Compare commits

...
35 Commits
Author SHA1 Message Date
CNWei 9f1a8863f4 refactor(layout): 窄屏堆叠交给 @stash/ui 0.3.2,删掉对应补丁
- 去掉 #page-scroll 的 flex-wrap / aside / main 高度覆盖(组件库已处理)
- 左栏吸顶只在 >1100 生效,窄屏不再需要手动取消 sticky
- 保留应用自己的侧栏排布(资料卡 + 导航并排)与页头断点
2026-11-06 09:21:22 +08:00
CNWei a0be02b4f4 refactor(nav): 手机导航抽屉改用组件库 Drawer
- 删掉手写的抽屉(遮罩 / Esc / 标题 / 关闭按钮 / 动画)与对应 CSS
- 直接用 @stash/ui 的 Drawer(原生 dialog,自带焦点陷阱、Esc、backdrop 与滚动锁)
- 保留原来自定义的分组选择逻辑
2026-11-05 20:33:03 +08:00
CNWei ff5c6f05a2 fix(header): 平板/小屏笔记本的滚动信息不再被挤没,只在 ≤640 隐藏
- ≤1100 页头改 auto/1fr/auto:品牌和操作图标按内容宽度,滚动信息吃剩余空间(之前中间列固定抢 720,品牌被压到看不见)
- 滚动信息隐藏阈值从 820 下调到 640,和侧栏一致:641–1100 正常显示(700 宽约 419px),手机才收起
2026-11-05 20:25:21 +08:00
CNWei 9948e43e6c fix(mobile): 侧栏只在 ≤640 收起,平板/分屏宽度保留资料卡与分组导航 2026-11-05 20:16:04 +08:00
CNWei 9c8e05c0ee feat(mobile): 小屏收起侧栏,页头菜单按钮打开分组导航抽屉
- ≤820 隐藏整个侧栏(资料卡 / 本地服务 / 导航都收起),主内容直接顶上来
- 页头新增只在手机显示的菜单按钮,打开左侧抽屉:分组导航 Menu,选中后自动关闭并跳转
- 抽屉带遮罩、Esc 关闭、打开时锁住背后滚动;桌面不显示按钮
2026-11-05 20:05:38 +08:00
CNWei 6e17169ac4 fix(mobile): 窄屏主内容被挤出视口,侧栏与页头适配手机/平板
- ≤1100 时滚动容器不换行、aside/main 不再锁 100% 高(之前 main 被排到视口右侧,完全看不见)
- 平板:资料卡 + 导航卡两列并排;手机:只留导航卡(资料/服务卡隐藏)
- 手机页头:隐藏跑马灯,改两列(品牌 + 操作图标),高度 56
- 删掉一直没生效的 .sidebar 网格规则(被更高优先级的 flex 规则盖住)
2026-11-05 19:50:21 +08:00
CNWei 960ea1c5eb fix(admin): 字体卡片里名称/族名两个字段顶部对齐并等宽 2026-11-05 19:07:22 +08:00
CNWei 0aebd610fe fix(admin): 没有字体时不渲染「当前使用的字体」下拉 2026-11-05 19:02:41 +08:00
CNWei 97b9d00cf3 feat(font): 字体分为内置/链接两种来源,链接可存多套并启用
- 设置改为 font.source(builtin|custom) + current + items[]
- 每条链接含 key/name/url/family;后台可加、改、删,选一条启用
- 前端按启用的那条注入 @font-face / CSS 链接,删除当前项时退回第一条
- 校验:条数上限 20、key 唯一、url 仅 http(s):// 或 /fonts/
2026-11-05 18:54:58 +08:00
CNWei 0bb4cc354a feat(font): 系统管理里可设置正文字体(font 目录或链接)
- 新增 GET /api/fonts 列出二进制同级 font/ 目录里的字体文件,/fonts/<文件名> 对外提供(只认一级文件名,拒绝穿越)
- 站点设置新增 font.url / font.family,校验只接受 http(s):// 或 /fonts/ 开头的链接
- 前台按设置注入 @font-face 或 CSS 链接并覆盖 --su-font-sans;留空回到内置文楷
- 后台「系统管理」新增字体选择 + 链接 + 族名;撤销/保存按钮覆盖该页
- 撤掉上一轮的霞鹜新晰黑改动
2026-11-05 18:12:29 +08:00
CNWei e243a20b69 style(typography): 正文字体换成霞鹜新晰黑
- index.html 换用 cn-fontsource 的霞鹜新晰黑 webfont(unicode-range 分片)
- index.css 覆盖 --su-font-sans,组件库默认保持文楷
- ALRIGHT-DESIGN 字体表同步
2026-11-05 16:58:57 +08:00
CNWei 21825431c7 fix(sidebar): 分组导航卡补上与其他卡片一致的底色 2026-11-05 16:43:10 +08:00
CNWei 65c7fe79dd style(group): 分组页恢复卡片外壳,撤掉文字描边方案 2026-11-05 16:37:29 +08:00
CNWei 93f80af893 revert(cards): 撤掉卡片外观配置与分组页卡片外壳,改用文字对比度
- 回退 card.opacity / card.blur 设置与后台「卡片外观」面板
- 分组页内容恢复为直接压在背景图上,不再套卡片
- 分组页文字:次级文字提一档 + 同主题描边(深色暗描边、浅色亮描边)
- 保留上一轮:背景缓存、搜索记忆、明暗图标、卡片描边/阴影、侧栏实底
2026-11-05 14:18:18 +08:00
CNWei 4ce1b7e7c1 feat(cards): 卡片外观可配置(不透明度 / 毛玻璃)
- 站点设置新增 card.opacity(40–100)与 card.blur,后台「首页管理 → 卡片外观」可调
- 前台所有卡片(首页 / 分组页 / 详情 / 侧栏)共用一套 CSS 变量
- 顺带带上上一轮:背景缓存、搜索记忆、明暗图标跟随实际状态、卡片描边/阴影、分组页卡片外壳
2026-11-05 12:45:11 +08:00
CNWei a9a913942a docs: 更新交接文档到当前搜索/背景/主题状态
- 记录 develop 领先远端 19 个提交、stash-ui 三个未推送提交
- 更新 v23 库结构、settings(search/background)、后台 IA 与 API 清单
- 补充构建顺序、双份 git_helper、exe 占用等坑
2026-11-04 19:30:42 +08:00
CNWei 1d1ece6a2a fix(group): 分组面包屑保留详情页外框
用 Card.detail-nav 包裹 Breadcrumb,和条目/图文详情页一致
2026-11-04 19:27:42 +08:00
CNWei 9d1e5dcb60 fix(group): 分组页面包屑与详情页统一
分组页改用 @stash/ui 的 Breadcrumb,去掉带边框的按钮样式和独立分隔符
2026-11-04 19:25:20 +08:00
CNWei 4862f8fb6b fix(search): 外部引擎模式不再过滤本地内容
只有站内搜索模式才把输入用于本地过滤;Google 等模式保持页面内容不变,仅回车调用对应引擎
2026-11-04 19:18:43 +08:00
CNWei aa6f341505 feat(search): 按参考稿重做搜索框并支持后台配置
- 顶部悬浮引擎标签 + 下拉选项,使用 stash-ui 新增的 EngineIcon
- 站内搜索保持现有本地过滤与回车打开首个匹配项
- 外部引擎使用配置的 {query} 模板新标签页搜索
- settings 新增 search.default / search.engines,后台新增『搜索框』配置默认项、名称、地址与启停
- 默认默认搜索项为站内搜索,地址校验必须含 {query}
2026-11-04 18:11:22 +08:00
CNWei 5ae2088826 style(search): 引擎图标恢复 18px 2026-11-04 12:56:07 +08:00
CNWei 200908cef6 style(search): 引擎图标统一使用组件库图标
- 移除内联品牌 SVG,改用 @stash/ui 的 Icon 名称
- 快捷栏每个选项都显示图标,左侧触发器同步切换
2026-11-04 12:53:12 +08:00
CNWei 56f362f921 feat(search): 点击放大镜切换搜索引擎
- 搜索框左侧图标改为触发器,点击展开效果五快捷栏
- 选中后左侧图标切换为对应引擎图标(本地放大镜 / Google / Bing / 百度 / 搜狗 / 360)
- 搜索框占位文案随当前模式变化,原有过滤与回车行为保留
2026-11-04 12:50:02 +08:00
CNWei 8c1c658b15 feat(search): 效果五快捷栏加入本地搜索
- 引擎选项新增「本地」并作为默认;本地模式回车打开第一个匹配条目/分组
- 快捷栏改为贴在搜索框右侧内部展开,贴合效果五
- 外部引擎仍在新标签页搜索,输入时本地过滤逻辑保持
2026-11-04 12:39:31 +08:00
CNWei 038b7ecb42 feat(search): 搜索框支持切换搜索引擎
- 采用效果五:点击右侧引擎名展开横向快捷切换栏
- 支持 Google / Bing / 百度 / 搜狗 / 360,选择结果存 localStorage
- 输入时仍按原逻辑过滤本地项目;回车用当前引擎打开新标签搜索
- Esc 关闭切换栏,点击外部自动收起
2026-11-03 20:33:41 +08:00
CNWei a5d7859c6e fix(theme): 用整页快照过渡消除明暗割裂
- 支持 View Transition 时整页旧/新快照交叉淡入 160ms,背景图与底色不再各自过渡
- 不支持或减少动画偏好时回退到原来的颜色过渡
2026-11-03 20:30:05 +08:00
CNWei 5439ebf3be feat(admin): 增加背景管理
- settings 新增 background.light / background.dark,留空使用内置图
- 后台首页管理新增「背景管理」,两栏 URL 输入与实时预览,可一键恢复内置图
- 前台按设置覆盖明暗背景图,仍保留 65vh 与交叉淡入
- 保存/撤销沿用现有设置乐观锁流程
2026-11-03 20:23:57 +08:00
CNWei b9005dd514 style(theme): 暗色使用 Alright 同款深色背景
- 新增 site-bg-dark.jpg(Alright 演示站深色背景)
- 明暗背景拆成两层交叉淡入,切换时不再露出单层底色
- 保留 65vh 高度与底部渐隐
2026-11-03 20:18:29 +08:00
CNWei 1f8147dd73 fix(theme): 背景图不再跟着半程淡出
切深色时立即隐藏背景图,切浅色时等底色过渡完再淡入,避免 65vh 边界出现半黑半白
2026-11-03 20:14:03 +08:00
CNWei 5736bbc101 style(theme): 缩短明暗过渡到 160ms 2026-11-03 20:12:14 +08:00
CNWei 3330af818e fix(theme): 消除首帧主题闪动并柔化明暗过渡
- index.html 首帧按本机偏好设置 dark/light,避免先浅后深
- 实际 scheme 只写 dark/light(之前 auto 会覆盖成浅色)
- 切换时给所有表面加 240ms 颜色/边框/阴影过渡,结束后移除
- 背景图深色下淡出而不是 display:none
- 页头操作图标与分组导航等设置加载后再渲染,避免先显示后消失
2026-11-03 20:09:24 +08:00
CNWei dacb30e548 chore(web): 首屏兜底标题改为 Stash 2026-11-03 20:02:22 +08:00
CNWei 14938a9c83 fix(sidebar): 设置加载前不渲染可隐藏卡片
侧栏三个卡片统一等 /api/settings 返回后再渲染,避免禁用本地服务后刷新仍闪一下
2026-11-03 20:02:15 +08:00
CNWei 6dabeabaf2 refactor: 清理遗留设置模型与无用代码
- 删除 settings 的 sections 与 sidebar 六个旧排序/显隐字段,前后端类型与校验同步
- 删除旧分类模型 types、旧 /api/catalog 客户端、旧路由解析与 settings CRUD
- 删除未使用的 AppProject::initial
- 删除 27 条已无组件的 CSS 规则(159 行)
- 保持现有首页/接口页渲染,cargo test 52 passed
2026-11-03 19:49:04 +08:00
CNWei ffa123d2cd docs(api): 同步接口一览到清理后的路由
- 移除已删除的 /api/catalog 与 /api/categories、旧 /api/groups/{id}
- 内容库描述改为分组树 / 条目 / 卡片,节点接口改为根分组 / 子分组
- 「需登录」说明不再等同于写操作
2026-11-03 19:34:16 +08:00
20 changed files with 1831 additions and 1112 deletions
+1 -1
View File
@@ -88,7 +88,7 @@
| 角色 | 字体 | 说明 |
| --- | --- | --- |
| 正文 | **LXGW WenKai(霞鹜文楷)** | 与演示站同一份 webfont,`cdn.staticfile.net` |
| 正文 | **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` | 路由地址等路径文本 |
+204 -183
View File
@@ -1,236 +1,257 @@
# Stash 交接文档
> 写于 2026-10-29。新会话第一件事:读这份 + `docs/ADMIN-PLAN.md` + `stash-ui/README.md`。
> 更新于 2026-11-04 19:30(Asia/Shanghai)。本文件是当前状态,取代旧版历史快照。
> 新会话先读这份,再按需看 `README.md`、`docs/ADMIN-PLAN.md`、`stash-ui/README.md`。
> **⚠️ 这是一份历史快照,只保留当时的决策背景。** 2026-11-01 之后又做了这些改动,
> 下面正文里提到的文件名与页面结构可能已经不同,以 `README.md` 为准:
>
> - 前端 `App.tsx` → `AppNext.tsx`,后台 `admin/AdminPage.tsx` → `admin/AdminNext.tsx`(旧文件已删除);
> - 后台从横向 Tabs 改成**左侧导航 + 右侧内容**,页头归到「首页管理」下,
> Tab 名改为 分组管理 / 数据管理 / 首页管理(页头·侧边栏·内容卡片)/ 资料管理 / 系统管理;
> - 新增 `/interface` 接口一览页;侧栏「查看接口」走站内路由,不再直接开 `/api/projects`;
> - 接入 `@stash/ui` 组件库:前后台所有卡片、表格、开关、文字按钮都来自库里,
> 明暗主题(浅色 / 深色 / 跟随系统)由令牌接管;
> - 内容库表结构升到 v23 并**有了自动迁移**,卡片模型是 `card` + `card_group`;`section_block` 与旧 `node` 兼容字段已删除。
## 1. 一句话现状
## 0. 一句话现状
Stash 是「本地项目托管 + 可配置导航首页」:Rust 单 crate 内嵌 React 前端,单管理员登录,内容库为 SQLite,分组 / 条目 / 首页卡片 / 站点设置都在 `/admin` 管理。
Stash 已从「只读本地导航站」升级为**可登录、可配置、可编辑内容的本地发布台**:
当前开发分支 `develop`,本地服务:
- 登录(单用户,argon2id + Cookie 会话);
- `/admin` 设置页(页头滚动信息 / 首页卡片 / 归类 / 侧栏 / 资料 / 系统);
- 条目就地编辑(Drawer + 乐观锁);
- 分区新增/删除、**条目新增/删除(可选分区与分组)**、分组(子分类)新建/改名/删除、侧栏子分类配置、浏览/点赞/分享/跳转;
- 数据库自动迁移到 **v18**;
- 前端 100% 使用组件库 `@stash/ui`(v0.3.1,42 个组件)。
- 前台 + 后台:<http://127.0.0.1:8080>
- 管理后台:<http://127.0.0.1:8080/admin>
- 接口一览:<http://127.0.0.1:8080/interface>
- Vite 开发服务器 `5173` 当前**未运行**;需要时 `cd stash-web && npm run dev`
当前数据库**已设置管理员密码**(2026-10-30 由使用者设置);忘记密码按 §6 删除 `admin` 行即可回到首次设置。
## 2. 仓库与分支
## 1. 仓库与运行环境
| 仓库 | 路径 | 分支 | 状态 |
| --- | --- | --- | --- |
| 主项目 | `D:\CNWei\CNW\Rust\stash` | `develop` | 工作区干净;功能最新 `1d1ece6`,本文件随后单独提交 |
| 组件库 | `D:\CNWei\CNW\Rust\stash-ui` | `master` | 工作区只有忽略的 `.idea/`;`994436a` 最新 |
| 项目 | 路径 | 分支 | 端口 | 说明 |
| --- | --- | --- | --- | --- |
| 新版(组件重构) | `D:\CNWei\CNW\Rust\stash` | `feat/use-stash-ui` | **8080** | 所有新功能都在这里 |
| 旧版(1:1 基线) | `D:\CNWei\CNW\Rust\stash-old`(worktree) | `develop` | **8081** | 只用于视觉对照,不要改 |
| 组件库 | `D:\CNWei\CNW\Rust\stash-ui` | `master` | — | tag `v0.1.0` / `v0.2.0` / `v0.3.0` / `v0.3.1` |
- 主项目 remote 名为 `master`(不是 `origin`),地址 `https://gitea.ngmao.com/CNWei/stash.git`。
- `develop` 比 `master/develop` **领先 20 个提交,尚未推送**;此前 HTTPS 凭据不可用,需要时先准备 Gitea 凭据。
- `stash-ui` 也有 3 个未推送提交:`b97c77e`(SearchField suffix)、`46829cd`(Input leading)、`994436a`(EngineIcon)。
- `@stash/ui` 以 `file:../../stash-ui` 引入,`stash-web` 使用其 `dist/`;改组件库后必须重新 `npm run build`。
后端:Rust 2024 + Axum 0.8 + rusqlite(bundled)。前端:React 19 + Vite + TS + CSS Modules,`@stash/ui` 通过 `file:` 依赖引入。
## 3. 启动 / 构建 / 测试
### 启动 / 构建(顺序重要)
前端产物会被 `build.rs` 内嵌,顺序不能反:
```powershell
# 1) 前端(产物会被 build.rs 内嵌,必须先构建)
cd D:\CNWei\CNW\Rust\stash\stash-web
npm run build
npm run build # tsc -b + vite,产物 dist/
# 2) 先杀掉正在运行的 stash.exe,否则 cargo 会报「拒绝访问」
Get-Process stash -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*stash\target*' } | Stop-Process -Force
# 3) 后端
cd ..
cd D:\CNWei\CNW\Rust\stash
Get-Process stash -ErrorAction SilentlyContinue |
Where-Object { $_.Path -like '*stash\target*' } | Stop-Process -Force # 否则 exe 被占用
cargo build
Start-Process .\target\debug\stash.exe -RedirectStandardOutput "$env:TEMP\stash.log" -RedirectStandardError "$env:TEMP\stash-err.log" -WindowStyle Hidden
Start-Process .\target\debug\stash.exe `
-RedirectStandardOutput "$env:TEMP\stash.log" `
-RedirectStandardError "$env:TEMP\stash-err.log" -WindowStyle Hidden
```
旧版(8081,仅在需要对照时):
```powershell
D:\CNWei\CNW\Rust\stash-old\target\debug\stash.exe --port 8081
```
### 测试
Windows 下 `TEMP` 权限有问题,跑测试要覆盖临时目录:
测试(Windows 下覆盖 TEMP 权限):
```powershell
$env:TEMP = "D:\CNWei\CNW\Rust\stash\target\tmp"; $env:TMP = $env:TEMP
cargo test # 当前 48 passed
cargo test # 交接时 52 passed
```
## 2. 目录 / 文件地图
组件库:
```powershell
cd D:\CNWei\CNW\Rust\stash-ui
npm run build # dist/stash-ui.js + .css + types
```
## 4. 代码地图
```
src/
main.rs / lib.rs 启动、日志
config.rs 端口 / 数据目录(默认 target/debug/data)
server.rs 全部路由(API + 内嵌前端 + SPA 回落)
error.rs StashError(含 Conflict → 409)
main.rs / lib.rs 启动、参数、日志
config.rs 端口 / 数据目录 / 托管目录
server.rs 全部 API + 内嵌前端 + SPA 回落
listing.rs 托管目录扫描
watcher.rs 文件监听 + SSE 防抖
db/
mod.rs Db 门面(所有方法)+ 迁移测试
schema.rs 建表、种子、版本迁移(0/15→16→17)
auth.rs 管理员、密码哈希、会话(token 只存 sha256)
settings.rs settings JSON(header/sidebar/sections/profile)
entries.rs 条目读取/编辑、浏览数、点赞、审计
categories.rs 分区新增/删除
catalog.rs 查询组装(前端三种视图)
mod.rs Db 门面 + 迁移/种子测试
schema.rs 建表、种子、v15→…→v23 迁移
auth.rs admin / session / argon2id
settings.rs settings JSON + 校验
entries.rs 条目 CRUD、浏览、点赞、审计
groups.rs 分组树 CRUD / 移动 / 排序
cards.rs 卡片与勾选分组
catalog.rs 分组树 + 条目查询组装
stash-web/src/
App.tsx 主页壳、登录弹窗、编辑器、设置应用
main.tsx 入口分流(/admin 与 /login)
auth.ts / settings.ts / items.ts 前端 API 客户端
AppNext.tsx 前台壳:路由、卡片、侧栏、搜索框、主题
main.tsx /admin 与普通前台分流
settings.ts SiteSettings 类型 / 默认值 / API 客户端
manage.ts 分组、卡片、catalog2 客户端
items.ts 条目编辑客户端
api.ts 项目扫描 / SSE 客户端
components/
header/SiteHeader.tsx 品牌 + 滚动信息 + 操作图标
admin/AdminPage.tsx /admin 全部 Tab
auth/LoginDialog.tsx Modal 登录
auth/PasswordInput.tsx 带显隐切换的密码框
content/ 首页、分区、条目、文章、编辑器
sidebar/ 资料卡、服务面板、导航
docs/ADMIN-PLAN.md 后台方案 + P0–P4 实施记录 + 未做项
README.md 运行、路由、库结构、登录说明
admin/AdminNext.tsx 后台全部面板
header/SiteHeader.tsx 品牌 / 滚动信息 / 操作图标
content/ 卡片、网格、详情、编辑器
sidebar/ 资料卡、本地服务、导航
stash-ui/src/components/ SearchField / Input / EngineIcon 等
```
## 3. 功能清单(已完成)
## 5. 数据库(v23)
### 登录
- 单用户 `admin`,密码 argon2id;
- 会话:随机 32 字节 token,HttpOnly + SameSite=Strict + Path=/,7 天,库里存 sha256;
- 登录失败 5 次锁 60 秒;写接口校验 Origin;写接口需要会话。
路径:`target/debug/data/stash.db`(`--data` 可覆盖)。
### `/admin`(新原子模型)
| Tab | 能力 |
| --- | --- |
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/图标/排序/状态)、删除;格式已下沉到条目,不再在分组上设置 |
| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 格式 / 跳转链接 / 所属分组 / 操作;每条内容单独选格式(应用/海报/图文),同一分组可混合 |
| 侧边栏 | 顶部「侧栏卡片」控制本地管理员 / 本地服务显隐;下方「分组导航」逐项勾选;开关即保存 |
| 首页卡片 | 左侧列表含「本地服务」只读表(不可删除,但可拖动排序)+ 自建卡片;右侧分组表格;表格内开关/条数即时保存,标题用「保存」按钮 |
| 页头 | 启用开关、速度、条目、标签与色调 |
| 资料 | 头像 URL、名称、角色、简介、位置 |
| 系统 | 修改密码、下载备份 |
- `PRAGMA user_version = 23`;支持 0 建全量、15→16→…→23 迁移,高于程序版本拒绝启动。
- 当前表:`admin`、`audit`、`card`、`card_group`、`entry`、`entry_tag`、`node`、`session`、`setting`。
- 当前数据(交接时):15 个 node、44 条 entry、5 张 card;`PRAGMA foreign_key_check` 无异常。
- `section_block` 与 `node.view_mode / empty_text / show_in_sidebar / allow_groups` 已在 v23 删除。
- `entry.format` 是每条内容自己的 `apps / posters / articles`,同一分组可混合。
- `node.kind = 'section'` 是根分组,`group` 是子分组;`enabled` 控制整棵子树显示。
- 条目只存 `node_key`;格式、分组、卡片都通过业务 key 关联。
- 备份:`target/debug/data/stash.db.bak-pre-cleanup-v22` 是 v23 改造前快照;`/api/backup` 可下载当前快照。
- **不要直接 `cargo clean`/删除整个 `target`**:运行库和备份在 `target/debug/data/`。
保存:分组/内容/卡片逐项保存;侧边栏、页头、资料走 `PATCH /api/settings`(`version` 乐观锁)。
## 6. settings JSON
### 内容编辑
- 详情页 `[编辑]` 只在登录后显示;
- 打开右侧 Drawer:标题、简介、标签、一句话评价、正文 Markdown、封面、跳转地址;
- `PATCH /api/items/{key}`,`updatedAt` 乐观锁;
- 保存后 `catalog.reload()` 局部刷新,不整页刷新。
### 详情操作条
- 浏览:进入页面 `POST /api/items/{key}/view`,数字 +1;
- 点赞:可切换,`POST /like`,localStorage 记住状态;
- 分享:复制当前链接,Toast 反馈;
- 跳转:保存时规范化 `href`(`www.` 自动补 `https://`,拒绝 `javascript:` 等协议);为空或 `#` 时按钮置灰。
## 4. HTTP API
`setting` 表 `key='site'`,API `GET/PATCH /api/settings`,`version` 乐观锁。当前字段:
```jsonc
{
"header": { "enabled": true, "speed": 24, "gap": 26, "items": [...] },
"sidebar": { "show_profile": true, "show_service": false, "show_nav": true },
"background": { "light": "", "dark": "" }, // 空 = 内置背景
"font": { "source": "builtin", "current": "", "items": [] }, // builtin = 文楷;custom = 用 items 里 current 那条
"search": {
"default": "internal",
"engines": [
{ "id": "google", "name": "Google", "url": "https://www.google.com/search?q={query}", "enabled": true },
{ "id": "bing", "name": "Bing", "url": "https://www.bing.com/search?q={query}", "enabled": true },
{ "id": "baidu", "name": "百度", "url": "https://www.baidu.com/s?wd={query}", "enabled": true },
{ "id": "sogou", "name": "搜狗", "url": "https://www.sogou.com/web?query={query}", "enabled": true },
{ "id": "360", "name": "360", "url": "https://www.so.com/s?q={query}", "enabled": true }
]
},
"profile": { "name": "...", "role": "...", "description": "...", "location": "...", "avatar": "" },
"site": { "title": "...", "favicon": "", "theme": "light", "brand": {...}, "actions": {...} }
}
```
- `search.default` 可以是 `internal` 或某个引擎 id;默认默认值是 `internal`。
- 上面是字段结构示例;实际值以 `GET /api/settings` 为准(交接时 `search.default=internal`、`site.theme=light`,background 两项使用自定义 picsum 图)。
- 外部引擎 URL 必须以 `http(s)://` 开头并包含 `{query}`,否则后端 400。
- 引擎 id 固定为 `google / bing / baidu / sogou / 360`,因为图标与 id 对应;暂不支持新增自定义引擎。
- `background.light/dark` 为空时用内置 `stash-web/src/assets/site-bg.jpg` / `site-bg-dark.jpg`;否则填托管目录 `public/` 路径或完整 URL。
- 旧 settings JSON 缺失字段会自动回落默认值;多余旧字段在下次保存时被丢弃。
## 7. 当前功能 / 后台 IA
后台左侧导航:
- **分组管理**:根分组 / 子分组树、编辑、移动、排序、启停、删除;
- **数据管理**:按主分组列出条目(含子分组),每条可选格式;
- **首页管理**
- 页头:开关、速度、滚动条目、品牌 / 图标 / 配色;
- 背景管理:亮色 / 暗色背景 URL + 预览 + 恢复内置;
- 搜索框:默认搜索项、五个外部引擎的名称 / URL / 启停、恢复默认;
- 侧边栏:资料卡 / 本地服务 / 分组导航显隐;
- 内容卡片:卡片 + 勾选分组 + 预览条数 + 排序;
- **资料管理**:头像、名称、角色、简介、位置;
- **系统管理**:改密码、下载备份、正文字体(先选内置 / 字体链接;链接模式下可存多套字体,启用其中一条)。
搜索框行为:
- 顶部悬浮标签显示当前引擎图标 + 名称,点击展开下拉;
- 第一项固定「站内搜索」,分隔线后是启用的外部引擎;
- 站内模式:输入过滤本地内容;回车打开第一个匹配条目/分组;
- 外部模式:输入**不再过滤**本地内容;回车用该引擎 URL 新标签页搜索;
- 当前选择存在本机 localStorage(`stash-search-engine`),刷新后仍用它;引擎被停用 / 删除时回落到后台默认项。
主题:
- 深色背景来自 Alright 演示站,资源 `site-bg-dark.jpg`;
- 明暗切换优先用 View Transition 整页快照交叉淡入(160ms),不支持时回退到 CSS 颜色过渡;
- 首帧由 `index.html` 内联脚本按 localStorage / 系统偏好先上色;
- 背景地址也缓存在 localStorage(`stash-background`),刷新首帧直接用它,设置回来再校准,不会先闪内置背景;
- 页头图标显示**当前**实际明暗(深色月亮 / 浅色太阳),点它是切到另一侧;
- 背景图两层(light/dark)交叉淡入。
## 8. HTTP API(当前保留)
```text
GET /api/health
GET /api/projects GET /api/projects/events (SSE)
GET /api/catalog2 分组树 + 卡片(新前台/后台)
GET /api/cards 卡片列表
POST /api/nodes {key,title,parentKey?,icon?,sort?,enabled?} 需登录
PATCH /api/nodes/{key} {title,parentKey?,sort?,icon?,enabled?} 需登录
DELETE /api/nodes/{key} 需登录,级联
POST /api/cards {title} 需登录
PATCH /api/cards/{id} {title?,sort?} 需登录
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} 需登录
POST /api/cards 需登录
PATCH /api/cards/{id} 需登录
DELETE /api/cards/{id} 需登录
PUT /api/cards/{id}/groups [{groupKey,previewLimit}] 需登录
GET /api/auth/session -> {authenticated, has_admin, username}
POST /api/auth/setup {password} 仅当无管理员
POST /api/auth/login {password}
POST /api/auth/logout
PATCH /api/auth/password {current, next}
GET /api/settings -> {version, settings}
PATCH /api/settings {version, settings} 需登录
GET /api/items/{key} -> 原始字段(编辑表单)
POST /api/items {key,nodeKey,item} 需登录
PATCH /api/items/{key} {updatedAt, item, nodeKey?} 需登录,nodeKey 可移动条目
PUT /api/cards/{id}/groups 需登录
GET /api/items/{key}
POST /api/items 需登录
PATCH /api/items/{key} 需登录
DELETE /api/items/{key} 需登录
POST /api/items/{key}/view
POST /api/items/{key}/like {liked}
GET /api/backup SQLite 快照附件 需登录
POST /api/items/{key}/like
GET /api/backup 需登录
GET /api/auth/session
POST /api/auth/setup / login / logout
PATCH /api/auth/password
```
## 5. 数据库与迁移
已删除:`/api/catalog`、`/api/categories/*`、旧 `/api/groups/{id}`。
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。
- 当前 `PRAGMA user_version = 23`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21→22→23、更高版本拒绝启动。
- 格式在 `entry.format`(apps / posters / articles),同组可混合;`node` 不再保存格式。
- v23 迁移删除 `section_block` 表与 `node` 的 `view_mode / empty_text / show_in_sidebar / allow_groups` 字段。
- `settings.sidebar.show_profile / show_service` 控制侧栏两张卡片显隐(默认 true)。
- 首页滚动:`#page-scroll`(Layout 的 body)是唯一滚动容器,搜索框固定在其上方;左右两栏顶部对齐,左侧到底后通过 `translateY` 吸底,右侧继续滚动;滚动条隐藏;页头实底,搜索框 50% 透明,顶部另有一层上下渐隐的软虚化(`blur(8px)`)。
- 本地服务是运行时卡片,但 `card` 表里有一行 `local-service` 只用于排序;内容来自 `/api/projects`,后端禁止删除/改名/配置分组。
- `node` 是统一分组树(根节点 `kind='section'`,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。
- 主要表:`node`、`entry`、`entry_tag`、`card`、`card_group`、`admin`、`session`、`setting`、`audit`。
- `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。
- **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。
## 9. 组件库改动(stash-ui)
## 6. 认证运维
- `SearchField` 新增可选 `suffix` 与 `leading` 插槽;
- `Input` 新增 `leading` ReactNode,优先于 `icon`;
- 新增 `EngineIcon`(`internal/google/bing/baidu/sogou/360`),图标路径来自参考稿;`18px` 默认、`14px` 用于顶部小标签;
- 构建产物已更新,`stash-web` 通过 file 依赖读取;改库后必须重建 dist,否则主项目构建仍用旧类型/资源。
- 忘记密码:删除 `admin` 行(或整库)后重启 → 回到首次设置。
```powershell
node -e "const {DatabaseSync}=require('node:sqlite');const d=new DatabaseSync('target/debug/data/stash.db');d.exec('DELETE FROM admin; DELETE FROM session;')"
```
- 改密码:`/admin → 系统`,成功后该账号所有会话失效,需要重新登录。
- 首个管理员由 `/api/auth/setup` 创建,密码至少 8 位。
## 10. 已知限制 / 坑
## 7. 组件库 `@stash/ui`
- 42 个组件,文档站:`cd stash-ui && npm run dev`(也可 `npm run build:site` 后看 `dist-site`)。
- 关键命令:`npm run typecheck && npm run lint && npm run build && npm run build:site`。
- 组件契约:`docs/COMPONENTS.md`;设计规范:`DESIGN.md`;外部风格接入流程:`docs/STYLE-INTAKE.md`(闸门:截图 → 映射 → 决策 → 评审 → 再写代码)。
- 主题:`themes/example.css` 只改变量;页面禁止写组件内部选择器。
- **判断归属**:全局配置放 `/admin`;页面版式(间距、布局容器)写在页面 CSS;只有「第二个页面也复用」的才抽组件。
## 8. 验证与截图
- 后端逻辑:`cargo test`(48 passed,覆盖迁移/登录/设置/条目编辑/分区)。
- 前端构建:`stash-web npm run build`(tsc -b + vite)。
- 视觉/交互:Playwright 脚本在 `stash-ui/.ref/*.mjs`(不提交 git),截图在 `stash-ui/shots/`。
- `measure-final.mjs` **已过时**(旧 1:1 对照用),选择器 `.app header` 仍可用;
- `c3-check.mjs`、`d-check.mjs`、`p2-check.mjs` 是后期功能检查脚本。
- 常用冒烟:首页卡片、`/admin` 各 Tab、详情编辑、登录弹窗。
## 9. 已知限制 / 未做
| 项 | 状态 |
| 项 | 说明 |
| --- | --- |
| 备份恢复/导入 | 只提供下载快照;恢复需停机替换 `stash.db` |
| 回收站/软删除 | 未做,删除分组会级联删子分组与条目(有二次确认) |
| 图片上传 | 只支持 URL;上传需存储目录、大小/类型校验 |
| 拖拽排序 | 分组/卡片顺序暂用创建顺序;上移下移/拖拽未做 |
| 多用户/角色 | 未做,当前单管理员 |
| 分组移动 | 编辑弹窗里可改「上级分组」(防环);根分组不能移到子级 |
| 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) |
| 分组排序 | 可编辑「排序权重」数值;拖拽排序未做 |
| 旧接口 | 已删除 `/api/catalog`、`/api/categories/*`、旧 `/api/groups/{id}`;当前只保留 `/api/catalog2` 与 `/api/nodes` |
| 登录限流 | 进程内计数,重启清空 |
| 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 |
| 搜索引擎 | id 固定五个;新增需同时补 EngineIcon、后端校验、前端默认值、后台面板 |
| 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 |
| 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 |
| 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 |
| 备份恢复 | 只有 `/api/backup` 下载;恢复需停机替换库文件 |
| 旧接口 | 已删;不要再按旧文档调用 `/api/catalog` 或 categories |
| 双份示例 | `public/git_helper/` 是 Rust 托管示例;`stash-web/public/git_helper/` 是 Vite dev 示例,**两份都保留** |
| exe 占用 | 重启/构建前先杀 stash.exe,否则 `cargo build` 拒绝访问 |
| 编码 | 源码 UTF-8;PowerShell 控制台可能显示乱码,以 read 工具为准,别用 Set-Content 改中文源码 |
## 10. 约定与坑
## 11. 最近提交(主项目,倒序)
- 回复用中文,表格化优先;视觉工作要截图验证。
- **任何共享组件改动**都要在 `docs/COMPONENTS.md` 补契约 + changelog,并说明兼容性。
- PowerShell 写文件注意 UTF-8;优先用编辑工具,别用 `Set-Content` 写带中文的源码。
- 改前端后必须 `npm run build` 再 `cargo build`,否则内嵌资源是旧的。
- 重启前先杀 `stash.exe`,否则 cargo 报「拒绝访问」。
- `/login` 是旧地址,`main.tsx` 会重定向到 `/`;登录已改为弹窗。
- 前端路由:`/`、`/g/<分组 key>`、`/g/<分组 key>/i/<条目>`;旧 `/c/...` 地址按第一个路径段当分组 key 兼容,`/admin` 独立壳。
```text
1d1ece6 fix(group): 分组面包屑保留详情页外框
9d1e5dc fix(group): 分组页面包屑与详情页统一
4862f8f fix(search): 外部引擎模式不再过滤本地内容
aa6f341 feat(search): 按参考稿重做搜索框并支持后台配置
5ae2088 style(search): 引擎图标恢复 18px
200908c style(search): 引擎图标统一使用组件库图标
56f362f feat(search): 点击放大镜切换搜索引擎
8c1c658 feat(search): 效果五快捷栏加入本地搜索
038b7ec feat(search): 搜索框支持切换搜索引擎
a5d7859 fix(theme): 用整页快照过渡消除明暗割裂
5439ebf feat(admin): 增加背景管理
b9005dd style(theme): 暗色使用 Alright 同款深色背景
```
## 11. 新会话建议的下一步
组件库最近:
1. 先跑 `cargo test` + 打开 8080 冒烟,确认基线;
2. 若要继续产品化:条目移动 UI、回收站、图片上传、拖拽排序;
3. 若要发布:确认 `stash-ui` 以 `file:` 引入的方式在发布构建里如何锁定版本(建议改为 git tag 依赖或构建前 `npm pack`)。
```text
994436a feat(icons): 增加搜索引擎图标
46829cd feat(input): 增加左侧 leading 插槽
b97c77e feat(search): SearchField 增加 suffix 插槽
```
## 12. 新会话建议顺序
1. 打开 <http://127.0.0.1:8080> 与 `/admin` 冒烟;确认 8080 在跑(当前 PID 会变,不必依赖 PID)。
2. 改前端:`cd stash-web && npm run build` → 杀 stash → `cargo build` → 启动。
3. 改组件库:先 `cd stash-ui && npm run build`,再构建 `stash-web`,最后 Rust。
4. 改设置结构:同时改 `src/db/settings.rs`、`stash-web/src/settings.ts`、后台面板和默认值;旧 JSON 由 serde 默认值兜底。
5. 改数据库结构:升 `SCHEMA_VERSION` 并补迁移与迁移测试;先备份 `target/debug/data/stash.db`。
6. 视觉改动用 Playwright 截图到 `stash-ui/shots/` 验证;脚本在 `stash-ui/.ref/`(不提交)。
7. 收尾跑 `cargo test`(52 条)和 `npm run build`,确认工作区干净。
+3 -2
View File
@@ -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` 与本站同源。
+26
View File
@@ -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!(
+213 -59
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")]
@@ -92,13 +93,6 @@ impl Default for SidebarSettings {
show_profile: true,
show_service: true,
show_nav: true,
hidden: Vec::new(),
order: Vec::new(),
hidden_groups: Vec::new(),
shown_groups: Vec::new(),
group_order: std::collections::HashMap::new(),
tree_hidden: Vec::new(),
tree_order: std::collections::HashMap::new(),
}
}
}
@@ -115,33 +109,108 @@ pub struct SidebarSettings {
/// 是否显示「分组导航」卡片(首页 / 分组树)
#[serde(default = "default_true")]
pub show_nav: bool,
/// 隐藏的分类 id
pub hidden: Vec<String>,
/// 分类显示顺序(id);未列出的排在后面,保持原有顺序
pub order: Vec<String>,
/// 隐藏的分组,键为 `分区id/分组id`
pub hidden_groups: Vec<String>,
/// 强制显示的分组(覆盖数据库里的 show_in_sidebar=0)
pub shown_groups: Vec<String>,
/// 每个分区下分组的顺序:分区 id → 分组 id 列表
pub group_order: std::collections::HashMap<String, Vec<String>>,
/// 新模型:侧栏隐藏的分组 key(任意层级)
pub tree_hidden: Vec<String>,
/// 新模型:每级分组的顺序,键是父分组 key(根级用空串)
pub tree_order: std::collections::HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct SectionsSettings {
/// 隐藏的首页卡片(分类 id)
pub hidden: Vec<String>,
/// 卡片顺序(分类 id)
pub order: Vec<String>,
/// 标题覆盖:id → 标题
pub titles: std::collections::HashMap<String, String>,
/// 预览条数覆盖:id → 条数
pub limits: std::collections::HashMap<String, u32>,
pub struct BackgroundSettings {
/// 亮色背景图地址;空表示用内置图
pub light: String,
/// 暗色背景图地址;空表示用内置图
pub dark: String,
}
/// 正文字体来源:内置霞鹜文楷,或用户存的字体链接。
#[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 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 {
pub id: String,
pub name: String,
/// 搜索地址模板,用 `{query}` 表示关键词
pub url: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SearchSettings {
/// `internal` 或某个引擎 id
pub default: String,
pub engines: Vec<SearchEngineSettings>,
}
impl Default for SearchSettings {
fn default() -> Self {
Self {
default: "internal".into(),
engines: vec![
SearchEngineSettings {
id: "google".into(),
name: "Google".into(),
url: "https://www.google.com/search?q={query}".into(),
enabled: true,
},
SearchEngineSettings {
id: "bing".into(),
name: "Bing".into(),
url: "https://www.bing.com/search?q={query}".into(),
enabled: true,
},
SearchEngineSettings {
id: "baidu".into(),
name: "百度".into(),
url: "https://www.baidu.com/s?wd={query}".into(),
enabled: true,
},
SearchEngineSettings {
id: "sogou".into(),
name: "搜狗".into(),
url: "https://www.sogou.com/web?query={query}".into(),
enabled: true,
},
SearchEngineSettings {
id: "360".into(),
name: "360".into(),
url: "https://www.so.com/s?q={query}".into(),
enabled: true,
},
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -254,7 +323,9 @@ impl Default for SiteMetaSettings {
pub struct Settings {
pub header: HeaderSettings,
pub sidebar: SidebarSettings,
pub sections: SectionsSettings,
pub background: BackgroundSettings,
pub font: FontSettings,
pub search: SearchSettings,
pub profile: ProfileSettings,
pub site: SiteMetaSettings,
}
@@ -298,33 +369,6 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
}
}
}
if settings.sidebar.hidden.len() > 100 || settings.sidebar.order.len() > 100 {
return Err(StashError::InvalidDirectory("侧栏配置过长".into()));
}
if settings.sidebar.hidden_groups.len() > 200
|| settings.sidebar.shown_groups.len() > 200
|| settings.sidebar.group_order.len() > 100
|| settings.sidebar.tree_hidden.len() > 500
|| settings.sidebar.tree_order.len() > 200
{
return Err(StashError::InvalidDirectory("侧栏分组配置过长".into()));
}
if settings.sections.hidden.len() > 100 || settings.sections.order.len() > 100 {
return Err(StashError::InvalidDirectory("首页卡片配置过长".into()));
}
if settings.sections.titles.len() > 100 || settings.sections.limits.len() > 100 {
return Err(StashError::InvalidDirectory("首页卡片配置过长".into()));
}
for title in settings.sections.titles.values() {
if title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("卡片标题最多 40 个字".into()));
}
}
for limit in settings.sections.limits.values() {
if *limit > 60 {
return Err(StashError::InvalidDirectory("卡片条数最多 60".into()));
}
}
if settings.profile.name.chars().count() > 40 {
return Err(StashError::InvalidDirectory("名称最多 40 个字".into()));
}
@@ -337,6 +381,97 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
if settings.profile.location.chars().count() > 40 {
return Err(StashError::InvalidDirectory("位置最多 40 个字".into()));
}
if settings.background.light.chars().count() > 500 {
return Err(StashError::InvalidDirectory(
"亮色背景地址最多 500 个字符".into(),
));
}
if settings.background.dark.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 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.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()));
}
let mut ids = std::collections::HashSet::new();
for engine in &settings.search.engines {
if !matches!(
engine.id.as_str(),
"google" | "bing" | "baidu" | "sogou" | "360"
) {
return Err(StashError::InvalidDirectory("搜索引擎标识不合法".into()));
}
if !ids.insert(engine.id.as_str()) {
return Err(StashError::InvalidDirectory("搜索引擎标识不能重复".into()));
}
let name = engine.name.trim();
if name.is_empty() || name.chars().count() > 20 {
return Err(StashError::InvalidDirectory("搜索引擎名称需为 1–20 个字".into()));
}
let url = engine.url.trim();
if url.chars().count() > 500
|| !(url.starts_with("http://") || url.starts_with("https://"))
|| !url.contains("{query}")
{
return Err(StashError::InvalidDirectory(
"搜索地址需以 http(s):// 开头并包含 {query}".into(),
));
}
}
let default = settings.search.default.trim();
if default != "internal" {
let engine = settings
.search
.engines
.iter()
.find(|engine| engine.id == default)
.ok_or_else(|| StashError::InvalidDirectory("默认搜索引擎不存在".into()))?;
if !engine.enabled {
return Err(StashError::InvalidDirectory("默认搜索引擎未启用".into()));
}
}
if settings.site.title.chars().count() > 120 {
return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into()));
}
@@ -457,7 +592,6 @@ mod tests {
let settings = Settings::default();
assert!(settings.header.enabled);
assert_eq!(settings.header.items.len(), 3);
assert!(settings.sections.hidden.is_empty());
assert!(validate(&settings).is_ok());
}
@@ -495,6 +629,26 @@ 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.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.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());
}
#[test]
-11
View File
@@ -7,17 +7,6 @@ pub struct AppProject {
pub route_path: String,
}
impl AppProject {
pub fn initial(&self) -> String {
self.name
.chars()
.next()
.unwrap_or('A')
.to_uppercase()
.to_string()
}
}
pub fn scan_projects(root: &Path) -> Vec<AppProject> {
let mut projects = Vec::new();
+9 -1
View File
@@ -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
View File
@@ -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()
+11 -3
View File
@@ -4,14 +4,22 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<!-- 与 Alright 主题一致的正文字体:霞鹜文楷 -->
<meta name="color-scheme" content="light dark" />
<script>
// 首帧先按本机偏好上色,避免先浅色再切深色的闪动;站点默认值随后由设置接管。
try {
var saved = localStorage.getItem('stash-theme')
var dark = saved ? saved === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.dataset.scheme = dark ? 'dark' : 'light'
} catch (e) {}
</script>
<!-- 内置正文字体:霞鹜文楷;后台「系统管理 → 字体」可以换成 font 目录里的字体或外部链接 -->
<link
rel="stylesheet"
href="https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/style.min.css"
crossorigin="anonymous"
/>
<title>Stash · 本地应用工作区</title>
<title>Stash</title>
</head>
<body>
<div id="root"></div>
+367 -193
View File
@@ -1,12 +1,26 @@
/* 页面级样式:只保留「组件库没有」的部分(背景、外壳、封面兜底、正文版式)。
颜色 / 间距 / 字号全部取自 @stash/ui 的令牌。 */
/* 主题级覆盖:放 :root 而不是 .app,前后台两个壳才能拿到同一套阴影 */
/* 主题级覆盖:放 :root 而不是 .app,前后台两个壳才能拿到同一套描边/阴影。
卡片保留一层很浅的投影;浅色默认 gray-1 几乎看不见,描边提一档。 */
:root {
--su-card-border: var(--su-gray-3);
--su-shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
--su-shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06);
}
/* 深色下描边要比卡片底(#202022)亮一档,才是一条实线 */
:root[data-scheme='dark'] {
--su-card-border: var(--su-border-strong);
}
/* 卡片阴影默认会被滚动容器在左右边缘一刀切掉(卡片正好贴着容器边)。
负边距把容器撑宽 16px、再补同样的内边距,内容位置不变,阴影有地方画。 */
#page-scroll {
margin: 0 -16px;
padding: 0 16px;
}
.app {
position: relative;
z-index: 1;
@@ -14,14 +28,27 @@
background-color: transparent;
}
/* 顶部背景图:固定 65vh,底部渐隐到页面底色 */
/* 顶部背景:固定 65vh,底部渐隐到页面底色;明暗两层交叉淡入 */
.site-background {
position: fixed;
inset: 0 0 auto;
z-index: 0;
height: 65vh;
background: url('./assets/site-bg.jpg') center top / cover no-repeat;
background-position: center top;
background-repeat: no-repeat;
background-size: cover;
pointer-events: none;
opacity: 1;
transition: opacity 160ms var(--su-ease-in-out);
}
.site-background-light {
background-image: url('./assets/site-bg.jpg');
}
.site-background-dark {
background-image: url('./assets/site-bg-dark.jpg');
opacity: 0;
}
.site-background::after {
@@ -34,9 +61,12 @@
background: linear-gradient(to bottom, transparent, var(--su-bg-page));
}
/* 这张背景图是浅色渐变图,深色下留着会糊成一层脏灰,直接不显示 */
[data-scheme='dark'] .site-background {
display: none;
[data-scheme='dark'] .site-background-light {
opacity: 0;
}
[data-scheme='dark'] .site-background-dark {
opacity: 1;
}
/* ---------- 页头 ---------- */
@@ -109,6 +139,26 @@
}
}
/* 后台:一条已保存字体的编辑框 */
.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);
}
/* 字体族名带 hint、名称不带,两个字段高度不同;顶部对齐才不会错位 */
.font-link .admin-row {
align-items: flex-start;
}
.font-link .admin-row > * {
flex: 1 1 0;
min-width: 0;
}
/* 后台「首页 Logo」的实时预览:外观交给库里的 Brand,这里只管外框 */
.brand-preview {
display: flex;
@@ -342,17 +392,6 @@
margin-top: var(--su-space-4);
}
.group-crumbs {
display: flex;
align-items: center;
gap: var(--su-space-2);
padding: var(--su-space-2) var(--su-space-3);
}
.group-crumb-sep {
color: var(--su-text-muted);
}
.group-child-block {
margin-top: var(--su-space-2);
}
@@ -478,18 +517,6 @@
color: var(--su-color-primary);
}
.item-cover--square {
width: 96px;
height: 96px;
border-radius: var(--su-radius-md);
}
.item-cover--portrait {
width: 179px;
aspect-ratio: 2 / 3;
border-radius: var(--su-radius-cover);
}
.item-cover img {
width: 100%;
height: 100%;
@@ -803,21 +830,6 @@
gap: var(--su-space-2);
}
.admin-nav-row {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.admin-section-row {
grid-template-columns: auto minmax(0, 1fr) 96px auto;
}
.admin-item-actions {
display: flex;
justify-content: flex-end;
}
.admin-item-row .su-switch {
justify-self: start;
}
@@ -825,25 +837,20 @@
/* ---------- 响应式 ---------- */
@media (max-width: 1100px) {
.sidebar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--su-space-3);
padding: 0 var(--su-space-4) var(--su-space-4);
/* 页头:品牌和操作图标按内容宽度,滚动信息吃剩下的空间,
否则中间那列固定抢 720px,品牌文字会被挤没 */
.site-header-inner {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.profile-card {
grid-row: span 2;
grid-row: auto;
}
.service-panel {
display: none;
}
.header-info {
display: none;
}
.search-toolbar {
padding: var(--su-space-5) 0 var(--su-space-6);
}
@@ -853,13 +860,21 @@
}
}
@media (max-width: 820px) {
.sidebar {
@media (max-width: 640px) {
/* 手机页头只留品牌 + 操作图标:跑马灯放不下,会盖到下面的搜索条上 */
.header-ticker-slot {
display: none;
}
.site-header-inner {
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--su-space-2);
height: 56px;
padding: 0 var(--su-space-3);
}
.header-actions {
gap: var(--su-space-1);
}
}
@@ -904,69 +919,20 @@
margin-bottom: var(--su-space-5);
}
.admin-create {
margin-top: var(--su-space-4);
padding-top: var(--su-space-4);
border-top: 1px dashed var(--su-border-strong);
}
.admin-create-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 160px auto;
gap: var(--su-space-2);
align-items: center;
}
/* Select 自身有 160px 最小宽度,网格项必须允许收缩,否则会盖住「新增」按钮 */
.admin-create-row > * {
min-width: 0;
}
/* 侧栏里的子分类(分组):缩进一层,弱化文字 */
.admin-nav-group-row {
padding-left: var(--su-space-6);
}
/* 归类页:一行一个分区,标题 / 类型 / 图标 / 色条 / 条数 / 保存 / 删除 */
.admin-category-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) 150px 80px 110px 90px auto auto auto;
align-items: center;
gap: var(--su-space-2);
}
.admin-toggle {
display: flex;
align-items: center;
gap: var(--su-space-2);
white-space: nowrap;
}
/* 新后台:分组树行 / 新建行 / 卡片头,用弹性布局避免网格列数对不上 */
.admin-node-row {
display: flex;
align-items: center;
gap: var(--su-space-2);
}
.admin-node-title {
flex: 1;
min-width: 0;
}
.admin-node-format {
width: 170px;
flex: none;
}
.admin-new-node-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--su-space-2);
}
/* 分组管理:参考后台的树形表格 */
.group-toolbar {
display: flex;
@@ -1098,14 +1064,6 @@
font-weight: 500;
}
.group-format {
padding: 0 var(--su-space-2);
border-radius: var(--su-radius-xs);
background-color: var(--su-bg-subtle);
color: var(--su-text-muted);
font-size: var(--su-font-size-sm);
}
@@ -1192,7 +1150,9 @@ a.entry-href:hover {
}
/* 左栏吸顶:sticky 由滚动引擎处理,不会像 JS 改 transform 那样落后平滑滚动一帧。
高度锁在滚动区高度内,栏内只有「分组导航」可滚,资料卡与本地服务保持不动。 */
高度锁在滚动区高度内,栏内只有「分组导航」可滚,资料卡与本地服务保持不动。
只在两栏布局(>1100)生效;窄屏堆叠交给 Layout 自己处理。 */
@media (min-width: 1101px) {
#page-scroll > aside {
position: sticky;
top: 0;
@@ -1201,6 +1161,17 @@ a.entry-href:hover {
max-height: 100%;
overflow: hidden;
}
}
/* 侧栏卡片也贴着 aside 的边缘,阴影同样会被裁掉。
撑宽 16px 再补等量内边距,卡片宽度和位置都不变,阴影有地方画(窄屏堆叠时不适用)。 */
@media (min-width: 1101px) {
#page-scroll > aside {
width: calc(var(--layout-sider) + 32px);
margin: 0 -16px;
padding: 0 16px;
}
}
#page-scroll > aside .sidebar-wrap,
#page-scroll > aside .sidebar-home {
@@ -1243,27 +1214,15 @@ a.entry-href:hover {
/* 侧栏到内容区共用一条滚动虚化遮罩,同一水平高度 */
.sidebar-fade.is-visible {
opacity: 1;
}
/* 左侧卡片半透明,露出页面背景 */
.sidebar-home .profile-card,
.sidebar-home .service-panel,
.sidebar-home .nav-card {
background-color: color-mix(in srgb, var(--su-bg-card) 64%, transparent);
border-color: color-mix(in srgb, var(--su-card-border) 55%, transparent);
backdrop-filter: blur(14px) saturate(1.15);
}
/* 侧栏卡片保持实底:原来 64% 半透明 + 毛玻璃,同一张卡落在背景图不同亮度的位置
会显出深浅差,明暗两套主题也分不出来。现在和首页卡片同一种底,只有背景图透出卡片外面。 */
.sidebar-home .nav-card {
overflow: hidden;
border: 1px solid var(--su-card-border);
border-radius: var(--su-radius-md);
}
.sidebar-divider {
margin: var(--su-space-2) 0;
border-top: 1px dashed var(--su-border-strong);
background-color: var(--su-bg-card);
box-shadow: var(--su-shadow-card);
}
/* 首页卡片:左侧卡片列表 + 右侧分组表格 */
@@ -1342,16 +1301,6 @@ a.entry-href:hover {
}
/* 分组管理里状态为「关闭」的分组:卡片里只能看、不能勾选 */
.group-off-tag {
display: inline-block;
padding: 0 var(--su-space-2);
border-radius: var(--su-radius-pill);
background-color: var(--su-gray-2);
color: var(--su-text-muted);
font-size: var(--su-font-size-xs);
line-height: var(--su-line-height-sm);
white-space: nowrap;
}
/* 卡片表格的状态列:启用/已停用同一个胶囊,只换颜色 */
.card-state {
@@ -1373,11 +1322,6 @@ a.entry-href:hover {
color: var(--su-color-danger);
}
.group-state-on {
font-size: var(--su-font-size-xs);
color: var(--su-text-muted);
}
.card-order-index {
display: grid;
place-items: center;
@@ -1396,45 +1340,7 @@ a.entry-href:hover {
}
}
.admin-category-row > * {
min-width: 0;
}
/* 分区管理:一个分区一块,下面挂卡片预览与子分组 */
.admin-section-block {
display: flex;
flex-direction: column;
gap: var(--su-space-3);
padding-bottom: var(--su-space-4);
border-bottom: 1px dashed var(--su-border-strong);
}
.admin-block-row {
display: flex;
align-items: center;
gap: var(--su-space-2);
}
.admin-groups {
display: flex;
flex-direction: column;
gap: var(--su-space-2);
padding-left: var(--su-space-4);
}
.admin-group-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--su-space-2);
}
.admin-new-group-row {
display: grid;
grid-template-columns: 190px minmax(0, 1fr) auto;
align-items: center;
gap: var(--su-space-2);
}
/* 内容页:新增条目表单与条目列表 */
.admin-content-form {
@@ -1447,15 +1353,6 @@ a.entry-href:hover {
grid-column: 1 / -1;
}
.admin-entry-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px auto auto;
align-items: center;
gap: var(--su-space-2);
padding: var(--su-space-2) 0;
border-bottom: 1px solid var(--su-border);
}
.admin-item-actions .admin-remove:disabled {
@@ -1487,3 +1384,280 @@ a.entry-href:hover {
color: var(--su-color-danger);
background-color: color-mix(in srgb, var(--su-color-danger) 10%, transparent);
}
/* ---------- 明暗切换:短暂给所有表面补颜色过渡 ---------- */
/* 只在切换的 180ms 内加 !important,避免覆盖组件平时的 hover/展开动画。 */
html.theme-transition,
html.theme-transition *,
html.theme-transition *::before,
html.theme-transition *::after {
transition:
background-color 160ms var(--su-ease-in-out),
border-color 160ms var(--su-ease-in-out),
color 160ms var(--su-ease-in-out),
fill 160ms var(--su-ease-in-out),
stroke 160ms var(--su-ease-in-out),
box-shadow 160ms var(--su-ease-in-out) !important;
}
@media (prefers-reduced-motion: reduce) {
html.theme-transition,
html.theme-transition *,
html.theme-transition *::before,
html.theme-transition *::after {
transition: none !important;
}
}
/* 背景图不跟着全局颜色过渡,单独做明暗交叉淡入 */
html.theme-transition .site-background {
transition: opacity 160ms var(--su-ease-in-out) !important;
}
@media (prefers-reduced-motion: reduce) {
html.theme-transition .site-background {
transition: none !important;
}
}
/* 后台:背景管理预览 */
.background-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--su-space-5);
}
.background-section {
display: flex;
flex-direction: column;
gap: var(--su-space-3);
min-width: 0;
}
.background-preview {
position: relative;
height: 180px;
border: 1px solid var(--su-border);
border-radius: var(--su-radius-md);
background-color: var(--su-bg-subtle);
background-position: center top;
background-repeat: no-repeat;
background-size: cover;
overflow: hidden;
}
.background-preview span {
position: absolute;
bottom: var(--su-space-3);
left: var(--su-space-3);
padding: 2px var(--su-space-2);
border-radius: var(--su-radius-pill);
background-color: rgb(0 0 0 / 55%);
color: #fff;
font-size: var(--su-font-size-xs);
}
@media (width <= 900px) {
.background-grid {
grid-template-columns: minmax(0, 1fr);
}
}
/* 明暗切换:支持的浏览器直接对整页做快照交叉淡入,避免背景图和底色各变各的 */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 160ms;
animation-timing-function: var(--su-ease-in-out);
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
/* ---------- 搜索框:顶部引擎标签 + 下拉选项 ---------- */
.search-shell {
position: relative;
width: min(100%, 480px);
}
.engine-badge {
position: absolute;
top: -12px;
left: 16px;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border: 1px solid var(--su-border);
border-radius: 6px;
background-color: var(--su-bg-card);
box-shadow: var(--su-shadow-card);
color: var(--su-text-main);
font: inherit;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
transition:
background-color var(--su-duration-fast) var(--su-ease-in-out),
border-color var(--su-duration-fast) var(--su-ease-in-out);
}
.engine-badge:hover,
.engine-badge[aria-expanded='true'] {
border-color: var(--su-border-strong);
background-color: var(--su-bg-subtle);
}
.engine-badge svg {
display: block;
flex-shrink: 0;
}
.engine-dropdown {
position: absolute;
top: calc(100% + 8px);
left: 10px;
z-index: var(--su-z-float);
display: flex;
flex-direction: column;
gap: 2px;
min-width: 170px;
padding: var(--su-space-1);
border: 1px solid var(--su-border);
border-radius: var(--su-radius-sm);
background-color: var(--su-bg-card);
box-shadow: var(--su-shadow-pop);
animation: engine-menu-in 160ms var(--su-ease-out);
}
@keyframes engine-menu-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.engine-divider {
height: 1px;
margin: 4px 8px;
background-color: var(--su-border);
}
.engine-option {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--su-text-main);
font: inherit;
font-size: 13px;
text-align: left;
white-space: nowrap;
cursor: pointer;
transition: background-color var(--su-duration-fast) var(--su-ease-in-out);
}
.engine-option:hover {
background-color: var(--su-bg-subtle);
}
.engine-option.is-active {
background-color: var(--su-color-primary-soft);
color: var(--su-color-primary);
}
/* 后台:搜索框引擎配置 */
.search-engine-list {
display: flex;
flex-direction: column;
gap: var(--su-space-3);
}
.search-engine-row {
padding: var(--su-space-4);
border: 1px solid var(--su-border);
border-radius: var(--su-radius-md);
}
.search-engine-row.is-disabled {
opacity: 0.55;
}
.search-engine-head {
display: flex;
align-items: center;
gap: var(--su-space-2);
margin-bottom: var(--su-space-3);
}
.search-engine-head > :last-child {
margin-left: auto;
}
.search-engine-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: var(--su-space-3);
}
@media (width <= 900px) {
.search-engine-fields {
grid-template-columns: minmax(0, 1fr);
}
}
/* ---------- 窄屏覆盖(放文件末尾:要盖过上面的侧栏基础规则) ---------- */
@media (max-width: 1100px) {
/* 堆叠时的 aside / main 高度、换行、inner 滚动都由 Layout 负责(v0.3.2 起);
这里只管侧栏内部的排布 */
#page-scroll > aside .sidebar-wrap {
height: auto;
padding: 0 var(--su-space-4) var(--su-space-4);
}
/* 资料卡 + 导航卡并排,少占一屏 */
#page-scroll > aside .sidebar-home {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--su-space-3);
height: auto;
}
}
/* 手机(≤640)才收起侧栏:平板、分屏窗口都保留侧栏卡片 */
@media (max-width: 640px) {
#page-scroll > aside {
display: none;
}
#page-scroll > aside .sidebar-home {
display: block;
}
}
/* ---------- 小屏分组导航(抽屉用组件库的 Drawer) ---------- */
/* 桌面不显示菜单按钮(抽屉只能从小屏页头打开) */
.header-nav-toggle {
display: none;
}
@media (max-width: 640px) {
/* 手机页头才显示菜单按钮(必须写在上面的 display: none 之后) */
.header-nav-toggle {
display: inline-flex;
}
}
+344 -46
View File
@@ -1,11 +1,16 @@
import {
Breadcrumb,
Card,
Drawer,
Empty,
EngineIcon,
Layout,
Menu,
Notice,
SearchField,
Space,
Toaster,
type EngineIconName,
type MenuItem,
} from '@stash/ui'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -26,7 +31,10 @@ import { ServicePanel } from './components/sidebar/ServicePanel'
import { useProjects } from './useProjects'
import { fetchSession, logout, type SessionState } from './auth'
import {
DEFAULT_SEARCH_ENGINES,
fetchSettings,
readCachedBackground,
writeCachedBackground,
type SettingsDocument,
type SiteSettings,
} from './settings'
@@ -62,6 +70,27 @@ type Route =
| { kind: 'item'; key: string; itemId: string }
| { kind: 'api' }
const INTERNAL_SEARCH = { id: 'internal', name: '站内搜索', url: '' } as const
const SEARCH_ENGINE_KEY = 'stash-search-engine'
/** 上次选的搜索引擎:只存在这台浏览器,刷新后仍用它;失效时回落到站点默认 */
function readSearchEngine(): string | null {
try {
return window.localStorage.getItem(SEARCH_ENGINE_KEY)
} catch {
return null
}
}
function writeSearchEngine(id: string): void {
try {
window.localStorage.setItem(SEARCH_ENGINE_KEY, id)
} catch {
// 隐私模式下 localStorage 不可写,忽略即可
}
}
function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean)
if (segments[0] === 'interface') return { kind: 'api' }
@@ -181,6 +210,24 @@ function matches(entry: Record<string, unknown>, keyword: string): boolean {
return text.includes(keyword)
}
/** 本地搜索回车时打开第一个匹配项;没有条目命中但分组名命中时打开分组 */
function findLocalTarget(
groups: GroupNode[],
keyword: string,
): { group: GroupNode; entry?: Record<string, unknown> } | undefined {
for (const group of groups) {
if (group.enabled === false) continue
if (group.title.toLowerCase().includes(keyword)) return { group }
const entry = (group.entries as unknown as Record<string, unknown>[]).find((item) =>
matches(item, keyword),
)
if (entry !== undefined) return { group, entry }
const child = findLocalTarget(group.children, keyword)
if (child !== undefined) return child
}
return undefined
}
export default function AppNext() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
const { route, navigate } = useRoute()
@@ -189,10 +236,17 @@ export default function AppNext() {
const [keyword, setKeyword] = useState('')
const [session, setSession] = useState<SessionState | null>(null)
const [settings, setSettings] = useState<SettingsDocument | null>(null)
/** 上次缓存的背景:设置回来之前先用它上屏,避免刷新时先闪内置背景 */
const [cachedBackground] = useState(readCachedBackground)
const [loginOpen, setLoginOpen] = useState(false)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [scrolled, setScrolled] = useState(false)
/** 小屏的分组导航抽屉 */
const [navOpen, setNavOpen] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const [searchEngineId, setSearchEngineId] = useState<string | null>(readSearchEngine)
const [engineOpen, setEngineOpen] = useState(false)
const engineRef = useRef<HTMLDivElement>(null)
const load = () => {
void fetchManage().then((data) => {
@@ -218,6 +272,11 @@ export default function AppNext() {
return () => source.close()
}, [])
// 背景地址跟着设置存一份到本机:下次刷新首帧直接用它,不用等接口回来再换
useEffect(() => {
if (settings !== null) writeCachedBackground(settings.settings.background)
}, [settings])
useEffect(() => {
const content = document.getElementById('content-scroll')
const main = content?.parentElement
@@ -275,15 +334,13 @@ export default function AppNext() {
show_profile: true,
show_service: true,
show_nav: true,
hidden: [],
order: [],
hidden_groups: [],
shown_groups: [],
group_order: {},
tree_hidden: [],
tree_order: {},
},
sections: { hidden: [], order: [], titles: {}, limits: {} },
background: cachedBackground ?? { light: '', dark: '' },
font: { source: 'builtin', current: '', items: [] },
search: {
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
},
profile: {
name: '本地管理员',
role: '管理员',
@@ -302,19 +359,97 @@ export default function AppNext() {
const keywordLower = keyword.trim().toLowerCase()
const searchEngines = [
INTERNAL_SEARCH,
...site.search.engines
.filter((engine) => engine.enabled)
.map((engine) => ({ id: engine.id, name: engine.name, url: engine.url })),
]
const searchEngine =
searchEngines.find((engine) => engine.id === searchEngineId) ??
searchEngines.find((engine) => engine.id === site.search.default) ??
INTERNAL_SEARCH
/** 只有站内搜索模式才用输入内容过滤本地内容 */
const localKeyword = searchEngine.id === 'internal' ? keywordLower : ''
// 配色:本机偏好优先于站点默认,写在 <html data-scheme> 上,
// 深色由组件库的令牌块接管,页面与组件都不用改
const [themeOverride, setThemeOverride] = useState<ThemeChoice | null>(() =>
readThemeOverride(),
)
const effectiveTheme = themeOverride ?? site.site.theme
useEffect(() => {
document.documentElement.dataset.scheme = effectiveTheme
}, [effectiveTheme])
/** 当前实际是深色吗(auto 时按系统偏好算) */
const isDark = isDarkScheme(effectiveTheme)
const scheme = isDark ? 'dark' : 'light'
useEffect(() => {
const root = document.documentElement
if (root.dataset.scheme === scheme) return
const apply = () => {
root.dataset.scheme = scheme
}
const doc = document as Document & {
startViewTransition?: (callback: () => void) => unknown
}
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!reduceMotion && typeof doc.startViewTransition === 'function') {
try {
doc.startViewTransition(apply)
return
} catch {
// 浏览器正在跑另一段过渡时退回 CSS 过渡
}
}
root.classList.add('theme-transition')
apply()
const timer = window.setTimeout(() => root.classList.remove('theme-transition'), 180)
return () => {
window.clearTimeout(timer)
root.classList.remove('theme-transition')
}
}, [scheme])
// 正文字体:内置文楷,或启用「字体链接」里存好的某一条。
// 自定义时注入 @font-face(字体文件)或 CSS 链接,并覆盖字体令牌。
useEffect(() => {
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
}
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])
/** 页头的明暗切换:只写本机偏好,不需要登录;站点默认值在后台改 */
const toggleTheme = () => {
@@ -323,6 +458,59 @@ export default function AppNext() {
writeThemeOverride(next)
}
useEffect(() => {
if (!engineOpen) return
const close = (event: PointerEvent) => {
if (engineRef.current !== null && !engineRef.current.contains(event.target as Node)) {
setEngineOpen(false)
}
}
document.addEventListener('pointerdown', close)
return () => document.removeEventListener('pointerdown', close)
}, [engineOpen])
// 回车用当前搜索引擎打开新标签页;输入时的本地过滤逻辑保持不变
useEffect(() => {
const input = searchRef.current
if (input === null) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setEngineOpen(false)
return
}
if (event.key !== 'Enter' || keyword.trim() === '') return
event.preventDefault()
setEngineOpen(false)
if (searchEngine.id === 'internal') {
const target = findLocalTarget(manage.groups, keywordLower)
if (target !== undefined) {
navigate(
target.entry === undefined
? `/g/${encodeURIComponent(target.group.key)}`
: `/g/${encodeURIComponent(target.group.key)}/i/${encodeURIComponent(String(target.entry.id))}`,
)
}
return
}
const searchUrl = searchEngine.url.replaceAll(
'{query}',
encodeURIComponent(keyword.trim()),
)
window.open(searchUrl, '_blank', 'noopener,noreferrer')
}
input.addEventListener('keydown', handleKeyDown)
return () => input.removeEventListener('keydown', handleKeyDown)
}, [keyword, keywordLower, manage.groups, navigate, searchEngine])
const chooseEngine = (next: string) => {
setSearchEngineId(next)
writeSearchEngine(next)
setEngineOpen(false)
searchRef.current?.focus()
}
// 站点级设置:浏览器标签标题与 favicon(空值回落到内置)
useEffect(() => {
const title = site.site.title.trim()
@@ -367,12 +555,35 @@ export default function AppNext() {
? `g:${route.key}`
: `g:${route.key}`
/** 侧栏与手机抽屉共用:选完分组就关掉抽屉 */
const selectNav = (key: string) => {
setNavOpen(false)
if (key === 'home') navigate('/')
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
}
useEffect(() => {
if (!navOpen) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setNavOpen(false)
}
// 抽屉打开时锁住背后的滚动
const scroll = document.getElementById('page-scroll')
const previous = scroll?.style.overflowY ?? ''
if (scroll !== null) scroll.style.overflowY = 'hidden'
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
if (scroll !== null) scroll.style.overflowY = previous
}
}, [navOpen])
function renderEntries(
group: GroupNode,
entries: Record<string, unknown>[],
options: { detail?: boolean; limit?: number } = {},
) {
const filtered = entries.filter((entry) => matches(entry, keywordLower))
const filtered = entries.filter((entry) => matches(entry, localKeyword))
const limit = options.limit ?? filtered.length
const items = filtered.slice(0, limit)
const open = (itemId: string) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(itemId)}`)
@@ -404,7 +615,7 @@ export default function AppNext() {
const source = rootGroup
? (group.entries as unknown as Record<string, unknown>[])
: collectEntries(group)
const all = source.filter((entry) => matches(entry, keywordLower))
const all = source.filter((entry) => matches(entry, localKeyword))
if (all.length === 0) return null
return (
<div key={group.key} className="card-group-block">
@@ -473,7 +684,7 @@ export default function AppNext() {
{cards.map((card) =>
card.key === 'local-service' ? renderLocalService(card) : renderCard(card),
)}
{keywordLower !== '' && cards.length > 0 && (
{localKeyword !== '' && cards.length > 0 && (
<Notice title={`搜索结果:${keyword.trim()}`}>在卡片与分组内按标题过滤。</Notice>
)}
</div>
@@ -492,23 +703,19 @@ export default function AppNext() {
return (
<div className="detail-view">
<nav className="detail-breadcrumb group-crumbs">
<button type="button" className="link-button" onClick={() => navigate('/')}>
首页
</button>
{trail.map((node) => (
<span key={node.key}>
<span className="group-crumb-sep">/</span>
<button
type="button"
className="link-button"
onClick={() => navigate(`/g/${encodeURIComponent(node.key)}`)}
>
{node.title}
</button>
</span>
))}
</nav>
<Card padding="none" className="detail-nav">
<Breadcrumb
className="detail-breadcrumb"
items={[
{ label: '首页', href: '/' },
...trail.map((node) => ({
label: node.title,
href: `/g/${encodeURIComponent(node.key)}`,
})),
]}
onNavigate={navigate}
/>
</Card>
<div className="detail-hero">
<span className="detail-hero-icon" aria-hidden="true">
@@ -519,7 +726,9 @@ export default function AppNext() {
</div>
{group.entries.length > 0 ? (
renderEntries(group, group.entries)
<Card padding="md" className="group-own-block">
{renderEntries(group, group.entries)}
</Card>
) : group.children.some((child) => groupEntryCount(child) > 0) ? null : (
<Empty text="这个分组还没有内容" />
)}
@@ -528,7 +737,7 @@ export default function AppNext() {
{group.children
.filter((child) => child.enabled !== false)
.map((child) => (
<section key={child.key} className="group-child-block">
<Card key={child.key} padding="md" className="group-child-block">
<div className="group-title">
<span
className="group-bar"
@@ -551,7 +760,7 @@ export default function AppNext() {
) : (
<Empty text="暂无内容" />
)}
</section>
</Card>
))}
</Space>
</div>
@@ -644,7 +853,24 @@ export default function AppNext() {
}}
/>
<div className="site-background" aria-hidden="true" />
<div
className="site-background site-background-light"
style={
site.background.light.trim() === ''
? undefined
: { backgroundImage: `url(${JSON.stringify(site.background.light.trim())})` }
}
aria-hidden="true"
/>
<div
className="site-background site-background-dark"
style={
site.background.dark.trim() === ''
? undefined
: { backgroundImage: `url(${JSON.stringify(site.background.dark.trim())})` }
}
aria-hidden="true"
/>
<Layout
contained
@@ -671,6 +897,7 @@ export default function AppNext() {
headerSpeed={site.header.speed}
headerGap={site.header.gap}
site={site.site}
ready={settings !== null}
authenticated={session?.authenticated === true}
username={session?.username ?? undefined}
refreshing={refreshing}
@@ -685,26 +912,90 @@ export default function AppNext() {
void logout().then(() => setSession({ authenticated: false, has_admin: true }))
}}
onSettings={() => window.location.assign('/admin')}
onOpenNav={() => setNavOpen(true)}
/>
}
toolbar={
<div className="search-toolbar">
<div className="search-shell" ref={engineRef}>
<button
type="button"
className="engine-badge"
aria-haspopup="menu"
aria-expanded={engineOpen}
aria-label={`搜索引擎:${searchEngine.name},点击切换`}
title={
searchEngine.id === 'internal'
? '当前:站内搜索;回车打开第一个匹配项'
: `当前:${searchEngine.name};回车用该引擎搜索`
}
onClick={() => setEngineOpen((open) => !open)}
>
<EngineIcon name={searchEngine.id as EngineIconName} size={14} />
<span>{searchEngine.name}</span>
</button>
<SearchField
value={keyword}
onChange={setKeyword}
inputRef={searchRef}
placeholder="搜索本地项目…"
label="搜索本地项目"
maxWidth="100%"
placeholder={
searchEngine.id === 'internal'
? '搜索站内项目…'
: `使用 ${searchEngine.name} 搜索...`
}
label={
searchEngine.id === 'internal'
? '搜索站内项目,回车打开第一个匹配项'
: `使用 ${searchEngine.name} 搜索,回车打开新标签页`
}
variant="card"
hotkey="k"
/>
{engineOpen && (
<div className="engine-dropdown" role="menu" aria-label="选择搜索引擎">
<button
type="button"
role="menuitemradio"
aria-checked={searchEngine.id === 'internal'}
className={`engine-option${searchEngine.id === 'internal' ? ' is-active' : ''}`}
onClick={() => chooseEngine('internal')}
>
<EngineIcon name="internal" size={18} />
<span>站内搜索</span>
</button>
{searchEngines.length > 1 && (
<>
<div className="engine-divider" />
{searchEngines.slice(1).map((engine) => (
<button
key={engine.id}
type="button"
role="menuitemradio"
aria-checked={engine.id === searchEngine.id}
className={`engine-option${engine.id === searchEngine.id ? ' is-active' : ''}`}
onClick={() => chooseEngine(engine.id)}
>
<EngineIcon name={engine.id as EngineIconName} size={18} />
<span>{engine.name}</span>
</button>
))}
</>
)}
</div>
)}
</div>
</div>
}
sider={
<div className="sidebar-wrap">
<div className="sidebar sidebar-home">
{site.sidebar.show_profile !== false && <ProfileCard profile={site.profile} />}
{site.sidebar.show_service !== false && (
{settings !== null && site.sidebar.show_profile !== false && (
<ProfileCard profile={site.profile} />
)}
{settings !== null && site.sidebar.show_service !== false && (
<ServicePanel
projects={projects}
live={live}
@@ -713,16 +1004,13 @@ export default function AppNext() {
onOpenApi={() => navigate('/interface')}
/>
)}
{site.sidebar.show_nav !== false && (
{settings !== null && !loading && site.sidebar.show_nav !== false && (
<div className="nav-card">
<Menu
items={sidebarItems}
activeKey={activeKey}
itemHeight={39}
onSelect={(key) => {
if (key === 'home') navigate('/')
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
}}
onSelect={selectNav}
/>
</div>
)}
@@ -751,6 +1039,16 @@ export default function AppNext() {
</Layout>
<BackToTop visible={scrolled} targetId="page-scroll" />
<Drawer
open={navOpen}
onClose={() => setNavOpen(false)}
title="分组导航"
placement="left"
width={300}
>
<Menu items={sidebarItems} activeKey={activeKey} itemHeight={44} onSelect={selectNav} />
</Drawer>
</>
)
}
+19 -63
View File
@@ -1,5 +1,3 @@
import type { Category } from './data/types'
export type Project = {
name: string
route_path: string
@@ -26,12 +24,30 @@ export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
return Array.isArray(payload.projects) ? payload.projects : []
}
export type ProjectEventsHandlers = {
type ProjectEventsHandlers = {
onProjects: (projects: Project[]) => void
/** SSE 连接建立 / 断开时回调,浏览器的 EventSource 会自行重连。 */
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 时静默降级为空清理函数,页面仍可用手动刷新。
@@ -68,63 +84,3 @@ export function subscribeProjects(handlers: ProjectEventsHandlers): () => void {
source.close()
}
}
// ---------------------------------------------------------------------------
// 内容目录:数据来自后端的 SQLite 内容库(data/stash.db),不再是前端静态数组
// ---------------------------------------------------------------------------
const CATALOG_ENDPOINT = '/api/catalog'
const CATALOG_EVENTS_ENDPOINT = '/api/catalog/events'
type CatalogPayload = {
categories?: Category[]
}
/** 内容目录的完整返回;侧栏由这份分类树派生 */
export type Catalog = {
categories: Category[]
}
/**
* 拉取完整内容目录。
*
* 数据量只有几十 KB,一次全量取回即可;搜索仍然在前端按关键字过滤。
*/
export async function fetchCatalog(signal?: AbortSignal): Promise<Catalog> {
const response = await fetch(CATALOG_ENDPOINT, {
signal,
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(`请求 ${CATALOG_ENDPOINT} 失败:HTTP ${response.status}`)
}
const payload = (await response.json()) as CatalogPayload
return {
categories: Array.isArray(payload.categories) ? payload.categories : [],
}
}
/**
* 订阅内容库变更。
*
* 服务端用 SQLite 的 data_version 检测「外部工具改了库」,事件里不带数据,
* 只表示「该重新拉一次了」,避免两套数据结构需要保持同步。
*/
export function subscribeCatalog(onChange: () => void): () => void {
if (typeof EventSource === 'undefined') {
return () => {}
}
const source = new EventSource(CATALOG_EVENTS_ENDPOINT)
const handleChange = () => onChange()
source.addEventListener('catalog', handleChange)
return () => {
source.removeEventListener('catalog', handleChange)
source.close()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

+338 -5
View File
@@ -4,6 +4,7 @@ import {
Card,
Caret,
Empty,
EngineIcon,
FormField,
Icon,
ICON_NAMES,
@@ -27,6 +28,7 @@ import {
ToggleRow,
Toaster,
toast,
type EngineIconName,
type IconName,
type MenuItem,
} from '@stash/ui'
@@ -36,10 +38,15 @@ import { LoginDialog } from '../auth/LoginDialog'
import { GroupMark as GroupGlyph } from '../common/GroupMark'
import { isDarkScheme, readThemeOverride, writeThemeOverride } from '../../theme'
import {
DEFAULT_SEARCH_ENGINES,
fetchSettings,
saveSettings,
TONE_OPTIONS,
type BackgroundSettings,
type FontLink,
type FontSource,
type ProfileSettings,
type SearchSettings,
type SettingsDocument,
type SettingsTone,
type SiteSettings,
@@ -62,7 +69,9 @@ 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'
/**
* 新版后台:原子模型。
@@ -77,6 +86,8 @@ type TabKey =
| 'home-sidebar'
| 'home-cards'
| 'home-header'
| 'home-background'
| 'home-search'
| 'profile'
| 'system'
@@ -89,6 +100,8 @@ const ADMIN_NAV: MenuItem[] = [
label: '首页管理',
children: [
{ key: 'home-header', label: '页头' },
{ key: 'home-background', label: '背景管理' },
{ key: 'home-search', label: '搜索框' },
{ key: 'home-sidebar', label: '侧边栏' },
{ key: 'home-cards', label: '内容卡片' },
],
@@ -270,7 +283,7 @@ export function AdminNext() {
<div className="header-actions">
<IconButton
label={isDark ? '切换到浅色' : '切换到深色'}
icon={<Icon name={isDark ? 'sun_line' : 'moon_line'} />}
icon={<Icon name={isDark ? 'moon_line' : 'sun_line'} />}
onClick={() => void toggleTheme()}
disabled={!authenticated || saving}
/>
@@ -379,6 +392,20 @@ export function AdminNext() {
/>
)}
{tab === 'home-background' && (
<BackgroundPanel
value={draft.background}
onChange={(background) => setDraft({ ...draft, background })}
/>
)}
{tab === 'home-search' && (
<SearchPanel
value={draft.search}
onChange={(search) => setDraft({ ...draft, search })}
/>
)}
{tab === 'profile' && (
<ProfilePanel
value={draft.profile}
@@ -386,9 +413,18 @@ export function AdminNext() {
/>
)}
{tab === 'system' && <SystemPanel />}
{tab === 'system' && (
<SystemPanel
font={draft.font}
onChangeFont={(font) => setDraft({ ...draft, font })}
/>
)}
{(tab === 'home-header' || tab === 'profile') && (
{(tab === 'home-header' ||
tab === 'home-background' ||
tab === 'home-search' ||
tab === 'profile' ||
tab === 'system') && (
<Space gap="sm" justify="end" className="admin-actions">
<Button
variant="secondary"
@@ -2037,6 +2073,162 @@ function HeaderPanel({
)
}
function SearchPanel({
value,
onChange,
}: {
value: SearchSettings
onChange: (search: SearchSettings) => void
}) {
const updateEngine = (
id: string,
patch: Partial<SearchSettings['engines'][number]>,
) => {
const engines = value.engines.map((engine) =>
engine.id === id ? { ...engine, ...patch } : engine,
)
const nextDefault =
patch.enabled === false && value.default === id ? 'internal' : value.default
onChange({ ...value, engines, default: nextDefault })
}
const defaultOptions = [
{ value: 'internal', label: '站内搜索' },
...value.engines
.filter((engine) => engine.enabled)
.map((engine) => ({ value: engine.id, label: engine.name })),
]
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<Title level={4}>搜索框</Title>
<Text size="sm" tone="muted">
说明:站内搜索始终可用;外部搜索引擎可以改名称、地址和启停,地址里的 {'{query}'}{' '}
会替换成关键词。默认项是刷新后搜索框选中的引擎。
</Text>
<FormField label="默认搜索项">
<Select
options={defaultOptions}
value={value.default}
onChange={(next) => onChange({ ...value, default: next })}
/>
</FormField>
<div className="search-engine-list">
{value.engines.map((engine) => (
<div
className={`search-engine-row${engine.enabled ? '' : ' is-disabled'}`}
key={engine.id}
>
<div className="search-engine-head">
<EngineIcon name={engine.id as EngineIconName} size={18} />
<Text strong>{engine.name || engine.id}</Text>
<Switch
checked={engine.enabled}
label={`启用 ${engine.name || engine.id}`}
onChange={(enabled) => updateEngine(engine.id, { enabled })}
/>
</div>
<div className="search-engine-fields">
<FormField label="名称" htmlFor={`search-name-${engine.id}`}>
<Input
id={`search-name-${engine.id}`}
value={engine.name}
onChange={(event) => updateEngine(engine.id, { name: event.target.value })}
/>
</FormField>
<FormField
label="搜索地址"
htmlFor={`search-url-${engine.id}`}
hint="必须包含 {query}"
>
<Input
id={`search-url-${engine.id}`}
value={engine.url}
onChange={(event) => updateEngine(engine.id, { url: event.target.value })}
/>
</FormField>
</div>
</div>
))}
</div>
<div>
<Button
variant="secondary"
onClick={() =>
onChange({
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
})
}
>
恢复默认引擎
</Button>
</div>
</Space>
)
}
function BackgroundPanel({
value,
onChange,
}: {
value: BackgroundSettings
onChange: (background: BackgroundSettings) => void
}) {
const update = (patch: Partial<BackgroundSettings>) => onChange({ ...value, ...patch })
const preview = (url: string, fallback: string) => ({
backgroundImage: `url(${JSON.stringify(url.trim() || fallback)})`,
})
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<Title level={4}>背景管理</Title>
<Text size="sm" tone="muted">
说明:分别设置亮色与暗色模式顶部的背景图,留空使用内置图;站内图片请放到托管目录 public/ 后填写路径,或填完整 URL。
</Text>
<div className="background-grid">
<div className="background-section">
<FormField label="亮色背景图" htmlFor="background-light" hint="留空使用内置亮色背景">
<Input
id="background-light"
value={value.light}
placeholder="/bg-light.jpg 或 https://…"
onChange={(event) => update({ light: event.target.value })}
/>
</FormField>
<div className="background-preview" style={preview(value.light, siteBgLight)}>
<span>亮色预览</span>
</div>
</div>
<div className="background-section">
<FormField label="暗色背景图" htmlFor="background-dark" hint="留空使用内置暗色背景">
<Input
id="background-dark"
value={value.dark}
placeholder="/bg-dark.jpg 或 https://…"
onChange={(event) => update({ dark: event.target.value })}
/>
</FormField>
<div className="background-preview" style={preview(value.dark, siteBgDark)}>
<span>暗色预览</span>
</div>
</div>
</div>
<div>
<Button variant="secondary" onClick={() => onChange({ light: '', dark: '' })}>
恢复内置背景
</Button>
</div>
</Space>
)
}
function ProfilePanel({
value,
onChange,
@@ -2087,11 +2279,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)
@@ -2107,8 +2311,137 @@ function SystemPanel() {
setNext('')
}
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">
<Text size="sm" strong>
正文字体
</Text>
<Text size="sm" tone="muted">
说明:内置是霞鹜文楷。选「字体链接」后,可以在下面存几套常用字体(font/ 目录里的文件或外部
链接),需要哪个就启用哪个,不用每次重填。
</Text>
<FormField label="字体来源" htmlFor="font-source">
<Select
options={[
{ value: 'builtin', label: '内置(霞鹜文楷)' },
{ value: 'custom', label: '字体链接' },
]}
value={font.source}
onChange={(source) => update({ source: source as FontSource })}
/>
</FormField>
{font.source === 'custom' && (
<>
{/* 没有字体时不渲染下拉:空列表点开只会弹出一个空白面板 */}
{font.items.length > 0 ? (
<FormField
label="当前使用的字体"
htmlFor="font-current"
hint="从已保存的字体里选一个启用"
>
<Select
options={font.items.map((item) => ({
value: item.key,
label: item.name === '' ? item.key : item.name,
}))}
value={activeKey}
onChange={(current) => update({ current })}
/>
</FormField>
) : (
<Text size="sm" tone="muted">
还没有字体,点下面的「添加字体」存一条,再回来选。
</Text>
)}
{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"
+5 -12
View File
@@ -26,14 +26,13 @@ const GROUPS: { title: string; desc: string; items: Endpoint[] }[] = [
},
{
title: '内容库',
desc: '分区 / 分组 / 条目 / 卡片。写操作都需要登录会话与同源 Origin。',
desc: '分组树 / 条目 / 卡片。写操作都需要登录会话与同源 Origin。',
items: [
{ method: 'GET', path: '/api/catalog', summary: '旧版完整目录(兼容保留)' },
{ method: 'GET', path: '/api/catalog2', summary: '分组树 + 卡片,后台配置用' },
{ method: 'GET', path: '/api/catalog2', summary: '分组树 + 卡片,前台与后台共用' },
{ method: 'GET', path: '/api/catalog/events', summary: 'SSE:内容库变更时推送「该重拉」' },
{ method: 'POST', path: '/api/nodes', summary: '新建分区 / 分组', auth: true },
{ method: 'POST', path: '/api/nodes', summary: '新建根分组 / 子分组', auth: true },
{ method: 'PATCH', path: '/api/nodes/{key}', summary: '改标题、所属、图标、排序、启停', auth: true },
{ method: 'DELETE', path: '/api/nodes/{key}', summary: '删除分区 / 分组(级联子节点)', auth: true },
{ method: 'DELETE', path: '/api/nodes/{key}', summary: '删除分组(级联子分组与条目)', auth: true },
{ method: 'GET', path: '/api/items/{key}', summary: '条目详情' },
{ method: 'POST', path: '/api/items', summary: '新建条目', auth: true },
{ method: 'PATCH', path: '/api/items/{key}', summary: '就地编辑(带 updatedAt 乐观锁)', auth: true },
@@ -44,12 +43,6 @@ const GROUPS: { title: string; desc: string; items: Endpoint[] }[] = [
{ method: 'PATCH', path: '/api/cards/{id}', summary: '改卡片标题 / 顺序', auth: true },
{ method: 'DELETE', path: '/api/cards/{id}', summary: '删除卡片', auth: true },
{ method: 'PUT', path: '/api/cards/{id}/groups', summary: '整组替换卡片包含的分组与顺序', auth: true },
{ method: 'POST', path: '/api/categories', summary: '旧版:新建分类', auth: true },
{ method: 'PATCH', path: '/api/categories/{id}', summary: '旧版:改分类', auth: true },
{ method: 'DELETE', path: '/api/categories/{id}', summary: '旧版:删分类', auth: true },
{ method: 'POST', path: '/api/categories/{id}/groups', summary: '旧版:新建分组', auth: true },
{ method: 'PATCH', path: '/api/groups/{id}', summary: '旧版:改分组', auth: true },
{ method: 'DELETE', path: '/api/groups/{id}', summary: '旧版:删分组', auth: true },
],
},
{
@@ -89,7 +82,7 @@ export function ApiDocs() {
<Title level={2}>接口一览</Title>
<Text size="sm" tone="muted">
说明:这是 Stash 自带的 HTTP 接口清单,全部挂在同一个端口上。读接口公开,写接口需要登录会话并校验同源
Origin;带 <Tag tone="primary">需登录</Tag> 标记的即为写操作。
Origin;带 <Tag tone="primary">需登录</Tag> 标记的需要登录。
</Text>
</Space>
+25 -3
View File
@@ -25,6 +25,8 @@ type SiteHeaderProps = {
headerGap?: number
/** 品牌与操作工具配置 */
site: SiteMetaSettings
/** 站点设置是否已加载;未加载时不渲染依赖配置的图标,避免先显示再消失 */
ready?: boolean
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */
authenticated: boolean
username?: string
@@ -37,6 +39,8 @@ type SiteHeaderProps = {
onLogin: () => void
onLogout: () => void
onSettings: () => void
/** 小屏打开分组导航抽屉;不传则不显示菜单按钮 */
onOpenNav?: () => void
}
const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = {
@@ -55,6 +59,7 @@ export function SiteHeader({
headerSpeed,
headerGap,
site,
ready = true,
authenticated,
username,
refreshing = false,
@@ -64,6 +69,7 @@ export function SiteHeader({
onLogin,
onLogout,
onSettings,
onOpenNav,
}: SiteHeaderProps) {
const context = { count: projectCount, project: sampleProject, live }
@@ -77,7 +83,7 @@ export function SiteHeader({
/>
<div className="header-ticker-slot">
{headerEnabled && (
{ready && headerEnabled && (
<Ticker speed={headerSpeed} gap={headerGap} label="运行状态" className="header-ticker">
{headerItems.map((item, index) => (
<span className="header-ticker-item" key={`${index}-${item.text}`}>
@@ -94,10 +100,20 @@ export function SiteHeader({
</div>
<div className="header-actions">
{onOpenNav !== undefined && (
<IconButton
className="header-nav-toggle"
label="分组导航"
icon={<Icon name="menu_line" />}
onClick={onOpenNav}
/>
)}
{ready && (
<>
{site.actions.theme && (
<IconButton
label={dark ? '切换到浅色' : '切换到深色'}
icon={<Icon name={dark ? 'sun_line' : 'moon_line'} />}
icon={<Icon name={dark ? 'moon_line' : 'sun_line'} />}
onClick={onToggleTheme}
/>
)}
@@ -110,7 +126,11 @@ export function SiteHeader({
/>
)}
{site.actions.settings && authenticated && (
<IconButton label="站点设置" icon={<Icon name="settings_1_line" />} onClick={onSettings} />
<IconButton
label="站点设置"
icon={<Icon name="settings_1_line" />}
onClick={onSettings}
/>
)}
{site.actions.session &&
(authenticated ? (
@@ -122,6 +142,8 @@ export function SiteHeader({
) : (
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
))}
</>
)}
</div>
</div>
)
+1 -172
View File
@@ -1,8 +1,5 @@
/**
* 内容数据模型。
*
* 首页把每个「分类」渲染成一张卡片,明细页按分类的 kind 渲染不同网格,
* 因此这里用可辨识联合把三种展示形态分开。
* 内容数据模型:应用 / 海报 / 图文三种条目,以及详情页的推荐条目。
*/
/** 一个可点击的应用 / 站点条目 */
@@ -30,17 +27,6 @@ export type AppItem = {
likes?: number
}
/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */
export type ContentGroup<T> = {
id: string
name: string
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
items: T[]
}
export type AppGroup = ContentGroup<AppItem>
/** 竖版海报条目(电影) */
export type PosterItem = {
id: string
@@ -79,160 +65,3 @@ export type ArticleItem = {
body?: string
}
/** 首页展示块:分区卡片展示哪个节点、显示几条 */
export type SectionBlock = {
/** 要展示的节点业务键:通常是分组;电影/文章这类无分组的填分区自己 */
nodeKey: string
/** 块标题,缺省用节点标题 */
title?: string
/** 卡片显示几条,缺省用分类的 previewLimit 或默认值 */
previewLimit?: number
}
/** 应用类分类:内部按分组组织 */
export type AppCategory = {
id: string
kind: 'apps'
title: string
/** 标题左侧色条颜色,各分类用不同颜色区分 */
barColor: string
/** 明细页 hero 区的字符图标 */
icon: string
groups: AppGroup[]
/** 直接挂在分区上的条目(未分组) */
items?: AppItem[]
/** 首页预览条数,默认 4 */
previewLimit?: number
/** 首页预览为空时的提示文案 */
emptyText?: string
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
/** 海报类分类:竖版海报网格 */
export type PosterCategory = {
id: string
kind: 'posters'
title: string
barColor: string
icon: string
groups: ContentGroup<PosterItem>[]
/** 直接挂在分区上的条目(未分组) */
items: PosterItem[]
/** 首页预览条数,默认 6 */
previewLimit?: number
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
/** 图文类分类:上图下文卡片网格 */
export type ArticleCategory = {
id: string
kind: 'articles'
title: string
barColor: string
icon: string
groups: ContentGroup<ArticleItem>[]
/** 直接挂在分区上的条目(未分组) */
items: ArticleItem[]
/** 首页预览条数,默认 4 */
previewLimit?: number
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
export type Category = AppCategory | PosterCategory | ArticleCategory
/** 分类内的条目总数(直属 + 分组的),用于角标与明细页计数 */
export function categoryCount(category: Category): number {
const direct = category.items?.length ?? 0
const grouped = category.groups.reduce((total, group) => total + group.items.length, 0)
return direct + grouped
}
/** 把应用类分类的条目拍平(分组 + 直属),首页预览用 */
export function flattenAppItems(category: AppCategory): AppItem[] {
return [
...category.groups.flatMap((group) => group.items),
...(category.items ?? []),
]
}
/** 按 id 在分类里查找条目;详情页用 */
export function findAppItem(category: Category, itemId: string): AppItem | undefined {
if (category.kind !== 'apps') return undefined
const direct = (category.items ?? []).find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
/** 取同分类下的其他条目,用于详情页的「推荐阅读」 */
export function relatedAppItems(
category: Category,
excludeId: string | undefined,
limit = 4,
): AppItem[] {
if (category.kind !== 'apps') return []
return flattenAppItems(category)
.filter((item) => item.id !== excludeId)
.slice(0, limit)
}
/** 按 id 查找海报条目 */
export function findPosterItem(
category: Category,
itemId: string,
): PosterItem | undefined {
if (category.kind !== 'posters') return undefined
const direct = category.items.find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
/** 取同分类下的其他海报,用于详情页的「推荐阅读」 */
export function relatedPosterItems(
category: Category,
excludeId: string | undefined,
limit = 4,
): PosterItem[] {
if (category.kind !== 'posters') return []
return [
...category.items,
...category.groups.flatMap((group) => group.items),
]
.filter((item) => item.id !== excludeId)
.slice(0, limit)
}
/** 按 id 查找图文条目 */
export function findArticleItem(
category: Category,
itemId: string,
): ArticleItem | undefined {
if (category.kind !== 'articles') return undefined
const direct = category.items.find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
+2 -60
View File
@@ -16,7 +16,7 @@
* 条目地址带上它自己所在的分组,URL 才是自解释的:刷新之后点「返回」
* 仍然落回条目所属的分组,而不是去猜一个返回目标。
*
* 这里只做「地址 ↔ 视图」的纯转换,读写 history 的部分在 `useViewRoute.ts`。
* 这里只做「视图 → 地址」的纯转换,读写 history 的部分在 `AppNext.tsx`。
*/
export type DetailRoute = {
@@ -45,15 +45,10 @@ export type View =
| ItemRoute
/** 前端路由前缀;改动这里要同步 `src/server.rs` 的 `SPA_PREFIXES`。 */
const CATEGORY_PREFIX = '/c/'
/** 前缀去掉两侧斜杠后的那一段,拼地址时当第一个路径段用 */
const CATEGORY_SEGMENT = CATEGORY_PREFIX.slice(1, -1)
const CATEGORY_SEGMENT = 'c'
/** 条目段标记:`/c/<分区>[/<分组>]/i/<条目>` */
const ITEM_MARK = 'i'
/** 首页视图;未知地址一律归到这里 */
export const HOME_VIEW: View = { type: 'home' }
/** 把视图转成地址。 */
export function viewPath(view: View): string {
if (view.type === 'home') return '/'
@@ -65,60 +60,7 @@ export function viewPath(view: View): string {
return `/${segments.join('/')}`
}
/**
* 把地址解析成视图;看不懂的地址(段数不对、百分号编码非法)按首页处理。
*
* 这里只做形状校验,不判断分类 / 条目是否真的存在——那是数据层的事,
* 而且首屏还没拿到内容库时,它们本来就「暂时不存在」。
*/
export function parseViewPath(pathname: string): View {
if (!pathname.startsWith(CATEGORY_PREFIX)) return HOME_VIEW
const segments = pathname
.slice(CATEGORY_PREFIX.length)
.split('/')
.filter((segment) => segment !== '')
const [categorySegment] = segments
if (categorySegment === undefined) return HOME_VIEW
const categoryId = decodeSegment(categorySegment)
if (categoryId === undefined) return HOME_VIEW
if (segments.length === 1) return { type: 'detail', categoryId }
if (segments.length === 2) {
const groupId = decodeSegment(segments[1])
if (groupId === undefined) return HOME_VIEW
return { type: 'detail', categoryId, groupId }
}
if (segments.length === 3 && segments[1] === ITEM_MARK) {
const itemId = decodeSegment(segments[2])
if (itemId === undefined) return HOME_VIEW
return { type: 'item', categoryId, itemId }
}
if (segments.length === 4 && segments[2] === ITEM_MARK) {
const groupId = decodeSegment(segments[1])
const itemId = decodeSegment(segments[3])
if (groupId === undefined || itemId === undefined) return HOME_VIEW
return { type: 'item', categoryId, itemId, groupId }
}
return HOME_VIEW
}
/** 单个路径段编码:id 里可能带斜杠、空格、中文 */
function encodeSegment(value: string): string {
return encodeURIComponent(value)
}
/** 解码单个路径段;百分号编码非法时返回 undefined */
function decodeSegment(segment: string): string | undefined {
try {
return decodeURIComponent(segment)
} catch {
return undefined
}
}
+4
View File
@@ -11,6 +11,10 @@
-moz-osx-font-smoothing: grayscale;
}
html[data-scheme='dark'] {
color-scheme: dark;
}
* {
box-sizing: border-box;
margin: 0;
+117 -259
View File
@@ -1,10 +1,9 @@
/**
* 站点设置:页头滚动信息 + 侧栏顺序/显隐。
* 站点设置:页头滚动信息 + 侧栏卡片显隐。
*
* 接口是公开读、登录后写;写的时候带 `version` 做乐观锁,
* 冲突(多标签页同时改)时返回 409,页面提示刷新。
*/
import type { Category } from './data/types'
export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral'
@@ -14,13 +13,6 @@ export type HeaderItem = {
tone: SettingsTone
}
export type SectionSettings = {
hidden: string[]
order: string[]
titles: Record<string, string>
limits: Record<string, number>
}
export type ProfileSettings = {
name: string
role: string
@@ -31,6 +23,111 @@ export type ProfileSettings = {
avatar: string
}
export type BackgroundSettings = {
/** 亮色背景图地址;空表示使用内置图 */
light: string
/** 暗色背景图地址;空表示使用内置图 */
dark: string
}
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'
/**
* 上次拿到的背景地址。
*
* 设置要等 `/api/settings` 回来,刷新时首帧只能先画内置背景,自定义背景随后才换上来,
* 会闪一下。先用上次缓存上屏,等接口回来再校准。
*/
export function readCachedBackground(): BackgroundSettings | null {
try {
const raw = window.localStorage.getItem(BACKGROUND_CACHE_KEY)
if (raw === null) return null
const value = JSON.parse(raw) as Partial<BackgroundSettings>
if (typeof value.light !== 'string' || typeof value.dark !== 'string') return null
return { light: value.light, dark: value.dark }
} catch {
return null
}
}
export function writeCachedBackground(background: BackgroundSettings): void {
try {
window.localStorage.setItem(BACKGROUND_CACHE_KEY, JSON.stringify(background))
} catch {
// 隐私模式下 localStorage 不可写,忽略即可
}
}
export type SearchEngineId = 'google' | 'bing' | 'baidu' | 'sogou' | '360'
export type SearchEngineSettings = {
id: SearchEngineId
name: string
/** 搜索地址模板,用 {query} 表示关键词 */
url: string
enabled: boolean
}
export type SearchSettings = {
/** `internal` 或某个引擎 id */
default: string
engines: SearchEngineSettings[]
}
/** 首次启动 / 恢复默认时的搜索引擎清单 */
export const DEFAULT_SEARCH_ENGINES: SearchEngineSettings[] = [
{
id: 'google',
name: 'Google',
url: 'https://www.google.com/search?q={query}',
enabled: true,
},
{
id: 'bing',
name: 'Bing',
url: 'https://www.bing.com/search?q={query}',
enabled: true,
},
{
id: 'baidu',
name: '百度',
url: 'https://www.baidu.com/s?wd={query}',
enabled: true,
},
{
id: 'sogou',
name: '搜狗',
url: 'https://www.sogou.com/web?query={query}',
enabled: true,
},
{
id: '360',
name: '360',
url: 'https://www.so.com/s?q={query}',
enabled: true,
},
]
export type SiteSettings = {
header: {
enabled: boolean
@@ -47,20 +144,10 @@ export type SiteSettings = {
show_service: boolean
/** 是否显示「分组导航」卡片(首页 / 分组树) */
show_nav: boolean
hidden: string[]
order: string[]
/** 隐藏的分组:`分区id/分组id` */
hidden_groups: string[]
/** 强制显示的分组(覆盖数据库 show_in_sidebar=0) */
shown_groups: string[]
/** 每个分区下的分组顺序:分区 id → 分组 id 列表 */
group_order: Record<string, string[]>
/** 新模型:侧栏隐藏的分组 key */
tree_hidden: string[]
/** 新模型:每级分组的顺序,键为父分组 key(根级用空串) */
tree_order: Record<string, string[]>
}
sections: SectionSettings
background: BackgroundSettings
font: FontSettings
search: SearchSettings
profile: ProfileSettings
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
site: SiteMetaSettings
@@ -127,15 +214,13 @@ export function defaultSettings(): SiteSettings {
show_profile: true,
show_service: true,
show_nav: true,
hidden: [],
order: [],
hidden_groups: [],
shown_groups: [],
group_order: {},
tree_hidden: [],
tree_order: {},
},
sections: { hidden: [], order: [], titles: {}, limits: {} },
background: { light: '', dark: '' },
font: { source: 'builtin', current: '', items: [] },
search: {
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
},
profile: {
name: '本地管理员',
role: '管理员',
@@ -177,7 +262,8 @@ export async function fetchSettings(): Promise<SettingsDocument> {
export async function saveSettings(
version: number,
settings: SiteSettings,
): Promise<SettingsResult> { try {
): Promise<SettingsResult> {
try {
const response = await fetch('/api/settings', {
method: 'PATCH',
credentials: 'same-origin',
@@ -203,231 +289,3 @@ export async function saveSettings(
}
}
}
/**
* 按设置重排侧栏:先按 `order`,未列出的保持原顺序,`hidden` 过滤掉。
*/export function applySidebarSettings<T extends { id: string }>(
categories: T[],
sidebar: SiteSettings['sidebar'],
): T[] {
return orderAndFilter(categories, sidebar)
}
/** 首页卡片:过滤隐藏、按 order 排序(未列出的保持原顺序) */
export function applySectionSettings<T extends { id: string }>(
categories: T[],
sections: SectionSettings,
): T[] {
return orderAndFilter(categories, sections)
}
/**
* 侧栏子分类(分组):按设置过滤、排序。
*
* 数据库里的 `show_in_sidebar` 是默认值;`shown_groups` 可以把它强行打开,
* `hidden_groups` 则再隐藏一层。
*/
export function applySidebarGroups(
categories: Category[],
sidebar: SiteSettings['sidebar'],
): Category[] {
return categories.map((category) => {
if (category.kind === 'apps') {
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
}
if (category.kind === 'posters') {
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
}
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
})
}
/** 按设置过滤、排序某个分区下的分组 */
function filterGroups<T extends { id: string }>(
groups: T[],
categoryId: string,
sidebar: SiteSettings['sidebar'],
): T[] {
const hidden = new Set(sidebar.hidden_groups)
const shown = new Set(sidebar.shown_groups)
const ranks = new Map(
(sidebar.group_order[categoryId] ?? []).map((id, index) => [id, index] as const),
)
return groups
.filter((group) => {
const key = `${categoryId}/${group.id}`
const showInSidebar = (group as { showInSidebar?: boolean }).showInSidebar
return !hidden.has(key) && (showInSidebar !== false || shown.has(key))
})
.map((group, index) => ({
group,
index,
rank: ranks.get(group.id) ?? Number.MAX_SAFE_INTEGER,
}))
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
.map((entry) => entry.group)
}
/** 卡片标题:设置里的覆盖值优先 */
export function sectionTitle(
id: string,
fallback: string,
sections: SectionSettings,
): string {
const override = sections.titles[id]
return override !== undefined && override.trim() !== '' ? override : fallback
}
/** 卡片预览条数:设置里的覆盖值优先 */
export function sectionLimit(
id: string,
fallback: number,
sections: SectionSettings,
): number {
const override = sections.limits[id]
return typeof override === 'number' && override > 0 ? override : fallback
}
function orderAndFilter<T extends { id: string }>( categories: T[],
config: { hidden: string[]; order: string[] },
): T[] {
const hidden = new Set(config.hidden)
const ranks = new Map(config.order.map((id, index) => [id, index]))
return categories
.filter((category) => !hidden.has(category.id))
.map((category, index) => ({
category,
index,
rank: ranks.get(category.id) ?? Number.MAX_SAFE_INTEGER,
}))
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
.map((entry) => entry.category)
}
// ---------- 分区(首页卡片 / 侧栏项)的新增与删除 ----------
export type NewCategory = { id: string; title: string; kind: 'apps' | 'posters' | 'articles' }
export type SimpleResult = { ok: true } | { ok: false; error: string }
export async function createCategory(input: NewCategory): Promise<SimpleResult> {
try {
const response = await fetch('/api/categories', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function deleteCategory(id: string): Promise<SimpleResult> {
try {
const response = await fetch(`/api/categories/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'same-origin',
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
/** 修改分区(归类、标题、图标、色条、预览条数) */
export type UpdateCategoryInput = {
title?: string
icon?: string
barColor?: string
kind?: 'apps' | 'posters' | 'articles'
previewLimit?: number
emptyText?: string
/** 首页卡片预览哪个节点(分区自己或其分组) */
blockNodeKey?: string
/** 是否允许子分组(与类型无关的开关) */
allowGroups?: boolean
}
export async function updateCategory(
id: string,
patch: UpdateCategoryInput,
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/categories/${encodeURIComponent(id)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `保存失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
// ---------- 分组(子分类) ----------
export async function createGroup(
sectionId: string,
input: { id: string; title: string },
): Promise<SimpleResult> {
try {
const response = await fetch(
`/api/categories/${encodeURIComponent(sectionId)}/groups`,
{
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
},
)
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function updateGroup(
id: string,
patch: { title?: string; showInSidebar?: boolean },
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `保存失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function deleteGroup(id: string): Promise<SimpleResult> {
try {
const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'same-origin',
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}