From 4ce1b7e7c147d19988a3f881b5d3bebd71a4abb0 Mon Sep 17 00:00:00 2001 From: CNWei Date: Thu, 5 Nov 2026 12:45:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(cards):=20=E5=8D=A1=E7=89=87=E5=A4=96?= =?UTF-8?q?=E8=A7=82=E5=8F=AF=E9=85=8D=E7=BD=AE=EF=BC=88=E4=B8=8D=E9=80=8F?= =?UTF-8?q?=E6=98=8E=E5=BA=A6=20/=20=E6=AF=9B=E7=8E=BB=E7=92=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 站点设置新增 card.opacity(40–100)与 card.blur,后台「首页管理 → 卡片外观」可调 - 前台所有卡片(首页 / 分组页 / 详情 / 侧栏)共用一套 CSS 变量 - 顺带带上上一轮:背景缓存、搜索记忆、明暗图标跟随实际状态、卡片描边/阴影、分组页卡片外壳 --- HANDOFF.md | 8 ++- README.md | 2 +- src/db/settings.rs | 35 +++++++++++ stash-web/src/App.css | 58 ++++++++++++++++--- stash-web/src/AppNext.tsx | 49 ++++++++++++++-- stash-web/src/components/admin/AdminNext.tsx | 57 +++++++++++++++++- .../src/components/header/SiteHeader.tsx | 2 +- stash-web/src/settings.ts | 37 ++++++++++++ 8 files changed, 229 insertions(+), 19 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 17e0a53..a772e7f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -113,6 +113,7 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等 "header": { "enabled": true, "speed": 24, "gap": 26, "items": [...] }, "sidebar": { "show_profile": true, "show_service": false, "show_nav": true }, "background": { "light": "", "dark": "" }, // 空 = 内置背景 + "card": { "opacity": 100, "blur": false }, // 卡片不透明度 40–100 + 毛玻璃 "search": { "default": "internal", "engines": [ @@ -147,6 +148,7 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等 - 搜索框:默认搜索项、五个外部引擎的名称 / URL / 启停、恢复默认; - 侧边栏:资料卡 / 本地服务 / 分组导航显隐; - 内容卡片:卡片 + 勾选分组 + 预览条数 + 排序; + - 卡片外观:前台所有卡片的不透明度(40–100)与毛玻璃开关; - **资料管理**:头像、名称、角色、简介、位置; - **系统管理**:改密码、下载备份。 @@ -156,13 +158,15 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等 - 第一项固定「站内搜索」,分隔线后是启用的外部引擎; - 站内模式:输入过滤本地内容;回车打开第一个匹配条目/分组; - 外部模式:输入**不再过滤**本地内容;回车用该引擎 URL 新标签页搜索; -- 当前选择只存在当前页面会话,刷新后回到后台默认项。 +- 当前选择存在本机 localStorage(`stash-search-engine`),刷新后仍用它;引擎被停用 / 删除时回落到后台默认项。 主题: - 深色背景来自 Alright 演示站,资源 `site-bg-dark.jpg`; - 明暗切换优先用 View Transition 整页快照交叉淡入(160ms),不支持时回退到 CSS 颜色过渡; - 首帧由 `index.html` 内联脚本按 localStorage / 系统偏好先上色; +- 背景地址也缓存在 localStorage(`stash-background`),刷新首帧直接用它,设置回来再校准,不会先闪内置背景; +- 页头图标显示**当前**实际明暗(深色月亮 / 浅色太阳),点它是切到另一侧; - 背景图两层(light/dark)交叉淡入。 ## 8. HTTP API(当前保留) @@ -208,7 +212,7 @@ PATCH /api/auth/password | --- | --- | | 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 | | 搜索引擎 | id 固定五个;新增需同时补 EngineIcon、后端校验、前端默认值、后台面板 | -| 搜索选择记忆 | 不持久化;刷新按后台 `search.default` | +| 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 | | 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 | | 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 | | 备份恢复 | 只有 `/api/backup` 下载;恢复需停机替换库文件 | diff --git a/README.md b/README.md index 6526f5c..25a88ec 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 - 登录后点「站点设置」进入 `/admin`,左侧导航 + 右侧内容: - **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除; - **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除; - - **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示); + - **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示)、**卡片外观**(所有卡片的不透明度 40–100 与毛玻璃开关); - **资料管理**与**系统管理**(改密码、下载整库备份); - 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效; - 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁; diff --git a/src/db/settings.rs b/src/db/settings.rs index b9b942c..9d90a33 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -13,6 +13,8 @@ pub const SETTINGS_KEY: &str = "site"; pub const MAX_HEADER_ITEMS: usize = 8; pub const MIN_SPEED: u32 = 8; pub const MAX_SPEED: u32 = 120; +pub const MIN_CARD_OPACITY: u32 = 40; +pub const MAX_CARD_OPACITY: u32 = 100; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] @@ -119,6 +121,25 @@ pub struct BackgroundSettings { pub dark: String, } +/// 卡片外观:透明度与毛玻璃。前台所有卡片共用一套(首页 / 分组页 / 详情页 / 侧栏)。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct CardSettings { + /// 卡片底色的不透明度(百分比,40–100) + pub opacity: u32, + /// 毛玻璃:背景模糊 + 提饱和度 + pub blur: bool, +} + +impl Default for CardSettings { + fn default() -> Self { + Self { + opacity: 100, + blur: false, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default)] pub struct SearchEngineSettings { @@ -288,6 +309,7 @@ pub struct Settings { pub header: HeaderSettings, pub sidebar: SidebarSettings, pub background: BackgroundSettings, + pub card: CardSettings, pub search: SearchSettings, pub profile: ProfileSettings, pub site: SiteMetaSettings, @@ -354,6 +376,11 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> { "暗色背景地址最多 500 个字符".into(), )); } + if !(MIN_CARD_OPACITY..=MAX_CARD_OPACITY).contains(&settings.card.opacity) { + return Err(StashError::InvalidDirectory(format!( + "卡片不透明度需在 {MIN_CARD_OPACITY}–{MAX_CARD_OPACITY} 之间" + ))); + } if settings.search.engines.len() > 10 { return Err(StashError::InvalidDirectory("搜索引擎最多 10 个".into())); } @@ -551,6 +578,14 @@ mod tests { let mut empty = Settings::default(); empty.header.items.clear(); assert!(save(&mut conn, empty, 0).is_err()); + + let mut too_transparent = Settings::default(); + too_transparent.card.opacity = MIN_CARD_OPACITY - 1; + assert!(save(&mut conn, too_transparent, 0).is_err()); + + let mut clear = Settings::default(); + clear.card.opacity = MAX_CARD_OPACITY; + assert!(save(&mut conn, clear, 0).is_ok()); } #[test] diff --git a/stash-web/src/App.css b/stash-web/src/App.css index a869fb2..f0a5943 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -1,12 +1,46 @@ /* 页面级样式:只保留「组件库没有」的部分(背景、外壳、封面兜底、正文版式)。 颜色 / 间距 / 字号全部取自 @stash/ui 的令牌。 */ -/* 主题级覆盖:放 :root 而不是 .app,前后台两个壳才能拿到同一套阴影 */ +/* 主题级覆盖:放 :root 而不是 .app,前后台两个壳才能拿到同一套描边/阴影。 + 卡片保留一层很浅的投影;浅色默认 gray-1 几乎看不见,描边提一档。 */ :root { + --su-card-border: var(--su-gray-3); --su-shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02); --su-shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06); } +/* 深色下描边要比卡片底(#202022)亮一档,才是一条实线 */ +:root[data-scheme='dark'] { + --su-card-border: var(--su-border-strong); +} + +/* 卡片外观(后台「卡片外观」可调):底色透明度 + 毛玻璃,前台所有卡片共用一套。 + 变量由 AppNext 按站点设置写到 上;没设置时就是实底、无毛玻璃。 */ +.app .section-card, +.app .profile-card, +.app .service-panel, +.app .nav-card, +.app .group-own-block, +.app .group-child-block, +.app .detail-nav, +.app .item-card, +.app .article-body-card, +.app .related-card { + background-color: color-mix( + in srgb, + var(--su-bg-card) var(--stash-card-alpha, 100%), + transparent + ); + backdrop-filter: var(--stash-card-blur, none); +} + +/* 卡片阴影默认会被滚动容器在左右边缘一刀切掉(卡片正好贴着容器边)。 + 负边距把容器撑宽 16px、再补同样的内边距,内容位置不变,阴影有地方画。 */ +#page-scroll { + margin: 0 -16px; + padding: 0 16px; +} + .app { position: relative; z-index: 1; @@ -1119,6 +1153,16 @@ a.entry-href:hover { overflow: hidden; } +/* 侧栏卡片也贴着 aside 的边缘,阴影同样会被裁掉。 + 撑宽 16px 再补等量内边距,卡片宽度和位置都不变,阴影有地方画(窄屏堆叠时不适用)。 */ +@media (min-width: 1101px) { + #page-scroll > aside { + width: calc(var(--layout-sider) + 32px); + margin: 0 -16px; + padding: 0 16px; + } +} + #page-scroll > aside .sidebar-wrap, #page-scroll > aside .sidebar-home { display: flex; @@ -1160,18 +1204,14 @@ a.entry-href:hover { /* 侧栏到内容区共用一条滚动虚化遮罩,同一水平高度 */ -/* 左侧卡片半透明,露出页面背景 */ -.sidebar-home .profile-card, -.sidebar-home .service-panel, -.sidebar-home .nav-card { - background-color: color-mix(in srgb, var(--su-bg-card) 64%, transparent); - border-color: color-mix(in srgb, var(--su-card-border) 55%, transparent); - backdrop-filter: blur(14px) saturate(1.15); -} +/* 侧栏卡片保持实底:原来 64% 半透明 + 毛玻璃,同一张卡落在背景图不同亮度的位置 + 会显出深浅差,明暗两套主题也分不出来。现在和首页卡片同一种底,只有背景图透出卡片外面。 */ .sidebar-home .nav-card { overflow: hidden; + border: 1px solid var(--su-card-border); border-radius: var(--su-radius-md); + box-shadow: var(--su-shadow-card); } /* 首页卡片:左侧卡片列表 + 右侧分组表格 */ diff --git a/stash-web/src/AppNext.tsx b/stash-web/src/AppNext.tsx index 7283762..8e83135 100644 --- a/stash-web/src/AppNext.tsx +++ b/stash-web/src/AppNext.tsx @@ -32,6 +32,8 @@ import { fetchSession, logout, type SessionState } from './auth' import { DEFAULT_SEARCH_ENGINES, fetchSettings, + readCachedBackground, + writeCachedBackground, type SettingsDocument, type SiteSettings, } from './settings' @@ -69,6 +71,25 @@ type Route = const INTERNAL_SEARCH = { id: 'internal', name: '站内搜索', url: '' } as const +const SEARCH_ENGINE_KEY = 'stash-search-engine' + +/** 上次选的搜索引擎:只存在这台浏览器,刷新后仍用它;失效时回落到站点默认 */ +function readSearchEngine(): string | null { + try { + return window.localStorage.getItem(SEARCH_ENGINE_KEY) + } catch { + return null + } +} + +function writeSearchEngine(id: string): void { + try { + window.localStorage.setItem(SEARCH_ENGINE_KEY, id) + } catch { + // 隐私模式下 localStorage 不可写,忽略即可 + } +} + function parseRoute(pathname: string): Route { const segments = pathname.split('/').filter(Boolean) if (segments[0] === 'interface') return { kind: 'api' } @@ -214,11 +235,13 @@ export default function AppNext() { const [keyword, setKeyword] = useState('') const [session, setSession] = useState(null) const [settings, setSettings] = useState(null) + /** 上次缓存的背景:设置回来之前先用它上屏,避免刷新时先闪内置背景 */ + const [cachedBackground] = useState(readCachedBackground) const [loginOpen, setLoginOpen] = useState(false) const [editingKey, setEditingKey] = useState(null) const [scrolled, setScrolled] = useState(false) const searchRef = useRef(null) - const [searchEngineId, setSearchEngineId] = useState(null) + const [searchEngineId, setSearchEngineId] = useState(readSearchEngine) const [engineOpen, setEngineOpen] = useState(false) const engineRef = useRef(null) @@ -246,6 +269,11 @@ export default function AppNext() { return () => source.close() }, []) + // 背景地址跟着设置存一份到本机:下次刷新首帧直接用它,不用等接口回来再换 + useEffect(() => { + if (settings !== null) writeCachedBackground(settings.settings.background) + }, [settings]) + useEffect(() => { const content = document.getElementById('content-scroll') const main = content?.parentElement @@ -304,7 +332,8 @@ export default function AppNext() { show_service: true, show_nav: true, }, - background: { light: '', dark: '' }, + background: cachedBackground ?? { light: '', dark: '' }, + card: { opacity: 100, blur: false }, search: { default: 'internal', engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })), @@ -380,6 +409,13 @@ export default function AppNext() { } }, [scheme]) + // 卡片外观:透明度和毛玻璃写在 的 CSS 变量上,前台所有卡片共用一套 + useEffect(() => { + const root = document.documentElement.style + root.setProperty('--stash-card-alpha', `${site.card.opacity}%`) + root.setProperty('--stash-card-blur', site.card.blur ? 'blur(14px) saturate(1.15)' : 'none') + }, [site.card.opacity, site.card.blur]) + /** 页头的明暗切换:只写本机偏好,不需要登录;站点默认值在后台改 */ const toggleTheme = () => { const next: ThemeChoice = isDark ? 'light' : 'dark' @@ -435,6 +471,7 @@ export default function AppNext() { const chooseEngine = (next: string) => { setSearchEngineId(next) + writeSearchEngine(next) setEngineOpen(false) searchRef.current?.focus() } @@ -631,7 +668,9 @@ export default function AppNext() { {group.entries.length > 0 ? ( - renderEntries(group, group.entries) + + {renderEntries(group, group.entries)} + ) : group.children.some((child) => groupEntryCount(child) > 0) ? null : ( )} @@ -640,7 +679,7 @@ export default function AppNext() { {group.children .filter((child) => child.enabled !== false) .map((child) => ( -
+
)} -
+ ))} diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx index a521af2..5d02de7 100644 --- a/stash-web/src/components/admin/AdminNext.tsx +++ b/stash-web/src/components/admin/AdminNext.tsx @@ -83,6 +83,7 @@ type TabKey = | 'content' | 'home-sidebar' | 'home-cards' + | 'home-cards-look' | 'home-header' | 'home-background' | 'home-search' @@ -102,6 +103,7 @@ const ADMIN_NAV: MenuItem[] = [ { key: 'home-search', label: '搜索框' }, { key: 'home-sidebar', label: '侧边栏' }, { key: 'home-cards', label: '内容卡片' }, + { key: 'home-cards-look', label: '卡片外观' }, ], }, { key: 'profile', label: '资料管理' }, @@ -281,7 +283,7 @@ export function AdminNext() {
} + icon={} onClick={() => void toggleTheme()} disabled={!authenticated || saving} /> @@ -390,6 +392,16 @@ export function AdminNext() { /> )} + {tab === 'home-cards-look' && ( +
+ 卡片外观 + setDraft({ ...draft, card })} + /> +
+ )} + {tab === 'home-background' && (