Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f1a8863f4 | ||
|
|
a0be02b4f4 | ||
|
|
ff5c6f05a2 | ||
|
|
9948e43e6c | ||
|
|
9c8e05c0ee | ||
|
|
6e17169ac4 | ||
|
|
960ea1c5eb | ||
|
|
0aebd610fe | ||
|
|
97b9d00cf3 | ||
|
|
0bb4cc354a | ||
|
|
e243a20b69 | ||
|
|
21825431c7 | ||
|
|
65c7fe79dd | ||
|
|
93f80af893 | ||
|
|
4ce1b7e7c1 | ||
|
|
a9a913942a | ||
|
|
1d1ece6a2a | ||
|
|
9d1e5dcb60 | ||
|
|
4862f8fb6b | ||
|
|
aa6f341505 | ||
|
|
5ae2088826 | ||
|
|
200908cef6 | ||
|
|
56f362f921 | ||
|
|
8c1c658b15 | ||
|
|
038b7ecb42 | ||
|
|
a5d7859c6e | ||
|
|
5439ebf3be | ||
|
|
b9005dd514 | ||
|
|
1f8147dd73 | ||
|
|
5736bbc101 | ||
|
|
3330af818e | ||
|
|
dacb30e548 | ||
|
|
14938a9c83 | ||
|
|
6dabeabaf2 | ||
|
|
ffa123d2cd |
+1
-1
@@ -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
@@ -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`,确认工作区干净。
|
||||
|
||||
@@ -55,7 +55,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
||||
- **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除;
|
||||
- **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除;
|
||||
- **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示);
|
||||
- **资料管理**与**系统管理**(改密码、下载整库备份);
|
||||
- **资料管理**与**系统管理**(改密码、下载整库备份;系统管理里还能设正文字体:内置文楷 / 字体链接二选一,链接模式下可存多套字体(`font/` 目录里的文件或外部链接)并随时启用其中一套);
|
||||
- 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效;
|
||||
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
|
||||
- `/admin` 的「系统管理」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
||||
@@ -77,6 +77,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
||||
| `/c/**`、`/g/**` | 前端路由地址(分组页 / 条目详情),没有实体文件时同样返回 `index.html` |
|
||||
| `/admin`、`/login`、`/interface` | 固定页面地址,同样返回 `index.html` |
|
||||
| `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) |
|
||||
| `/fonts/**` | 程序同级 `font/` 目录里的字体文件(后台字体设置用,`no-cache`) |
|
||||
| `/favicon.svg` | 内嵌前端根目录文件 |
|
||||
| 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` |
|
||||
| 仍未命中 | 内置 404 页面(显示请求路径) |
|
||||
@@ -544,7 +545,7 @@ data: {"changed":true}
|
||||
| --- | --- |
|
||||
| 健康与项目 | `GET /api/health`、`GET /api/projects`、`GET /api/projects/events`(SSE) |
|
||||
| 内容库 | `GET /api/catalog2`、`GET /api/catalog/events`(SSE)、`POST /api/nodes`、`PATCH/DELETE /api/nodes/{key}`、`GET /api/items/{key}`、`POST /api/items`、`PATCH/DELETE /api/items/{key}`、`POST /api/items/{key}/view`、`POST /api/items/{key}/like`、`POST /api/cards`、`PATCH/DELETE /api/cards/{id}`、`PUT /api/cards/{id}/groups` |
|
||||
| 站点设置 | `GET/PATCH /api/settings`、`GET /api/backup` |
|
||||
| 站点设置 | `GET/PATCH /api/settings`、`GET /api/fonts`(`font/` 目录里的字体清单)、`GET /api/backup` |
|
||||
| 认证 | `GET /api/auth/session`、`POST /api/auth/setup`、`POST /api/auth/login`、`POST /api/auth/logout`、`PATCH /api/auth/password` |
|
||||
|
||||
写接口(POST / PATCH / PUT / DELETE)都要求登录会话,并校验请求的 `Origin` 与本站同源。
|
||||
|
||||
@@ -4,6 +4,8 @@ use crate::error::StashError;
|
||||
|
||||
/// 数据目录名:与二进制同级,存放可被用户直接修改的内容库。
|
||||
pub const DATA_DIR_NAME: &str = "data";
|
||||
/// 字体目录名:与二进制同级,后台「系统管理 → 字体」只从这个目录列字体。
|
||||
pub const FONT_DIR_NAME: &str = "font";
|
||||
/// 内容库文件名:固定为这个名字,一个实例一个库。
|
||||
pub const DB_FILE_NAME: &str = "stash.db";
|
||||
|
||||
@@ -13,6 +15,8 @@ pub struct Config {
|
||||
pub root: PathBuf,
|
||||
/// 数据目录:存放内容库与将来的备份 / 导出文件
|
||||
pub data_dir: PathBuf,
|
||||
/// 字体目录:后台字体下拉只列这里的文件,通过 `/fonts/<文件名>` 对外提供
|
||||
pub font_dir: PathBuf,
|
||||
/// 内容库路径,固定为 `data/stash.db`
|
||||
pub db_path: PathBuf,
|
||||
/// 监听端口
|
||||
@@ -34,6 +38,7 @@ impl Default for Config {
|
||||
root: PathBuf::from("../public"),
|
||||
db_path: resolve_db_path(&data_dir),
|
||||
data_dir,
|
||||
font_dir: PathBuf::from(FONT_DIR_NAME),
|
||||
port: 8080,
|
||||
host: "127.0.0.1".to_string(),
|
||||
directory_listing: true,
|
||||
@@ -65,6 +70,18 @@ pub fn resolve_db_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(DB_FILE_NAME)
|
||||
}
|
||||
|
||||
/// 字体目录的默认位置:**二进制所在目录**下的 `font/`,理由同 `data/`。
|
||||
pub fn default_font_dir() -> PathBuf {
|
||||
default_font_dir_from(std::env::current_exe().ok().as_deref())
|
||||
}
|
||||
|
||||
fn default_font_dir_from(exe: Option<&Path>) -> PathBuf {
|
||||
match exe.and_then(Path::parent) {
|
||||
Some(dir) => dir.join(FONT_DIR_NAME),
|
||||
None => PathBuf::from(FONT_DIR_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验内容库没有落在静态托管目录里。
|
||||
///
|
||||
/// `ServeDir` 会把 `--root` 下的所有文件当静态资源对外提供,
|
||||
@@ -151,6 +168,15 @@ mod tests {
|
||||
assert_eq!(default_data_dir_from(None), PathBuf::from(DATA_DIR_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_dir_follows_the_binary_directory() {
|
||||
assert_eq!(
|
||||
default_font_dir_from(Some(Path::new(r"D:\apps\stash\stash.exe"))),
|
||||
PathBuf::from(r"D:\apps\stash\font")
|
||||
);
|
||||
assert_eq!(default_font_dir_from(None), PathBuf::from(FONT_DIR_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_path_is_fixed_inside_the_data_dir() {
|
||||
assert_eq!(
|
||||
|
||||
+213
-59
@@ -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]
|
||||
|
||||
@@ -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
@@ -3,7 +3,7 @@ use clap::Parser;
|
||||
use colored::*;
|
||||
use stash::{
|
||||
Config,
|
||||
config::{default_data_dir, display_path, resolve_db_path, validate_db_location},
|
||||
config::{default_data_dir, default_font_dir, display_path, resolve_db_path, validate_db_location},
|
||||
start,
|
||||
};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
@@ -94,10 +94,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
let data_dir = data_dir.canonicalize().unwrap_or(data_dir);
|
||||
let db_path = resolve_db_path(&data_dir);
|
||||
|
||||
// 字体目录:跟 data/ 一样放在二进制旁边,后台只从这里列字体
|
||||
let font_dir = default_font_dir();
|
||||
std::fs::create_dir_all(&font_dir)?;
|
||||
let font_dir = font_dir.canonicalize().unwrap_or(font_dir);
|
||||
let font_dir_display = display_path(&font_dir);
|
||||
|
||||
// 4. 构造配置并启动
|
||||
let config = Config {
|
||||
root: abs_root.clone(),
|
||||
data_dir: data_dir.clone(),
|
||||
font_dir,
|
||||
db_path: db_path.clone(),
|
||||
port: cli.port,
|
||||
host: cli.host,
|
||||
@@ -121,6 +128,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline());
|
||||
println!(" ➜ 托管路径: {}", display_path(&abs_root).white());
|
||||
println!(" ➜ 内容库: {}", display_path(&db_path).white());
|
||||
println!(" ➜ 字体目录: {}", font_dir_display.white());
|
||||
println!(" ➜ 前端资源: {}", "已内嵌 (stash-web/dist)".white());
|
||||
println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed());
|
||||
println!("{}", "--------------------------------------------------".bright_blue());
|
||||
|
||||
+103
@@ -589,6 +589,80 @@ async fn patch_settings(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 字体 ----------------
|
||||
|
||||
/// 字体文件后缀:后台字体下拉只列这几种。
|
||||
const FONT_EXTENSIONS: [&str; 4] = ["woff2", "woff", "ttf", "otf"];
|
||||
|
||||
/// GET /api/fonts —— `font/` 目录里的字体文件(后台字体下拉用,公开读)
|
||||
async fn list_fonts(State(state): State<AppState>) -> Response {
|
||||
let dir = state.config.font_dir.clone();
|
||||
match tokio::task::spawn_blocking(move || scan_fonts(&dir)).await {
|
||||
Ok(Ok(fonts)) => Json(serde_json::json!({ "fonts": fonts })).into_response(),
|
||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 列目录:目录不存在按「没有字体」处理,不让后台报错。
|
||||
fn scan_fonts(dir: &std::path::Path) -> std::io::Result<Vec<serde_json::Value>> {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
let mut fonts: Vec<(String, serde_json::Value)> = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(extension) = path.extension().and_then(|value| value.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !FONT_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str()) {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
fonts.push((
|
||||
name.to_string(),
|
||||
serde_json::json!({ "name": name, "url": format!("/fonts/{name}") }),
|
||||
));
|
||||
}
|
||||
fonts.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok(fonts.into_iter().map(|(_, value)| value).collect())
|
||||
}
|
||||
|
||||
/// GET /fonts/{path} —— `font/` 目录里的字体文件。
|
||||
/// 只认一级文件名:`..`、子目录一律 404,避免越界读文件。
|
||||
async fn font_asset(State(state): State<AppState>, Path(path): Path<String>) -> Response {
|
||||
let reject = || not_found_page(&format!("/fonts/{path}"));
|
||||
if path.is_empty() || path.contains('/') || path.contains('\\') || path.contains("..") {
|
||||
return reject();
|
||||
}
|
||||
|
||||
let file = state.config.font_dir.join(&path);
|
||||
let bytes = match tokio::fs::read(&file).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return reject(),
|
||||
};
|
||||
|
||||
let mime = mime_guess::from_path(&path).first_or_octet_stream();
|
||||
let content_type = HeaderValue::from_str(mime.as_ref())
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(header::CACHE_CONTROL, HeaderValue::from_static(HTML_CACHE)),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ---------------- 条目编辑 / 备份 ----------------
|
||||
|
||||
/// GET /api/items/{key} —— 编辑表单的初始值(公开,便于未登录时预览)
|
||||
@@ -970,6 +1044,8 @@ fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/logout", post(auth_logout))
|
||||
.route("/api/auth/password", patch(auth_password))
|
||||
.route("/api/settings", get(get_settings).patch(patch_settings))
|
||||
.route("/api/fonts", get(list_fonts))
|
||||
.route("/fonts/{*path}", get(font_asset))
|
||||
.route("/api/items/{key}", get(get_item).patch(patch_item).delete(delete_item))
|
||||
.route("/api/items", post(create_item))
|
||||
.route("/api/items/{key}/view", post(post_view))
|
||||
@@ -1092,6 +1168,33 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_fonts_lists_only_font_files() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/font-scan");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("应能建目录");
|
||||
std::fs::write(dir.join("b.ttf"), b"x").expect("应能写入");
|
||||
std::fs::write(dir.join("a.WOFF2"), b"x").expect("应能写入");
|
||||
std::fs::write(dir.join("note.txt"), b"x").expect("应能写入");
|
||||
|
||||
let fonts = scan_fonts(&dir).expect("应能列目录");
|
||||
let names: Vec<_> = fonts
|
||||
.iter()
|
||||
.map(|font| font["name"].as_str().unwrap_or_default())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["a.WOFF2", "b.ttf"]);
|
||||
assert_eq!(fonts[0]["url"], "/fonts/a.WOFF2");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_fonts_treats_missing_dir_as_empty() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/no-such-font-dir");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(scan_fonts(&dir).expect("目录不存在不应报错").is_empty());
|
||||
}
|
||||
|
||||
/// 发一个 GET,返回(状态码,Content-Type,响应体)。
|
||||
async fn get(path: &str) -> (StatusCode, String, String) {
|
||||
let response = router()
|
||||
|
||||
+11
-3
@@ -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
@@ -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
@@ -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
@@ -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 |
@@ -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"
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html[data-scheme='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
|
||||
+117
-259
@@ -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 : '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user