feat(font): 字体分为内置/链接两种来源,链接可存多套并启用

- 设置改为 font.source(builtin|custom) + current + items[]
- 每条链接含 key/name/url/family;后台可加、改、删,选一条启用
- 前端按启用的那条注入 @font-face / CSS 链接,删除当前项时退回第一条
- 校验:条数上限 20、key 唯一、url 仅 http(s):// 或 /fonts/
This commit is contained in:
2026-11-05 18:54:58 +08:00
parent 0bb4cc354a
commit 97b9d00cf3
7 changed files with 232 additions and 67 deletions
+2 -2
View File
@@ -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 链接)。
- **系统管理**:改密码、下载备份、正文字体(先选内置 / 字体链接;链接模式下可存多套字体,启用其中一条)。
搜索框行为:
+1 -1
View File
@@ -55,7 +55,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
- **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除;
- **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除;
- **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示);
- **资料管理**与**系统管理**(改密码、下载整库备份;系统管理里还能设正文字体:从程序同级的 `font/` 目录选,或填字体链接);
- **资料管理**与**系统管理**(改密码、下载整库备份;系统管理里还能设正文字体:内置文楷 / 字体链接二选一,链接模式下可存多套字体(`font/` 目录里的文件或外部链接)并随时启用其中一套);
- 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效;
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
- `/admin` 的「系统管理」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
+81 -19
View File
@@ -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<FontLink>,
}
#[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());
}
+10
View File
@@ -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;
+10 -6
View File
@@ -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 = () => {
+112 -36
View File
@@ -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<SiteSettings['font']>) => onChangeFont({ ...font, ...patch })
const updateItem = (key: string, patch: Partial<FontLink>) =>
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 (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
@@ -2317,48 +2341,100 @@ function SystemPanel({
正文字体
</Text>
<Text size="sm" tone="muted">
说明:字体文件放进程序同级的 font/ 目录,这里会自动列出来;也可以直接填字体文件或字体 CSS
的链接。留空使用内置的霞鹜文楷。
说明:内置是霞鹜文楷。选「字体链接」后,可以在下面存几套常用字体(font/ 目录里的文件或外部
链接),需要哪个就启用哪个,不用每次重填。
</Text>
<FormField
label="font 目录里的字体"
htmlFor="font-file"
hint={fonts.length === 0 ? 'font/ 目录里还没有字体文件' : '选中的字体通过 /fonts/ 对外提供'}
>
<FormField label="字体来源" htmlFor="font-source">
<Select
options={[
{ value: '', label: '使用内置字体(霞鹜文楷)' },
...fonts.map((file) => ({ value: file.url, label: file.name })),
{ value: 'builtin', label: '内置(霞鹜文楷)' },
{ value: 'custom', label: '字体链接' },
]}
value={localFont}
onChange={(url) => onChangeFont({ ...font, url })}
/>
</FormField>
<FormField
label="或:字体链接"
htmlFor="font-url"
hint="woff2 / woff / ttf / otf 文件,或字体 CSS 链接;填了以这里为准"
>
<Input
id="font-url"
value={font.url}
placeholder="https://… 或 /fonts/xxx.woff2"
onChange={(event) => onChangeFont({ ...font, url: event.target.value })}
/>
</FormField>
<FormField
label="字体族名"
htmlFor="font-family"
hint="链接到 CSS 时填 CSS 里声明的 font-family;字体文件可留空"
>
<Input
id="font-family"
value={font.family}
placeholder="例如 LXGW Neo XiHei"
onChange={(event) => onChangeFont({ ...font, family: event.target.value })}
value={font.source}
onChange={(source) => update({ source: source as FontSource })}
/>
</FormField>
{font.source === 'custom' && (
<>
<FormField
label="当前使用的字体"
htmlFor="font-current"
hint={font.items.length === 0 ? '还没有字体,先在下面添加一条' : '从已保存的字体里选一个启用'}
>
<Select
options={font.items.map((item) => ({
value: item.key,
label: item.name === '' ? item.key : item.name,
}))}
value={activeKey}
onChange={(current) => update({ current })}
/>
</FormField>
{font.items.map((item) => (
<div className="font-link" key={item.key}>
<div className="admin-row">
<FormField label="名称" htmlFor={`font-name-${item.key}`}>
<Input
id={`font-name-${item.key}`}
value={item.name}
placeholder="例如 观感字体"
onChange={(event) => updateItem(item.key, { name: event.target.value })}
/>
</FormField>
<FormField
label="字体族名"
htmlFor={`font-family-${item.key}`}
hint="CSS 链接时填 CSS 里声明的 font-family"
>
<Input
id={`font-family-${item.key}`}
value={item.family}
placeholder="字体文件可留空"
onChange={(event) => updateItem(item.key, { family: event.target.value })}
/>
</FormField>
</div>
<FormField
label="字体链接"
htmlFor={`font-url-${item.key}`}
hint="woff2 / woff / ttf / otf 文件,或字体 CSS 链接"
>
<Input
id={`font-url-${item.key}`}
value={item.url}
placeholder="/fonts/xxx.woff2 或 https://…"
onChange={(event) => updateItem(item.key, { url: event.target.value })}
/>
</FormField>
<Space gap="sm" justify="end">
<TextButton onClick={() => removeItem(item.key)}>删除这条</TextButton>
</Space>
</div>
))}
<Space gap="sm">
<Button variant="secondary" onClick={() => addItem('', '新字体')}>
添加字体
</Button>
{fonts.length > 0 && (
<Select
options={[
{ value: '', label: '从 font 目录添加…' },
...fonts.map((file) => ({ value: file.url, label: file.name })),
]}
value=""
onChange={(url) => {
const file = fonts.find((entry) => entry.url === url)
if (file !== undefined) addItem(file.url, file.name.replace(/\.[^.]+$/, ''))
}}
/>
)}
</Space>
</>
)}
<FormField label="当前密码" htmlFor="current-password">
<PasswordInput
id="current-password"
+16 -3
View File
@@ -30,13 +30,26 @@ export type BackgroundSettings = {
dark: string
}
export type FontSettings = {
/** 字体文件或 CSS 链接;空表示用内置霞鹜文楷 */
export type FontSource = 'builtin' | 'custom'
/** 一条存下来的字体配置:名字只用于后台下拉,url 才是真正加载的东西 */
export type FontLink = {
key: string
name: string
url: string
/** 字体族名:CSS 链接时填 CSS 里声明的名字;字体文件留空则用 Stash Font */
family: string
}
export type FontSettings = {
/** 字体来源:内置霞鹜文楷,或下面保存的字体链接 */
source: FontSource
/** 当前启用的 FontLink.key;为空时用第一条 */
current: string
/** 存下来的字体:常用字体放这里,切换时不用重新填 */
items: FontLink[]
}
const BACKGROUND_CACHE_KEY = 'stash-background'
/**
@@ -203,7 +216,7 @@ export function defaultSettings(): SiteSettings {
show_nav: true,
},
background: { light: '', dark: '' },
font: { url: '', family: '' },
font: { source: 'builtin', current: '', items: [] },
search: {
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),