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:
+1
-1
@@ -88,7 +88,7 @@
|
||||
|
||||
| 角色 | 字体 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 正文 | **LXGW Neo XiHei(霞鹜新晰黑)** | 应用当前使用;`cdn.jsdelivr.net/npm/cn-fontsource-lxgw-neo-xi-hei-regular` 按 unicode-range 分片加载,在 `stash-web/src/index.css` 覆盖 `--su-font-sans`(组件库自身默认仍是文楷) |
|
||||
| 正文 | **LXGW WenKai(霞鹜文楷)** | 内置默认,`cdn.staticfile.net`;后台「系统管理 → 字体」可换成 `font/` 目录里的字体或外部字体链接 |
|
||||
| 回退 | `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif` | 字体加载失败时 |
|
||||
| 数字(主题) | `Outfit-Medium` | 仅主题的 `font-price` / `font-number` 使用,Stash 未引入 |
|
||||
| 等宽 | `ui-monospace, Consolas, monospace` | 路由地址等路径文本 |
|
||||
|
||||
+3
-1
@@ -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": "" }, // 空 = 内置背景
|
||||
"font": { "url": "", "family": "" }, // 空 = 内置文楷;url 为 /fonts/xxx 或外部链接
|
||||
"search": {
|
||||
"default": "internal",
|
||||
"engines": [
|
||||
@@ -148,7 +149,7 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等
|
||||
- 侧边栏:资料卡 / 本地服务 / 分组导航显隐;
|
||||
- 内容卡片:卡片 + 勾选分组 + 预览条数 + 排序;
|
||||
- **资料管理**:头像、名称、角色、简介、位置;
|
||||
- **系统管理**:改密码、下载备份。
|
||||
- **系统管理**:改密码、下载备份、正文字体(`font/` 目录里的文件或外部字体 / CSS 链接)。
|
||||
|
||||
搜索框行为:
|
||||
|
||||
@@ -176,6 +177,7 @@ GET /api/catalog2 分组树 + 卡片
|
||||
GET /api/catalog/events SSE:catalog 变化
|
||||
GET /api/settings
|
||||
PATCH /api/settings 需登录,{version,settings}
|
||||
GET /api/fonts font/ 目录里的字体清单(公开读)
|
||||
POST /api/nodes 需登录
|
||||
PATCH /api/nodes/{key} 需登录
|
||||
DELETE /api/nodes/{key} 需登录
|
||||
|
||||
@@ -55,7 +55,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
||||
- **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除;
|
||||
- **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除;
|
||||
- **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示);
|
||||
- **资料管理**与**系统管理**(改密码、下载整库备份);
|
||||
- **资料管理**与**系统管理**(改密码、下载整库备份;系统管理里还能设正文字体:从程序同级的 `font/` 目录选,或填字体链接);
|
||||
- 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效;
|
||||
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
|
||||
- `/admin` 的「系统管理」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
||||
@@ -77,6 +77,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
||||
| `/c/**`、`/g/**` | 前端路由地址(分组页 / 条目详情),没有实体文件时同样返回 `index.html` |
|
||||
| `/admin`、`/login`、`/interface` | 固定页面地址,同样返回 `index.html` |
|
||||
| `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) |
|
||||
| `/fonts/**` | 程序同级 `font/` 目录里的字体文件(后台字体设置用,`no-cache`) |
|
||||
| `/favicon.svg` | 内嵌前端根目录文件 |
|
||||
| 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` |
|
||||
| 仍未命中 | 内置 404 页面(显示请求路径) |
|
||||
@@ -544,7 +545,7 @@ data: {"changed":true}
|
||||
| --- | --- |
|
||||
| 健康与项目 | `GET /api/health`、`GET /api/projects`、`GET /api/projects/events`(SSE) |
|
||||
| 内容库 | `GET /api/catalog2`、`GET /api/catalog/events`(SSE)、`POST /api/nodes`、`PATCH/DELETE /api/nodes/{key}`、`GET /api/items/{key}`、`POST /api/items`、`PATCH/DELETE /api/items/{key}`、`POST /api/items/{key}/view`、`POST /api/items/{key}/like`、`POST /api/cards`、`PATCH/DELETE /api/cards/{id}`、`PUT /api/cards/{id}/groups` |
|
||||
| 站点设置 | `GET/PATCH /api/settings`、`GET /api/backup` |
|
||||
| 站点设置 | `GET/PATCH /api/settings`、`GET /api/fonts`(`font/` 目录里的字体清单)、`GET /api/backup` |
|
||||
| 认证 | `GET /api/auth/session`、`POST /api/auth/setup`、`POST /api/auth/login`、`POST /api/auth/logout`、`PATCH /api/auth/password` |
|
||||
|
||||
写接口(POST / PATCH / PUT / DELETE)都要求登录会话,并校验请求的 `Origin` 与本站同源。
|
||||
|
||||
@@ -4,6 +4,8 @@ use crate::error::StashError;
|
||||
|
||||
/// 数据目录名:与二进制同级,存放可被用户直接修改的内容库。
|
||||
pub const DATA_DIR_NAME: &str = "data";
|
||||
/// 字体目录名:与二进制同级,后台「系统管理 → 字体」只从这个目录列字体。
|
||||
pub const FONT_DIR_NAME: &str = "font";
|
||||
/// 内容库文件名:固定为这个名字,一个实例一个库。
|
||||
pub const DB_FILE_NAME: &str = "stash.db";
|
||||
|
||||
@@ -13,6 +15,8 @@ pub struct Config {
|
||||
pub root: PathBuf,
|
||||
/// 数据目录:存放内容库与将来的备份 / 导出文件
|
||||
pub data_dir: PathBuf,
|
||||
/// 字体目录:后台字体下拉只列这里的文件,通过 `/fonts/<文件名>` 对外提供
|
||||
pub font_dir: PathBuf,
|
||||
/// 内容库路径,固定为 `data/stash.db`
|
||||
pub db_path: PathBuf,
|
||||
/// 监听端口
|
||||
@@ -34,6 +38,7 @@ impl Default for Config {
|
||||
root: PathBuf::from("../public"),
|
||||
db_path: resolve_db_path(&data_dir),
|
||||
data_dir,
|
||||
font_dir: PathBuf::from(FONT_DIR_NAME),
|
||||
port: 8080,
|
||||
host: "127.0.0.1".to_string(),
|
||||
directory_listing: true,
|
||||
@@ -65,6 +70,18 @@ pub fn resolve_db_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(DB_FILE_NAME)
|
||||
}
|
||||
|
||||
/// 字体目录的默认位置:**二进制所在目录**下的 `font/`,理由同 `data/`。
|
||||
pub fn default_font_dir() -> PathBuf {
|
||||
default_font_dir_from(std::env::current_exe().ok().as_deref())
|
||||
}
|
||||
|
||||
fn default_font_dir_from(exe: Option<&Path>) -> PathBuf {
|
||||
match exe.and_then(Path::parent) {
|
||||
Some(dir) => dir.join(FONT_DIR_NAME),
|
||||
None => PathBuf::from(FONT_DIR_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验内容库没有落在静态托管目录里。
|
||||
///
|
||||
/// `ServeDir` 会把 `--root` 下的所有文件当静态资源对外提供,
|
||||
@@ -151,6 +168,15 @@ mod tests {
|
||||
assert_eq!(default_data_dir_from(None), PathBuf::from(DATA_DIR_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_dir_follows_the_binary_directory() {
|
||||
assert_eq!(
|
||||
default_font_dir_from(Some(Path::new(r"D:\apps\stash\stash.exe"))),
|
||||
PathBuf::from(r"D:\apps\stash\font")
|
||||
);
|
||||
assert_eq!(default_font_dir_from(None), PathBuf::from(FONT_DIR_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_path_is_fixed_inside_the_data_dir() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -119,6 +119,17 @@ pub struct BackgroundSettings {
|
||||
pub dark: String,
|
||||
}
|
||||
|
||||
/// 正文字体:空链接表示内置文楷;否则是 `font/` 目录里的文件(`/fonts/...`)
|
||||
/// 或外部字体 / CSS 链接。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct FontSettings {
|
||||
/// 字体文件或 CSS 链接;空字符串表示用内置霞鹜文楷
|
||||
pub url: String,
|
||||
/// 字体族名:链接到 CSS 时填 CSS 里声明的名字;字体文件留空则用 `Stash Font`
|
||||
pub family: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct SearchEngineSettings {
|
||||
@@ -288,6 +299,7 @@ pub struct Settings {
|
||||
pub header: HeaderSettings,
|
||||
pub sidebar: SidebarSettings,
|
||||
pub background: BackgroundSettings,
|
||||
pub font: FontSettings,
|
||||
pub search: SearchSettings,
|
||||
pub profile: ProfileSettings,
|
||||
pub site: SiteMetaSettings,
|
||||
@@ -354,6 +366,22 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
|
||||
"暗色背景地址最多 500 个字符".into(),
|
||||
));
|
||||
}
|
||||
if settings.font.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/"))
|
||||
{
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"字体链接需以 http(s):// 或 /fonts/ 开头".into(),
|
||||
));
|
||||
}
|
||||
if settings.font.family.chars().count() > 100 {
|
||||
return Err(StashError::InvalidDirectory("字体族名最多 100 个字符".into()));
|
||||
}
|
||||
if settings.search.engines.len() > 10 {
|
||||
return Err(StashError::InvalidDirectory("搜索引擎最多 10 个".into()));
|
||||
}
|
||||
@@ -551,6 +579,14 @@ mod tests {
|
||||
let mut empty = Settings::default();
|
||||
empty.header.items.clear();
|
||||
assert!(save(&mut conn, empty, 0).is_err());
|
||||
|
||||
let mut bad_font = Settings::default();
|
||||
bad_font.font.url = "javascript:alert(1)".into();
|
||||
assert!(save(&mut conn, bad_font, 0).is_err());
|
||||
|
||||
let mut ok_font = Settings::default();
|
||||
ok_font.font.url = "/fonts/x.woff2".into();
|
||||
assert!(save(&mut conn, ok_font, 0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+9
-1
@@ -3,7 +3,7 @@ use clap::Parser;
|
||||
use colored::*;
|
||||
use stash::{
|
||||
Config,
|
||||
config::{default_data_dir, display_path, resolve_db_path, validate_db_location},
|
||||
config::{default_data_dir, default_font_dir, display_path, resolve_db_path, validate_db_location},
|
||||
start,
|
||||
};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
@@ -94,10 +94,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
let data_dir = data_dir.canonicalize().unwrap_or(data_dir);
|
||||
let db_path = resolve_db_path(&data_dir);
|
||||
|
||||
// 字体目录:跟 data/ 一样放在二进制旁边,后台只从这里列字体
|
||||
let font_dir = default_font_dir();
|
||||
std::fs::create_dir_all(&font_dir)?;
|
||||
let font_dir = font_dir.canonicalize().unwrap_or(font_dir);
|
||||
let font_dir_display = display_path(&font_dir);
|
||||
|
||||
// 4. 构造配置并启动
|
||||
let config = Config {
|
||||
root: abs_root.clone(),
|
||||
data_dir: data_dir.clone(),
|
||||
font_dir,
|
||||
db_path: db_path.clone(),
|
||||
port: cli.port,
|
||||
host: cli.host,
|
||||
@@ -121,6 +128,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline());
|
||||
println!(" ➜ 托管路径: {}", display_path(&abs_root).white());
|
||||
println!(" ➜ 内容库: {}", display_path(&db_path).white());
|
||||
println!(" ➜ 字体目录: {}", font_dir_display.white());
|
||||
println!(" ➜ 前端资源: {}", "已内嵌 (stash-web/dist)".white());
|
||||
println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed());
|
||||
println!("{}", "--------------------------------------------------".bright_blue());
|
||||
|
||||
+103
@@ -589,6 +589,80 @@ async fn patch_settings(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 字体 ----------------
|
||||
|
||||
/// 字体文件后缀:后台字体下拉只列这几种。
|
||||
const FONT_EXTENSIONS: [&str; 4] = ["woff2", "woff", "ttf", "otf"];
|
||||
|
||||
/// GET /api/fonts —— `font/` 目录里的字体文件(后台字体下拉用,公开读)
|
||||
async fn list_fonts(State(state): State<AppState>) -> Response {
|
||||
let dir = state.config.font_dir.clone();
|
||||
match tokio::task::spawn_blocking(move || scan_fonts(&dir)).await {
|
||||
Ok(Ok(fonts)) => Json(serde_json::json!({ "fonts": fonts })).into_response(),
|
||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 列目录:目录不存在按「没有字体」处理,不让后台报错。
|
||||
fn scan_fonts(dir: &std::path::Path) -> std::io::Result<Vec<serde_json::Value>> {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
let mut fonts: Vec<(String, serde_json::Value)> = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(extension) = path.extension().and_then(|value| value.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !FONT_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str()) {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
fonts.push((
|
||||
name.to_string(),
|
||||
serde_json::json!({ "name": name, "url": format!("/fonts/{name}") }),
|
||||
));
|
||||
}
|
||||
fonts.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok(fonts.into_iter().map(|(_, value)| value).collect())
|
||||
}
|
||||
|
||||
/// GET /fonts/{path} —— `font/` 目录里的字体文件。
|
||||
/// 只认一级文件名:`..`、子目录一律 404,避免越界读文件。
|
||||
async fn font_asset(State(state): State<AppState>, Path(path): Path<String>) -> Response {
|
||||
let reject = || not_found_page(&format!("/fonts/{path}"));
|
||||
if path.is_empty() || path.contains('/') || path.contains('\\') || path.contains("..") {
|
||||
return reject();
|
||||
}
|
||||
|
||||
let file = state.config.font_dir.join(&path);
|
||||
let bytes = match tokio::fs::read(&file).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return reject(),
|
||||
};
|
||||
|
||||
let mime = mime_guess::from_path(&path).first_or_octet_stream();
|
||||
let content_type = HeaderValue::from_str(mime.as_ref())
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(header::CACHE_CONTROL, HeaderValue::from_static(HTML_CACHE)),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ---------------- 条目编辑 / 备份 ----------------
|
||||
|
||||
/// GET /api/items/{key} —— 编辑表单的初始值(公开,便于未登录时预览)
|
||||
@@ -970,6 +1044,8 @@ fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/logout", post(auth_logout))
|
||||
.route("/api/auth/password", patch(auth_password))
|
||||
.route("/api/settings", get(get_settings).patch(patch_settings))
|
||||
.route("/api/fonts", get(list_fonts))
|
||||
.route("/fonts/{*path}", get(font_asset))
|
||||
.route("/api/items/{key}", get(get_item).patch(patch_item).delete(delete_item))
|
||||
.route("/api/items", post(create_item))
|
||||
.route("/api/items/{key}/view", post(post_view))
|
||||
@@ -1092,6 +1168,33 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_fonts_lists_only_font_files() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/font-scan");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("应能建目录");
|
||||
std::fs::write(dir.join("b.ttf"), b"x").expect("应能写入");
|
||||
std::fs::write(dir.join("a.WOFF2"), b"x").expect("应能写入");
|
||||
std::fs::write(dir.join("note.txt"), b"x").expect("应能写入");
|
||||
|
||||
let fonts = scan_fonts(&dir).expect("应能列目录");
|
||||
let names: Vec<_> = fonts
|
||||
.iter()
|
||||
.map(|font| font["name"].as_str().unwrap_or_default())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["a.WOFF2", "b.ttf"]);
|
||||
assert_eq!(fonts[0]["url"], "/fonts/a.WOFF2");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_fonts_treats_missing_dir_as_empty() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/no-such-font-dir");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(scan_fonts(&dir).expect("目录不存在不应报错").is_empty());
|
||||
}
|
||||
|
||||
/// 发一个 GET,返回(状态码,Content-Type,响应体)。
|
||||
async fn get(path: &str) -> (StatusCode, String, String) {
|
||||
let response = router()
|
||||
|
||||
@@ -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