feat(font): 系统管理里可设置正文字体(font 目录或链接)
- 新增 GET /api/fonts 列出二进制同级 font/ 目录里的字体文件,/fonts/<文件名> 对外提供(只认一级文件名,拒绝穿越) - 站点设置新增 font.url / font.family,校验只接受 http(s):// 或 /fonts/ 开头的链接 - 前台按设置注入 @font-face 或 CSS 链接并覆盖 --su-font-sans;留空回到内置文楷 - 后台「系统管理」新增字体选择 + 链接 + 族名;撤销/保存按钮覆盖该页 - 撤掉上一轮的霞鹜新晰黑改动
This commit is contained in:
@@ -13,10 +13,10 @@
|
||||
document.documentElement.dataset.scheme = dark ? 'dark' : 'light'
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<!-- 正文字体:霞鹜新晰黑(cn-fontsource 按 unicode-range 分片,按需加载) -->
|
||||
<!-- 内置正文字体:霞鹜文楷;后台「系统管理 → 字体」可以换成 font 目录里的字体或外部链接 -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/cn-fontsource-lxgw-neo-xi-hei-regular/font.css"
|
||||
href="https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/style.min.css"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<title>Stash</title>
|
||||
|
||||
@@ -333,6 +333,7 @@ export default function AppNext() {
|
||||
show_nav: true,
|
||||
},
|
||||
background: cachedBackground ?? { light: '', dark: '' },
|
||||
font: { url: '', family: '' },
|
||||
search: {
|
||||
default: 'internal',
|
||||
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
|
||||
@@ -408,6 +409,41 @@ export default function AppNext() {
|
||||
}
|
||||
}, [scheme])
|
||||
|
||||
// 正文字体:空链接用内置文楷;否则注入 @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()
|
||||
|
||||
if (url === '') {
|
||||
root.style.removeProperty('--su-font-sans')
|
||||
return
|
||||
}
|
||||
|
||||
const familyName = family === '' ? 'Stash Font' : family
|
||||
if (url.toLowerCase().endsWith('.css')) {
|
||||
const link = document.createElement('link')
|
||||
link.id = 'stash-font-link'
|
||||
link.rel = 'stylesheet'
|
||||
link.href = url
|
||||
document.head.appendChild(link)
|
||||
} else {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'stash-font-face'
|
||||
// 用 JSON.stringify 包成 CSS 字符串,引号 / 反斜杠不用自己转义
|
||||
style.textContent = `@font-face{font-family:${JSON.stringify(familyName)};src:url(${JSON.stringify(url)});font-display:swap}`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
root.style.setProperty(
|
||||
'--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])
|
||||
|
||||
/** 页头的明暗切换:只写本机偏好,不需要登录;站点默认值在后台改 */
|
||||
const toggleTheme = () => {
|
||||
const next: ThemeChoice = isDark ? 'light' : 'dark'
|
||||
|
||||
@@ -30,6 +30,24 @@ type ProjectEventsHandlers = {
|
||||
onConnectionChange?: (connected: boolean) => void
|
||||
}
|
||||
|
||||
export type FontFile = {
|
||||
name: string
|
||||
/** `/fonts/<文件名>`,直接当作字体文件地址用 */
|
||||
url: string
|
||||
}
|
||||
|
||||
/** 读取服务端 `font/` 目录里的字体文件(后台字体下拉用)。 */
|
||||
export async function fetchFonts(): Promise<FontFile[]> {
|
||||
try {
|
||||
const response = await fetch('/api/fonts', { headers: { Accept: 'application/json' } })
|
||||
if (!response.ok) return []
|
||||
const payload = (await response.json()) as { fonts?: FontFile[] }
|
||||
return Array.isArray(payload.fonts) ? payload.fonts : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅服务端的项目快照推送。
|
||||
* 浏览器不支持 SSE 时静默降级为空清理函数,页面仍可用手动刷新。
|
||||
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
} from '../../manage'
|
||||
import { createItem, deleteItem, fetchItem, saveItem } from '../../items'
|
||||
import { useProjects } from '../../useProjects'
|
||||
import type { Project } from '../../api'
|
||||
import { fetchFonts, type FontFile, type Project } from '../../api'
|
||||
import siteBgLight from '../../assets/site-bg.jpg'
|
||||
import siteBgDark from '../../assets/site-bg-dark.jpg'
|
||||
|
||||
@@ -411,12 +411,18 @@ export function AdminNext() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'system' && <SystemPanel />}
|
||||
{tab === 'system' && (
|
||||
<SystemPanel
|
||||
font={draft.font}
|
||||
onChangeFont={(font) => setDraft({ ...draft, font })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(tab === 'home-header' ||
|
||||
tab === 'home-background' ||
|
||||
tab === 'home-search' ||
|
||||
tab === 'profile') && (
|
||||
tab === 'profile' ||
|
||||
tab === 'system') && (
|
||||
<Space gap="sm" justify="end" className="admin-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -2271,11 +2277,23 @@ function ProfilePanel({
|
||||
)
|
||||
}
|
||||
|
||||
function SystemPanel() {
|
||||
function SystemPanel({
|
||||
font,
|
||||
onChangeFont,
|
||||
}: {
|
||||
font: SiteSettings['font']
|
||||
onChangeFont: (font: SiteSettings['font']) => void
|
||||
}) {
|
||||
const [current, setCurrent] = useState('')
|
||||
const [next, setNext] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ tone: 'info' | 'error'; text: string } | null>(null)
|
||||
const [fonts, setFonts] = useState<FontFile[]>([])
|
||||
|
||||
// 字体列表来自服务端 font/ 目录;目录空时下拉只有「内置」
|
||||
useEffect(() => {
|
||||
void fetchFonts().then(setFonts)
|
||||
}, [])
|
||||
|
||||
const change = async () => {
|
||||
setBusy(true)
|
||||
@@ -2291,8 +2309,56 @@ function SystemPanel() {
|
||||
setNext('')
|
||||
}
|
||||
|
||||
const localFont = font.url.startsWith('/fonts/') ? font.url : ''
|
||||
|
||||
return (
|
||||
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||||
<Text size="sm" strong>
|
||||
正文字体
|
||||
</Text>
|
||||
<Text size="sm" tone="muted">
|
||||
说明:字体文件放进程序同级的 font/ 目录,这里会自动列出来;也可以直接填字体文件或字体 CSS
|
||||
的链接。留空使用内置的霞鹜文楷。
|
||||
</Text>
|
||||
<FormField
|
||||
label="font 目录里的字体"
|
||||
htmlFor="font-file"
|
||||
hint={fonts.length === 0 ? 'font/ 目录里还没有字体文件' : '选中的字体通过 /fonts/ 对外提供'}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: '使用内置字体(霞鹜文楷)' },
|
||||
...fonts.map((file) => ({ value: file.url, label: file.name })),
|
||||
]}
|
||||
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 })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="当前密码" htmlFor="current-password">
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/* 页面级基础:字号 / 字体 / 重置。颜色与间距全部来自 @stash/ui 的令牌。 */
|
||||
|
||||
:root {
|
||||
/* 正文字体:霞鹜新晰黑。@stash/ui 默认是霞鹜文楷,这里在应用层换掉,不影响组件库本身 */
|
||||
--su-font-sans:
|
||||
'LXGW Neo XiHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans CN', sans-serif;
|
||||
font-family: var(--su-font-sans);
|
||||
font-size: var(--su-font-size-md);
|
||||
line-height: var(--su-line-height-md);
|
||||
|
||||
@@ -30,6 +30,13 @@ export type BackgroundSettings = {
|
||||
dark: string
|
||||
}
|
||||
|
||||
export type FontSettings = {
|
||||
/** 字体文件或 CSS 链接;空表示用内置霞鹜文楷 */
|
||||
url: string
|
||||
/** 字体族名:CSS 链接时填 CSS 里声明的名字;字体文件留空则用 Stash Font */
|
||||
family: string
|
||||
}
|
||||
|
||||
const BACKGROUND_CACHE_KEY = 'stash-background'
|
||||
|
||||
/**
|
||||
@@ -126,6 +133,7 @@ export type SiteSettings = {
|
||||
show_nav: boolean
|
||||
}
|
||||
background: BackgroundSettings
|
||||
font: FontSettings
|
||||
search: SearchSettings
|
||||
profile: ProfileSettings
|
||||
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
|
||||
@@ -195,6 +203,7 @@ export function defaultSettings(): SiteSettings {
|
||||
show_nav: true,
|
||||
},
|
||||
background: { light: '', dark: '' },
|
||||
font: { url: '', family: '' },
|
||||
search: {
|
||||
default: 'internal',
|
||||
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
|
||||
|
||||
Reference in New Issue
Block a user