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:
+2
-2
@@ -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 链接)。
|
||||
- **系统管理**:改密码、下载备份、正文字体(先选内置 / 字体链接;链接模式下可存多套字体,启用其中一条)。
|
||||
|
||||
搜索框行为:
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
||||
- **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除;
|
||||
- **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除;
|
||||
- **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示);
|
||||
- **资料管理**与**系统管理**(改密码、下载整库备份;系统管理里还能设正文字体:从程序同级的 `font/` 目录选,或填字体链接);
|
||||
- **资料管理**与**系统管理**(改密码、下载整库备份;系统管理里还能设正文字体:内置文楷 / 字体链接二选一,链接模式下可存多套字体(`font/` 目录里的文件或外部链接)并随时启用其中一套);
|
||||
- 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效;
|
||||
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
|
||||
- `/admin` 的「系统管理」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
||||
|
||||
+75
-13
@@ -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,22 +391,47 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
|
||||
"暗色背景地址最多 500 个字符".into(),
|
||||
));
|
||||
}
|
||||
if settings.font.url.chars().count() > 500 {
|
||||
if settings.font.items.len() > MAX_FONT_LINKS {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"字体最多保存 {MAX_FONT_LINKS} 条"
|
||||
)));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
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/"))
|
||||
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 settings.font.family.chars().count() > 100 {
|
||||
if link.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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,47 +2341,99 @@ 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 })}
|
||||
value={font.source}
|
||||
onChange={(source) => update({ source: source as FontSource })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{font.source === 'custom' && (
|
||||
<>
|
||||
<FormField
|
||||
label="或:字体链接"
|
||||
htmlFor="font-url"
|
||||
hint="woff2 / woff / ttf / otf 文件,或字体 CSS 链接;填了以这里为准"
|
||||
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-url"
|
||||
value={font.url}
|
||||
placeholder="https://… 或 /fonts/xxx.woff2"
|
||||
onChange={(event) => onChangeFont({ ...font, url: event.target.value })}
|
||||
id={`font-name-${item.key}`}
|
||||
value={item.name}
|
||||
placeholder="例如 观感字体"
|
||||
onChange={(event) => updateItem(item.key, { name: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="字体族名"
|
||||
htmlFor="font-family"
|
||||
hint="链接到 CSS 时填 CSS 里声明的 font-family;字体文件可留空"
|
||||
htmlFor={`font-family-${item.key}`}
|
||||
hint="CSS 链接时填 CSS 里声明的 font-family"
|
||||
>
|
||||
<Input
|
||||
id="font-family"
|
||||
value={font.family}
|
||||
placeholder="例如 LXGW Neo XiHei"
|
||||
onChange={(event) => onChangeFont({ ...font, family: event.target.value })}
|
||||
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
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
Reference in New Issue
Block a user