diff --git a/HANDOFF.md b/HANDOFF.md index 0092ee7..cbcd121 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -113,7 +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": "" }, // 空 = 内置背景 - "font": { "url": "", "family": "" }, // 空 = 内置文楷;url 为 /fonts/xxx 或外部链接 + "font": { "source": "builtin", "current": "", "items": [] }, // builtin = 文楷;custom = 用 items 里 current 那条 "search": { "default": "internal", "engines": [ @@ -149,7 +149,7 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等 - 侧边栏:资料卡 / 本地服务 / 分组导航显隐; - 内容卡片:卡片 + 勾选分组 + 预览条数 + 排序; - **资料管理**:头像、名称、角色、简介、位置; -- **系统管理**:改密码、下载备份、正文字体(`font/` 目录里的文件或外部字体 / CSS 链接)。 +- **系统管理**:改密码、下载备份、正文字体(先选内置 / 字体链接;链接模式下可存多套字体,启用其中一条)。 搜索框行为: diff --git a/README.md b/README.md index 5c569fd..690d740 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 - **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除; - **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除; - **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示); - - **资料管理**与**系统管理**(改密码、下载整库备份;系统管理里还能设正文字体:从程序同级的 `font/` 目录选,或填字体链接); + - **资料管理**与**系统管理**(改密码、下载整库备份;系统管理里还能设正文字体:内置文楷 / 字体链接二选一,链接模式下可存多套字体(`font/` 目录里的文件或外部链接)并随时启用其中一套); - 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效; - 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁; - `/admin` 的「系统管理」页可以改密码、下载整库备份(`VACUUM INTO` 快照); diff --git a/src/db/settings.rs b/src/db/settings.rs index 2df925b..7d8b4ba 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -13,6 +13,7 @@ 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 MAX_FONT_LINKS: usize = 20; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] @@ -119,17 +120,41 @@ pub struct BackgroundSettings { pub dark: String, } -/// 正文字体:空链接表示内置文楷;否则是 `font/` 目录里的文件(`/fonts/...`) -/// 或外部字体 / CSS 链接。 +/// 正文字体来源:内置霞鹜文楷,或用户存的字体链接。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum FontSource { + #[default] + Builtin, + Custom, +} + +/// 一条存下来的字体配置:名字只用于后台下拉,url 才是真正加载的东西。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default)] -pub struct FontSettings { - /// 字体文件或 CSS 链接;空字符串表示用内置霞鹜文楷 +pub struct FontLink { + /// 唯一编码,由前端生成(删除 / 重排都不影响当前选择) + pub key: String, + /// 后台下拉里显示的名字 + pub name: String, + /// 字体文件或 CSS 链接:`/fonts/xxx` 或 `http(s)://…` pub url: String, /// 字体族名:链接到 CSS 时填 CSS 里声明的名字;字体文件留空则用 `Stash Font` pub family: String, } +/// 正文字体设置:先选来源(内置 / 字体链接),选「字体链接」时用 `current` 指向启用的那条。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(default)] +pub struct FontSettings { + /// 字体来源:内置文楷,或下面保存的字体链接 + pub source: FontSource, + /// 当前启用的 `FontLink.key`;为空时用第一条 + pub current: String, + /// 存下来的字体:常用字体放这里,切换时不用重新填 + pub items: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default)] pub struct SearchEngineSettings { @@ -366,21 +391,46 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> { "暗色背景地址最多 500 个字符".into(), )); } - if settings.font.url.chars().count() > 500 { - return Err(StashError::InvalidDirectory("字体链接最多 500 个字符".into())); + if settings.font.items.len() > MAX_FONT_LINKS { + return Err(StashError::InvalidDirectory(format!( + "字体最多保存 {MAX_FONT_LINKS} 条" + ))); } - let font_url = settings.font.url.trim(); - if !font_url.is_empty() - && !(font_url.starts_with("http://") - || font_url.starts_with("https://") - || font_url.starts_with("/fonts/")) - { - return Err(StashError::InvalidDirectory( - "字体链接需以 http(s):// 或 /fonts/ 开头".into(), - )); + let mut font_keys = std::collections::HashSet::new(); + for link in &settings.font.items { + let key = link.key.trim(); + if key.is_empty() || key.chars().count() > 60 { + return Err(StashError::InvalidDirectory("字体编码需为 1–60 个字符".into())); + } + if !font_keys.insert(key) { + return Err(StashError::InvalidDirectory("字体编码不能重复".into())); + } + let name = link.name.trim(); + if name.is_empty() || name.chars().count() > 60 { + return Err(StashError::InvalidDirectory("字体名称需为 1–60 个字".into())); + } + let url = link.url.trim(); + if url.chars().count() > 500 { + return Err(StashError::InvalidDirectory("字体链接最多 500 个字符".into())); + } + if url.is_empty() + || !(url.starts_with("http://") + || url.starts_with("https://") + || url.starts_with("/fonts/")) + { + return Err(StashError::InvalidDirectory( + "字体链接需以 http(s):// 或 /fonts/ 开头".into(), + )); + } + if link.family.chars().count() > 100 { + return Err(StashError::InvalidDirectory("字体族名最多 100 个字符".into())); + } } - if settings.font.family.chars().count() > 100 { - return Err(StashError::InvalidDirectory("字体族名最多 100 个字符".into())); + if settings.font.source == FontSource::Custom { + let current = settings.font.current.trim(); + if !current.is_empty() && !font_keys.contains(current) { + return Err(StashError::InvalidDirectory("当前启用的字体不存在".into())); + } } if settings.search.engines.len() > 10 { return Err(StashError::InvalidDirectory("搜索引擎最多 10 个".into())); @@ -581,11 +631,23 @@ mod tests { assert!(save(&mut conn, empty, 0).is_err()); let mut bad_font = Settings::default(); - bad_font.font.url = "javascript:alert(1)".into(); + bad_font.font.items.push(FontLink { + key: "font-1".into(), + name: "测试".into(), + url: "javascript:alert(1)".into(), + family: String::new(), + }); assert!(save(&mut conn, bad_font, 0).is_err()); let mut ok_font = Settings::default(); - ok_font.font.url = "/fonts/x.woff2".into(); + ok_font.font.items.push(FontLink { + key: "font-1".into(), + name: "测试".into(), + url: "/fonts/x.woff2".into(), + family: String::new(), + }); + ok_font.font.source = FontSource::Custom; + ok_font.font.current = "font-1".into(); assert!(save(&mut conn, ok_font, 0).is_ok()); } diff --git a/stash-web/src/App.css b/stash-web/src/App.css index ab2a8be..bdc128c 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -139,6 +139,16 @@ } } +/* 后台:一条已保存字体的编辑框 */ +.font-link { + display: flex; + flex-direction: column; + gap: var(--su-space-3); + padding: var(--su-space-4); + border: 1px solid var(--su-border); + border-radius: var(--su-radius-sm); +} + /* 后台「首页 Logo」的实时预览:外观交给库里的 Brand,这里只管外框 */ .brand-preview { display: flex; diff --git a/stash-web/src/AppNext.tsx b/stash-web/src/AppNext.tsx index fa34e5a..6434ff5 100644 --- a/stash-web/src/AppNext.tsx +++ b/stash-web/src/AppNext.tsx @@ -333,7 +333,7 @@ export default function AppNext() { show_nav: true, }, background: cachedBackground ?? { light: '', dark: '' }, - font: { url: '', family: '' }, + font: { source: 'builtin', current: '', items: [] }, search: { default: 'internal', engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })), @@ -409,15 +409,19 @@ export default function AppNext() { } }, [scheme]) - // 正文字体:空链接用内置文楷;否则注入 @font-face(字体文件)或 CSS 链接,并覆盖字体令牌 + // 正文字体:内置文楷,或启用「字体链接」里存好的某一条。 + // 自定义时注入 @font-face(字体文件)或 CSS 链接,并覆盖字体令牌。 useEffect(() => { - const url = site.font.url.trim() - const family = site.font.family.trim() const root = document.documentElement - document.getElementById('stash-font-link')?.remove() document.getElementById('stash-font-face')?.remove() + // current 指向的条目被删掉时退回第一条 + const active = + site.font.items.find((item) => item.key === site.font.current) ?? site.font.items[0] + const url = site.font.source === 'custom' && active !== undefined ? active.url.trim() : '' + const family = active?.family.trim() ?? '' + if (url === '') { root.style.removeProperty('--su-font-sans') return @@ -442,7 +446,7 @@ export default function AppNext() { '--su-font-sans', `${JSON.stringify(familyName)}, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans CN', sans-serif`, ) - }, [site.font.url, site.font.family]) + }, [site.font]) /** 页头的明暗切换:只写本机偏好,不需要登录;站点默认值在后台改 */ const toggleTheme = () => { diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx index 6337631..a284dba 100644 --- a/stash-web/src/components/admin/AdminNext.tsx +++ b/stash-web/src/components/admin/AdminNext.tsx @@ -43,6 +43,8 @@ import { saveSettings, TONE_OPTIONS, type BackgroundSettings, + type FontLink, + type FontSource, type ProfileSettings, type SearchSettings, type SettingsDocument, @@ -2309,7 +2311,29 @@ function SystemPanel({ setNext('') } - const localFont = font.url.startsWith('/fonts/') ? font.url : '' + const activeKey = font.items.some((item) => item.key === font.current) + ? font.current + : (font.items[0]?.key ?? '') + const update = (patch: Partial) => onChangeFont({ ...font, ...patch }) + const updateItem = (key: string, patch: Partial) => + update({ items: font.items.map((item) => (item.key === key ? { ...item, ...patch } : item)) }) + const removeItem = (key: string) => { + const items = font.items.filter((item) => item.key !== key) + onChangeFont({ + ...font, + items, + current: font.current === key ? (items[0]?.key ?? '') : font.current, + }) + } + const addItem = (url: string, name: string) => { + const key = `font-${Date.now().toString(36)}-${font.items.length}` + onChangeFont({ + ...font, + source: 'custom', + items: [...font.items, { key, name, url, family: '' }], + current: font.current === '' ? key : font.current, + }) + } return ( @@ -2317,48 +2341,100 @@ function SystemPanel({ 正文字体 - 说明:字体文件放进程序同级的 font/ 目录,这里会自动列出来;也可以直接填字体文件或字体 CSS - 的链接。留空使用内置的霞鹜文楷。 + 说明:内置是霞鹜文楷。选「字体链接」后,可以在下面存几套常用字体(font/ 目录里的文件或外部 + 链接),需要哪个就启用哪个,不用每次重填。 - + onChangeFont({ ...font, url: event.target.value })} - /> - - - onChangeFont({ ...font, family: event.target.value })} + value={font.source} + onChange={(source) => update({ source: source as FontSource })} /> + {font.source === 'custom' && ( + <> + + updateItem(item.key, { name: event.target.value })} + /> + + + updateItem(item.key, { family: event.target.value })} + /> + + + + updateItem(item.key, { url: event.target.value })} + /> + + + removeItem(item.key)}>删除这条 + + + ))} + + + + {fonts.length > 0 && ( +