46 Commits
Author SHA1 Message Date
CNWei f7d0630fe6 chore(settings): 去掉老库自动补内置引擎的兼容逻辑
- load() 不再把缺的内置引擎补进返回结果;老库缺内置引擎时保存会被 400 拒绝(不做自动迁移)
- 删除对应的 load_merges_new_builtin_engines 测试
- HANDOFF 同步说明
2026-11-06 13:51:28 +08:00
CNWei 733c1552ce feat(search): 内置 DuckDuckGo,内置引擎不允许删除
- 默认引擎清单加 duckduckgo(https://duckduckgo.com/?q={query}),图标用组件库 0.3.5 的 DuckDuckGo 品牌图标
- 后端:内置六个必须存在,缺了返回 400;老库读取时按 id 补齐缺的内置引擎(插在内置那一组里,不覆盖用户改过的值)
- 后台:内置引擎不渲染「删除」按钮,removeEngine 也做了兜底;自定义引擎照常可删
2026-11-06 12:40:31 +08:00
CNWei fdfd17657c feat(search): 自定义引擎支持图标地址(png / svg)
- settings.search.engines[] 新增 icon:空=品牌图标/通用地球,'/' 开头走托管目录,或 http(s) 链接
- 后端校验图标地址(空 / 托管路径 / http(s),≤500 字符)
- 后台每张引擎卡加「图标地址」输入;前后台都用组件库 EngineIcon 的 src(<img> 渲染,SVG 静态不执行脚本)
- @stash/ui 0.3.4
2026-11-06 12:30:15 +08:00
CNWei 281f04b060 feat(search): 后台可以自己添加搜索引擎
- 搜索引擎 id 放宽:小写字母/数字/-/_,1–30 位,internal 保留;最多 10 个
- 后台搜索框面板加「添加搜索引擎」(id 自动生成 custom-xxx)与每行「删除」
- 自定义引擎用组件库 EngineIcon 的通用地球图标(@stash/ui 0.3.3)
- 搜索地址仍要求 http(s):// + {query},默认项被删/停用时回落到站内搜索
2026-11-06 12:19:06 +08:00
CNWei 9f1a8863f4 refactor(layout): 窄屏堆叠交给 @stash/ui 0.3.2,删掉对应补丁
- 去掉 #page-scroll 的 flex-wrap / aside / main 高度覆盖(组件库已处理)
- 左栏吸顶只在 >1100 生效,窄屏不再需要手动取消 sticky
- 保留应用自己的侧栏排布(资料卡 + 导航并排)与页头断点
2026-11-06 09:21:22 +08:00
CNWei a0be02b4f4 refactor(nav): 手机导航抽屉改用组件库 Drawer
- 删掉手写的抽屉(遮罩 / Esc / 标题 / 关闭按钮 / 动画)与对应 CSS
- 直接用 @stash/ui 的 Drawer(原生 dialog,自带焦点陷阱、Esc、backdrop 与滚动锁)
- 保留原来自定义的分组选择逻辑
2026-11-05 20:33:03 +08:00
CNWei ff5c6f05a2 fix(header): 平板/小屏笔记本的滚动信息不再被挤没,只在 ≤640 隐藏
- ≤1100 页头改 auto/1fr/auto:品牌和操作图标按内容宽度,滚动信息吃剩余空间(之前中间列固定抢 720,品牌被压到看不见)
- 滚动信息隐藏阈值从 820 下调到 640,和侧栏一致:641–1100 正常显示(700 宽约 419px),手机才收起
2026-11-05 20:25:21 +08:00
CNWei 9948e43e6c fix(mobile): 侧栏只在 ≤640 收起,平板/分屏宽度保留资料卡与分组导航 2026-11-05 20:16:04 +08:00
CNWei 9c8e05c0ee feat(mobile): 小屏收起侧栏,页头菜单按钮打开分组导航抽屉
- ≤820 隐藏整个侧栏(资料卡 / 本地服务 / 导航都收起),主内容直接顶上来
- 页头新增只在手机显示的菜单按钮,打开左侧抽屉:分组导航 Menu,选中后自动关闭并跳转
- 抽屉带遮罩、Esc 关闭、打开时锁住背后滚动;桌面不显示按钮
2026-11-05 20:05:38 +08:00
CNWei 6e17169ac4 fix(mobile): 窄屏主内容被挤出视口,侧栏与页头适配手机/平板
- ≤1100 时滚动容器不换行、aside/main 不再锁 100% 高(之前 main 被排到视口右侧,完全看不见)
- 平板:资料卡 + 导航卡两列并排;手机:只留导航卡(资料/服务卡隐藏)
- 手机页头:隐藏跑马灯,改两列(品牌 + 操作图标),高度 56
- 删掉一直没生效的 .sidebar 网格规则(被更高优先级的 flex 规则盖住)
2026-11-05 19:50:21 +08:00
CNWei 960ea1c5eb fix(admin): 字体卡片里名称/族名两个字段顶部对齐并等宽 2026-11-05 19:07:22 +08:00
CNWei 0aebd610fe fix(admin): 没有字体时不渲染「当前使用的字体」下拉 2026-11-05 19:02:41 +08:00
CNWei 97b9d00cf3 feat(font): 字体分为内置/链接两种来源,链接可存多套并启用
- 设置改为 font.source(builtin|custom) + current + items[]
- 每条链接含 key/name/url/family;后台可加、改、删,选一条启用
- 前端按启用的那条注入 @font-face / CSS 链接,删除当前项时退回第一条
- 校验:条数上限 20、key 唯一、url 仅 http(s):// 或 /fonts/
2026-11-05 18:54:58 +08:00
CNWei 0bb4cc354a feat(font): 系统管理里可设置正文字体(font 目录或链接)
- 新增 GET /api/fonts 列出二进制同级 font/ 目录里的字体文件,/fonts/<文件名> 对外提供(只认一级文件名,拒绝穿越)
- 站点设置新增 font.url / font.family,校验只接受 http(s):// 或 /fonts/ 开头的链接
- 前台按设置注入 @font-face 或 CSS 链接并覆盖 --su-font-sans;留空回到内置文楷
- 后台「系统管理」新增字体选择 + 链接 + 族名;撤销/保存按钮覆盖该页
- 撤掉上一轮的霞鹜新晰黑改动
2026-11-05 18:12:29 +08:00
CNWei e243a20b69 style(typography): 正文字体换成霞鹜新晰黑
- index.html 换用 cn-fontsource 的霞鹜新晰黑 webfont(unicode-range 分片)
- index.css 覆盖 --su-font-sans,组件库默认保持文楷
- ALRIGHT-DESIGN 字体表同步
2026-11-05 16:58:57 +08:00
CNWei 21825431c7 fix(sidebar): 分组导航卡补上与其他卡片一致的底色 2026-11-05 16:43:10 +08:00
CNWei 65c7fe79dd style(group): 分组页恢复卡片外壳,撤掉文字描边方案 2026-11-05 16:37:29 +08:00
CNWei 93f80af893 revert(cards): 撤掉卡片外观配置与分组页卡片外壳,改用文字对比度
- 回退 card.opacity / card.blur 设置与后台「卡片外观」面板
- 分组页内容恢复为直接压在背景图上,不再套卡片
- 分组页文字:次级文字提一档 + 同主题描边(深色暗描边、浅色亮描边)
- 保留上一轮:背景缓存、搜索记忆、明暗图标、卡片描边/阴影、侧栏实底
2026-11-05 14:18:18 +08:00
CNWei 4ce1b7e7c1 feat(cards): 卡片外观可配置(不透明度 / 毛玻璃)
- 站点设置新增 card.opacity(40–100)与 card.blur,后台「首页管理 → 卡片外观」可调
- 前台所有卡片(首页 / 分组页 / 详情 / 侧栏)共用一套 CSS 变量
- 顺带带上上一轮:背景缓存、搜索记忆、明暗图标跟随实际状态、卡片描边/阴影、分组页卡片外壳
2026-11-05 12:45:11 +08:00
CNWei a9a913942a docs: 更新交接文档到当前搜索/背景/主题状态
- 记录 develop 领先远端 19 个提交、stash-ui 三个未推送提交
- 更新 v23 库结构、settings(search/background)、后台 IA 与 API 清单
- 补充构建顺序、双份 git_helper、exe 占用等坑
2026-11-04 19:30:42 +08:00
CNWei 1d1ece6a2a fix(group): 分组面包屑保留详情页外框
用 Card.detail-nav 包裹 Breadcrumb,和条目/图文详情页一致
2026-11-04 19:27:42 +08:00
CNWei 9d1e5dcb60 fix(group): 分组页面包屑与详情页统一
分组页改用 @stash/ui 的 Breadcrumb,去掉带边框的按钮样式和独立分隔符
2026-11-04 19:25:20 +08:00
CNWei 4862f8fb6b fix(search): 外部引擎模式不再过滤本地内容
只有站内搜索模式才把输入用于本地过滤;Google 等模式保持页面内容不变,仅回车调用对应引擎
2026-11-04 19:18:43 +08:00
CNWei aa6f341505 feat(search): 按参考稿重做搜索框并支持后台配置
- 顶部悬浮引擎标签 + 下拉选项,使用 stash-ui 新增的 EngineIcon
- 站内搜索保持现有本地过滤与回车打开首个匹配项
- 外部引擎使用配置的 {query} 模板新标签页搜索
- settings 新增 search.default / search.engines,后台新增『搜索框』配置默认项、名称、地址与启停
- 默认默认搜索项为站内搜索,地址校验必须含 {query}
2026-11-04 18:11:22 +08:00
CNWei 5ae2088826 style(search): 引擎图标恢复 18px 2026-11-04 12:56:07 +08:00
CNWei 200908cef6 style(search): 引擎图标统一使用组件库图标
- 移除内联品牌 SVG,改用 @stash/ui 的 Icon 名称
- 快捷栏每个选项都显示图标,左侧触发器同步切换
2026-11-04 12:53:12 +08:00
CNWei 56f362f921 feat(search): 点击放大镜切换搜索引擎
- 搜索框左侧图标改为触发器,点击展开效果五快捷栏
- 选中后左侧图标切换为对应引擎图标(本地放大镜 / Google / Bing / 百度 / 搜狗 / 360)
- 搜索框占位文案随当前模式变化,原有过滤与回车行为保留
2026-11-04 12:50:02 +08:00
CNWei 8c1c658b15 feat(search): 效果五快捷栏加入本地搜索
- 引擎选项新增「本地」并作为默认;本地模式回车打开第一个匹配条目/分组
- 快捷栏改为贴在搜索框右侧内部展开,贴合效果五
- 外部引擎仍在新标签页搜索,输入时本地过滤逻辑保持
2026-11-04 12:39:31 +08:00
CNWei 038b7ecb42 feat(search): 搜索框支持切换搜索引擎
- 采用效果五:点击右侧引擎名展开横向快捷切换栏
- 支持 Google / Bing / 百度 / 搜狗 / 360,选择结果存 localStorage
- 输入时仍按原逻辑过滤本地项目;回车用当前引擎打开新标签搜索
- Esc 关闭切换栏,点击外部自动收起
2026-11-03 20:33:41 +08:00
CNWei a5d7859c6e fix(theme): 用整页快照过渡消除明暗割裂
- 支持 View Transition 时整页旧/新快照交叉淡入 160ms,背景图与底色不再各自过渡
- 不支持或减少动画偏好时回退到原来的颜色过渡
2026-11-03 20:30:05 +08:00
CNWei 5439ebf3be feat(admin): 增加背景管理
- settings 新增 background.light / background.dark,留空使用内置图
- 后台首页管理新增「背景管理」,两栏 URL 输入与实时预览,可一键恢复内置图
- 前台按设置覆盖明暗背景图,仍保留 65vh 与交叉淡入
- 保存/撤销沿用现有设置乐观锁流程
2026-11-03 20:23:57 +08:00
CNWei b9005dd514 style(theme): 暗色使用 Alright 同款深色背景
- 新增 site-bg-dark.jpg(Alright 演示站深色背景)
- 明暗背景拆成两层交叉淡入,切换时不再露出单层底色
- 保留 65vh 高度与底部渐隐
2026-11-03 20:18:29 +08:00
CNWei 1f8147dd73 fix(theme): 背景图不再跟着半程淡出
切深色时立即隐藏背景图,切浅色时等底色过渡完再淡入,避免 65vh 边界出现半黑半白
2026-11-03 20:14:03 +08:00
CNWei 5736bbc101 style(theme): 缩短明暗过渡到 160ms 2026-11-03 20:12:14 +08:00
CNWei 3330af818e fix(theme): 消除首帧主题闪动并柔化明暗过渡
- index.html 首帧按本机偏好设置 dark/light,避免先浅后深
- 实际 scheme 只写 dark/light(之前 auto 会覆盖成浅色)
- 切换时给所有表面加 240ms 颜色/边框/阴影过渡,结束后移除
- 背景图深色下淡出而不是 display:none
- 页头操作图标与分组导航等设置加载后再渲染,避免先显示后消失
2026-11-03 20:09:24 +08:00
CNWei dacb30e548 chore(web): 首屏兜底标题改为 Stash 2026-11-03 20:02:22 +08:00
CNWei 14938a9c83 fix(sidebar): 设置加载前不渲染可隐藏卡片
侧栏三个卡片统一等 /api/settings 返回后再渲染,避免禁用本地服务后刷新仍闪一下
2026-11-03 20:02:15 +08:00
CNWei 6dabeabaf2 refactor: 清理遗留设置模型与无用代码
- 删除 settings 的 sections 与 sidebar 六个旧排序/显隐字段,前后端类型与校验同步
- 删除旧分类模型 types、旧 /api/catalog 客户端、旧路由解析与 settings CRUD
- 删除未使用的 AppProject::initial
- 删除 27 条已无组件的 CSS 规则(159 行)
- 保持现有首页/接口页渲染,cargo test 52 passed
2026-11-03 19:49:04 +08:00
CNWei ffa123d2cd docs(api): 同步接口一览到清理后的路由
- 移除已删除的 /api/catalog 与 /api/categories、旧 /api/groups/{id}
- 内容库描述改为分组树 / 条目 / 卡片,节点接口改为根分组 / 子分组
- 「需登录」说明不再等同于写操作
2026-11-03 19:34:16 +08:00
CNWei 27e411fd2c refactor(db): 删除旧分区展示块与兼容字段
- v23 迁移删除 section_block 表,以及 node 的 view_mode / empty_text / show_in_sidebar / allow_groups
- 重建 node 表并保留 44 条内容、15 个分组、5 张卡片,外键检查通过
- 种子库直接生成 card / card_group,不再依赖 section_block
- 删除旧 /api/catalog、/api/categories、/api/groups/{id} 与 categories 模块
- 分组接口不再接收/返回 format,前端 GroupNode 同步移除
- README/HANDOFF 更新到 v23;管理页提示改为『格式由每条内容决定』
2026-11-02 20:24:08 +08:00
CNWei 4621f8cb22 chore: 清理无引用的前端与旧模板资源
- 删除 13 个旧 App 时代的前端组件/数据层文件(无 import 可达)
- 删除 templates/ 6 个旧 Askama 原型页与 assets/dashboard.{css,js}
- README/设计文档同步当前路由与图标实现,移除失效引用
- stash-web 构建通过,cargo test 59 passed
2026-11-02 20:10:48 +08:00
CNWei 8889515373 style(admin): 后台页头与前台左右对齐
- 页头网格补一个空的中间列,品牌与操作图标分别贴左右边缘
- 后台不渲染滚动信息,仅保留前台同样的 60 高、限宽与对齐
2026-11-02 19:56:54 +08:00
CNWei bbfbc77b30 Revert "style(admin): 统一标题层级与页面排版"
This reverts commit b1f7589538.
2026-11-02 19:54:36 +08:00
CNWei b1f7589538 style(admin): 统一标题层级与页面排版
- 每个后台页面新增统一的加粗标题 + 说明 + 右侧操作区
- 分组/数据页操作按钮移到标题右侧,取消正文里的『说明:』散排
- 页头/资料/系统改为 820px 窄栏,标签加粗,保存按钮与表单对齐
- 系统管理拆成『修改密码 / 数据备份』两段,按钮不再整行拉伸
- 外层标题改为『后台管理』,侧栏与卡片页去掉重复说明,卡片列表标题加粗
2026-11-02 19:50:51 +08:00
CNWei f25fc8b4ba chore: 同步 stash-ui 本地包版本 2026-11-02 19:39:14 +08:00
CNWei dec82080d3 style: 页头滚动信息居中,接口按钮统一为 Button
- 页头改为三列 grid,Ticker 放入中间列,左右等宽,滚动信息在视口居中
- 查看接口从自定义 a.link-button 改为与重新扫描相同的 @stash/ui Button
- 保留站内路由与修饰键新标签打开行为
2026-11-02 19:39:08 +08:00
49 changed files with 2279 additions and 8453 deletions
+11 -43
View File
@@ -88,7 +88,7 @@
| 角色 | 字体 | 说明 | | 角色 | 字体 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| 正文 | **LXGW WenKai(霞鹜文楷)** | 与演示站同一份 webfont,`cdn.staticfile.net` | | 正文 | **LXGW WenKai(霞鹜文楷)** | 内置默认,`cdn.staticfile.net`;后台「系统管理 → 字体」可换成 `font/` 目录里的字体或外部字体链接 |
| 回退 | `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif` | 字体加载失败时 | | 回退 | `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif` | 字体加载失败时 |
| 数字(主题) | `Outfit-Medium` | 仅主题的 `font-price` / `font-number` 使用,Stash 未引入 | | 数字(主题) | `Outfit-Medium` | 仅主题的 `font-price` / `font-number` 使用,Stash 未引入 |
| 等宽 | `ui-monospace, Consolas, monospace` | 路由地址等路径文本 | | 等宽 | `ui-monospace, Consolas, monospace` | 路由地址等路径文本 |
@@ -100,48 +100,16 @@ CSS 变量 `--font-sans` / `--font-mono` 定义在 `index.css`。
## 3. 图标 ## 3. 图标
### 3.1 图标字体 图标统一来自组件库 `@stash/ui`:
主题使用自建 iconfont(线性风格,Project id 4809463)。 ```tsx
Stash 自托管了一份 **只含所需字形** 的子集: import { Icon } from '@stash/ui'
- 字体文件:[`stash-web/src/assets/iconfont.woff2`](stash-web/src/assets/iconfont.woff2) <Icon name="search_3_line" />
- 样式:[`stash-web/src/iconfont.css`](stash-web/src/iconfont.css)
- 组件封装:[`stash-web/src/components/common/Icon.tsx`](stash-web/src/components/common/Icon.tsx)
| 用途 | 主题字形 | 码点 | Stash 类名 |
| --- | --- | --- | --- |
| 搜索 | `search_3_line` | `\e942` | `.icon-search` |
| 用户 / 登录 | `user_3_line` | `\e966` | `.icon-user` |
| 重新扫描 | `refresh_3_line` | `\e93d` | `.icon-refresh` |
| 查看接口 | `link_line` | `\ea37` | `.icon-link` |
| 跳转 | `arrow_right_up_line` | `\e8be` | `.icon-external` |
| 浏览量 | `eye_line` | `\e8fd` | `.icon-eye` |
| 点赞 | `thumb_up_2_line` | `\e957` | `.icon-thumb-up` |
| 分享 | `share_3_line` | `\e946` | `.icon-share` |
| 清空搜索 | `close_line` | `\e8e4` | `.icon-close` |
| 分区更多 | `more_3_line` | `\e91f` | `.icon-more` |
| 回到顶部 | `arrow_to_up_line` | `\e8c1` | `.icon-arrow-up` |
| 返回 | `arrow_left_line` | `\e8b5` | `.icon-arrow-left` |
| 导航展开 | `arrow_down_line` | `\e8b7` | `.icon-arrow-down`(未使用,见下) |
用法:`<i className="iconfont icon-search" />`,尺寸用 `font-size`、颜色用 `color` 控制。
### 3.2 CSS 绘制的箭头
主题的**导航折叠箭头不是图标字体**,而是一个 6×6 的描边方块旋转而成:
```css
.nav-link.has-children::after {
content: '';
width: 6px; height: 6px;
border-left: 1px solid currentColor;
border-bottom: 1px solid currentColor;
transform: rotate(315deg); /* 展开时 135deg */
opacity: 0.4;
}
``` ```
可用名称见 `@stash/ui` 导出的 `ICON_NAMES`,尺寸与颜色分别由 `size` / CSS `color` 控制。`stash-web` 不再维护本地 iconfont 或 `Icon.tsx`。
--- ---
## 4. 布局 ## 4. 布局
@@ -311,10 +279,10 @@ background: linear-gradient(to top, rgba(0,0,0,.85) 0%, rgba(0,0,0,.4) 60%, tran
| 需求 | 改动位置 | | 需求 | 改动位置 |
| --- | --- | | --- | --- |
| 改主色 / 灰阶 / 圆角 | [`stash-web/src/index.css`](stash-web/src/index.css) 的 `:root` | | 改主色 / 灰阶 / 圆角 | [`stash-web/src/index.css`](stash-web/src/index.css) 的 `:root` |
| 新增图标 | 把 codepoint 加进 [`stash-web/src/iconfont.css`](stash-web/src/iconfont.css),在 `Icon.tsx` 包一个组件 | | 新增图标 | 在 `@stash/ui` 的 iconfont 中补图标,再通过 `<Icon name>` 使用 |
| 新增分类 | 内容库 `category` 表(首次启动前可改 [`src/db/seed.json`](src/db/seed.json)),首页与明细页自动适配 | | 新增分组 | 后台「分组管理」新建根分组 / 子分组,内容挂在分组上 |
| 调整侧栏导航 | 内容库 `nav_entry` 表,标签 / 层级 / 跳转目标都可在库里改 | | 调整侧栏导航 | 后台「侧边栏」勾选要显示的卡片与分组 |
| 新增卡片类型 | 在 `data/types.ts` 增加 kind,`HomeView` / `DetailView` 各加一个分支 | | 新增卡片类型 | 在 `AppNext.tsx` 的 `renderEntries` / `renderCard` 增加分支 |
| 调整分区样式 | [`stash-web/src/App.css`](stash-web/src/App.css) 的「分区卡片」段落 | | 调整分区样式 | [`stash-web/src/App.css`](stash-web/src/App.css) 的「分区卡片」段落 |
| 换背景图 | 替换 `stash-web/src/assets/site-bg.jpg` | | 换背景图 | 替换 `stash-web/src/assets/site-bg.jpg` |
+209 -190
View File
@@ -1,243 +1,262 @@
# Stash 交接文档 # 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 之后又做了这些改动, ## 1. 一句话现状
> 下面正文里提到的文件名与页面结构可能已经不同,以 `README.md` 为准:
>
> - 前端 `App.tsx` → `AppNext.tsx`,后台 `admin/AdminPage.tsx` → `admin/AdminNext.tsx`(旧文件已删除);
> - 后台从横向 Tabs 改成**左侧导航 + 右侧内容**,页头归到「首页管理」下,
> Tab 名改为 分组管理 / 数据管理 / 首页管理(页头·侧边栏·内容卡片)/ 资料管理 / 系统管理;
> - 新增 `/interface` 接口一览页;侧栏「查看接口」走站内路由,不再直接开 `/api/projects`;
> - 接入 `@stash/ui` 组件库:前后台所有卡片、表格、开关、文字按钮都来自库里,
> 明暗主题(浅色 / 深色 / 跟随系统)由令牌接管;
> - 内容库表结构升到 v22 并**有了自动迁移**,卡片模型是 `card` + `card_group`(`section_block` 仅作旧库迁移来源)。
## 0. 一句话现状 Stash 是「本地项目托管 + 可配置导航首页」:Rust 单 crate 内嵌 React 前端,单管理员登录,内容库为 SQLite,分组 / 条目 / 首页卡片 / 站点设置都在 `/admin` 管理。
Stash 已从「只读本地导航站」升级为**可登录、可配置、可编辑内容的本地发布台**: 当前开发分支 `develop`,本地服务:
- 登录(单用户,argon2id + Cookie 会话); - 前台 + 后台:<http://127.0.0.1:8080>
- `/admin` 设置页(页头滚动信息 / 首页卡片 / 归类 / 侧栏 / 资料 / 系统); - 管理后台:<http://127.0.0.1:8080/admin>
- 条目就地编辑(Drawer + 乐观锁); - 接口一览:<http://127.0.0.1:8080/interface>
- 分区新增/删除、**条目新增/删除(可选分区与分组)**、分组(子分类)新建/改名/删除、侧栏子分类配置、浏览/点赞/分享/跳转; - Vite 开发服务器 `5173` 当前**未运行**;需要时 `cd stash-web && npm run dev`
- 数据库自动迁移到 **v18**;
- 前端 100% 使用组件库 `@stash/ui`(v0.3.1,42 个组件)。
当前数据库**已设置管理员密码**(2026-10-30 由使用者设置);忘记密码按 §6 删除 `admin` 行即可回到首次设置。 ## 2. 仓库与分支
## 1. 仓库与运行环境 | 仓库 | 路径 | 分支 | 状态 |
| --- | --- | --- | --- |
| 主项目 | `D:\CNWei\CNW\Rust\stash` | `develop` | 工作区干净;功能最新 `1d1ece6`,本文件随后单独提交 |
| 组件库 | `D:\CNWei\CNW\Rust\stash-ui` | `master` | 工作区只有忽略的 `.idea/`;`994436a` 最新 |
| 项目 | 路径 | 分支 | 端口 | 说明 | - 主项目 remote 名为 `master`(不是 `origin`),地址 `https://gitea.ngmao.com/CNWei/stash.git`。
| --- | --- | --- | --- | --- | - `develop` 比 `master/develop` **领先 20 个提交,尚未推送**;此前 HTTPS 凭据不可用,需要时先准备 Gitea 凭据。
| 新版(组件重构) | `D:\CNWei\CNW\Rust\stash` | `feat/use-stash-ui` | **8080** | 所有新功能都在这里 | - `stash-ui` 也有 3 个未推送提交:`b97c77e`(SearchField suffix)、`46829cd`(Input leading)、`994436a`(EngineIcon)。
| 旧版(1:1 基线) | `D:\CNWei\CNW\Rust\stash-old`(worktree) | `develop` | **8081** | 只用于视觉对照,不要改 | - `@stash/ui` 以 `file:../../stash-ui` 引入,`stash-web` 使用其 `dist/`;改组件库后必须重新 `npm run build`。
| 组件库 | `D:\CNWei\CNW\Rust\stash-ui` | `master` | — | tag `v0.1.0` / `v0.2.0` / `v0.3.0` / `v0.3.1` |
后端:Rust 2024 + Axum 0.8 + rusqlite(bundled)。前端:React 19 + Vite + TS + CSS Modules,`@stash/ui` 通过 `file:` 依赖引入。 ## 3. 启动 / 构建 / 测试
### 启动 / 构建(顺序重要) 前端产物会被 `build.rs` 内嵌,顺序不能反:
```powershell ```powershell
# 1) 前端(产物会被 build.rs 内嵌,必须先构建)
cd D:\CNWei\CNW\Rust\stash\stash-web cd D:\CNWei\CNW\Rust\stash\stash-web
npm run build npm run build # tsc -b + vite,产物 dist/
# 2) 先杀掉正在运行的 stash.exe,否则 cargo 会报「拒绝访问」 cd D:\CNWei\CNW\Rust\stash
Get-Process stash -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*stash\target*' } | Stop-Process -Force Get-Process stash -ErrorAction SilentlyContinue |
Where-Object { $_.Path -like '*stash\target*' } | Stop-Process -Force # 否则 exe 被占用
# 3) 后端
cd ..
cargo build 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,仅在需要对照时): 测试(Windows 下覆盖 TEMP 权限):
```powershell
D:\CNWei\CNW\Rust\stash-old\target\debug\stash.exe --port 8081
```
### 测试
Windows 下 `TEMP` 权限有问题,跑测试要覆盖临时目录:
```powershell ```powershell
$env:TEMP = "D:\CNWei\CNW\Rust\stash\target\tmp"; $env:TMP = $env:TEMP $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/ src/
main.rs / lib.rs 启动、日志 main.rs / lib.rs 启动、参数、日志
config.rs 端口 / 数据目录(默认 target/debug/data) config.rs 端口 / 数据目录 / 托管目录
server.rs 全部路由(API + 内嵌前端 + SPA 回落) server.rs 全部 API + 内嵌前端 + SPA 回落
error.rs StashError(含 Conflict → 409) listing.rs 托管目录扫描
watcher.rs 文件监听 + SSE 防抖
db/ db/
mod.rs Db 门面(所有方法)+ 迁移测试 mod.rs Db 门面 + 迁移/种子测试
schema.rs 建表、种子、版本迁移(0/15→16→17) schema.rs 建表、种子、v15→…→v23 迁移
auth.rs 管理员、密码哈希、会话(token 只存 sha256) auth.rs admin / session / argon2id
settings.rs settings JSON(header/sidebar/sections/profile) settings.rs settings JSON + 校验
entries.rs 条目读取/编辑、浏览数、点赞、审计 entries.rs 条目 CRUD、浏览、点赞、审计
categories.rs 分区新增/删除 groups.rs 分组树 CRUD / 移动 / 排序
catalog.rs 查询组装(前端三种视图) cards.rs 卡片与勾选分组
catalog.rs 分组树 + 条目查询组装
stash-web/src/ stash-web/src/
App.tsx 主页壳、登录弹窗、编辑器、设置应用 AppNext.tsx 前台壳:路由、卡片、侧栏、搜索框、主题
main.tsx 入口分流(/admin 与 /login) main.tsx /admin 与普通前台分流
auth.ts / settings.ts / items.ts 前端 API 客户端 settings.ts SiteSettings 类型 / 默认值 / API 客户端
manage.ts 分组、卡片、catalog2 客户端
items.ts 条目编辑客户端
api.ts 项目扫描 / SSE 客户端
components/ components/
header/SiteHeader.tsx 品牌 + 滚动信息 + 操作图标 admin/AdminNext.tsx 后台全部面板
admin/AdminPage.tsx /admin 全部 Tab header/SiteHeader.tsx 品牌 / 滚动信息 / 操作图标
auth/LoginDialog.tsx Modal 登录 content/ 卡片、网格、详情、编辑器
auth/PasswordInput.tsx 带显隐切换的密码框 sidebar/ 资料卡、本地服务、导航
content/ 首页、分区、条目、文章、编辑器 stash-ui/src/components/ SearchField / Input / EngineIcon 等
sidebar/ 资料卡、服务面板、导航
docs/ADMIN-PLAN.md 后台方案 + P0–P4 实施记录 + 未做项
README.md 运行、路由、库结构、登录说明
``` ```
## 3. 功能清单(已完成) ## 5. 数据库(v23)
### 登录 路径:`target/debug/data/stash.db`(`--data` 可覆盖)。
- 单用户 `admin`,密码 argon2id;
- 会话:随机 32 字节 token,HttpOnly + SameSite=Strict + Path=/,7 天,库里存 sha256;
- 登录失败 5 次锁 60 秒;写接口校验 Origin;写接口需要会话。
### `/admin`(新原子模型) - `PRAGMA user_version = 23`;支持 0 建全量、15→16→…→23 迁移,高于程序版本拒绝启动。
| Tab | 能力 | - 当前表:`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` 可下载当前快照。
| 资料 | 头像 URL、名称、角色、简介、位置 | - **不要直接 `cargo clean`/删除整个 `target`**:运行库和备份在 `target/debug/data/`。
| 系统 | 修改密码、下载备份 |
保存:分组/内容/卡片逐项保存;侧边栏、页头、资料走 `PATCH /api/settings`(`version` 乐观锁)。 ## 6. settings JSON
### 内容编辑 `setting` 表 `key='site'`,API `GET/PATCH /api/settings`,`version` 乐观锁。当前字段:
- 详情页 `[编辑]` 只在登录后显示;
- 打开右侧 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
```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}", "icon": "", "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 },
{ "id": "duckduckgo", "name": "DuckDuckGo", "url": "https://duckduckgo.com/?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 只能用小写字母 / 数字 / `-` / `_`(1–30 位,`internal` 保留);内置六个(含 DuckDuckGo)用品牌图标,
且**不能删除**(只能改名 / 改地址 / 停用)。不做老库兼容:库里的设置缺内置引擎时,保存会 400;
后台新加的引擎 id 自动生成为 `custom-xxx`、图标用通用地球(组件库 EngineIcon 自动回落)。
- 每个引擎可带 `icon`(png / svg 地址,`/` 开头的托管路径或 http(s) 链接):给了就用 `<img>` 渲染自己
的图标,留空回落到品牌图标 / 地球;SVG 走 img 是静态的,不执行脚本。
- `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/health
GET /api/projects GET /api/projects/events (SSE) GET /api/projects GET /api/projects/events (SSE)
GET /api/catalog GET /api/catalog/events (SSE) 旧结构(兼容) GET /api/catalog2 分组树 + 卡片
GET /api/catalog2 分组树 + 卡片(新前台/后台) GET /api/catalog/events SSE:catalog 变化
GET /api/cards 卡片列表 GET /api/settings
POST /api/nodes {key,title,parentKey?,format?} 需登录 PATCH /api/settings 需登录,{version,settings}
PATCH /api/nodes/{key} {title,parentKey?,sort?,format?} 需登录 GET /api/fonts font/ 目录里的字体清单(公开读)
DELETE /api/nodes/{key} 需登录,级联 POST /api/nodes 需登录
POST /api/cards {title} 需登录 PATCH /api/nodes/{key} 需登录
PATCH /api/cards/{id} {title?,sort?} 需登录 DELETE /api/nodes/{key} 需登录
POST /api/cards 需登录
PATCH /api/cards/{id} 需登录
DELETE /api/cards/{id} 需登录 DELETE /api/cards/{id} 需登录
PUT /api/cards/{id}/groups [{groupKey,previewLimit}] 需登录 PUT /api/cards/{id}/groups 需登录
GET /api/items/{key}
GET /api/auth/session -> {authenticated, has_admin, username} POST /api/items 需登录
POST /api/auth/setup {password} 仅当无管理员 PATCH /api/items/{key} 需登录
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 可移动条目
DELETE /api/items/{key} 需登录 DELETE /api/items/{key} 需登录
POST /api/items/{key}/view POST /api/items/{key}/view
POST /api/items/{key}/like {liked} POST /api/items/{key}/like
GET /api/backup 需登录
POST /api/categories {id,title,kind} 需登录 GET /api/auth/session
PATCH /api/categories/{id} {title,icon,barColor,kind,previewLimit,emptyText,blockNodeKey,allowGroups} 需登录 POST /api/auth/setup / login / logout
POST /api/categories/{id}/groups {id,title} 需登录,仅应用分区 PATCH /api/auth/password
DELETE /api/categories/{id} 需登录,级联,local 不可删
PATCH /api/groups/{id} {title,showInSidebar} 需登录
DELETE /api/groups/{id} 需登录,级联组内条目
GET /api/backup SQLite 快照附件 需登录
``` ```
## 5. 数据库与迁移 已删除:`/api/catalog`、`/api/categories/*`、旧 `/api/groups/{id}`。
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。 ## 9. 组件库改动(stash-ui)
- 当前 `PRAGMA user_version = 22`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21→22、更高版本拒绝启动。
- 格式在 `entry.format`(apps / posters / articles),同组可混合;`node` 的旧 `view_mode` 仅保留兼容,不再决定渲染。
- `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`、`section_block`、`admin`、`session`、`setting`、`audit`。
- `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。
- **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。
## 6. 认证运维 - `SearchField` 新增可选 `suffix` 与 `leading` 插槽;
- `Input` 新增 `leading` ReactNode,优先于 `icon`;
- 新增 `EngineIcon`(`internal/google/bing/baidu/sogou/360`),图标路径来自参考稿;`18px` 默认、`14px` 用于顶部小标签;
- 构建产物已更新,`stash-web` 通过 file 依赖读取;改库后必须重建 dist,否则主项目构建仍用旧类型/资源。
- 忘记密码:删除 `admin` 行(或整库)后重启 → 回到首次设置。 ## 10. 已知限制 / 坑
```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 位。
## 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` | | 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 |
| 回收站/软删除 | 未做,删除分组会级联删子分组与条目(有二次确认) | | 搜索引擎 | 内置六个(含 DuckDuckGo,有品牌图标,不能删只能停用)+ 后台添加的自定义引擎(最多 10 个,通用地球图标) |
| 图片上传 | 只支持 URL;上传需存储目录、大小/类型校验 | | 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 |
| 拖拽排序 | 分组/卡片顺序暂用创建顺序;上移下移/拖拽未做 | | 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 |
| 多用户/角色 | 未做,当前单管理员 | | 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 |
| 分组移动 | 编辑弹窗里可改「上级分组」(防环);根分组不能移到子级 | | 备份恢复 | 只有 `/api/backup` 下载;恢复需停机替换库文件 |
| 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) | | 旧接口 | 已删;不要再按旧文档调用 `/api/catalog` 或 categories |
| 分组排序 | 可编辑「排序权重」数值;拖拽排序未做 | | 双份示例 | `public/git_helper/` 是 Rust 托管示例;`stash-web/public/git_helper/` 是 Vite dev 示例,**两份都保留** |
| 旧接口 | `/api/catalog`、`/api/categories/*`、`/api/groups/*` 仍保留给旧组件/兼容,后续可删 | | exe 占用 | 重启/构建前先杀 stash.exe,否则 `cargo build` 拒绝访问 |
| 登录限流 | 进程内计数,重启清空 | | 编码 | 源码 UTF-8;PowerShell 控制台可能显示乱码,以 read 工具为准,别用 Set-Content 改中文源码 |
## 10. 约定与坑 ## 11. 最近提交(主项目,倒序)
- 回复用中文,表格化优先;视觉工作要截图验证。 ```text
- **任何共享组件改动**都要在 `docs/COMPONENTS.md` 补契约 + changelog,并说明兼容性。 1d1ece6 fix(group): 分组面包屑保留详情页外框
- PowerShell 写文件注意 UTF-8;优先用编辑工具,别用 `Set-Content` 写带中文的源码。 9d1e5dc fix(group): 分组页面包屑与详情页统一
- 改前端后必须 `npm run build` 再 `cargo build`,否则内嵌资源是旧的。 4862f8f fix(search): 外部引擎模式不再过滤本地内容
- 重启前先杀 `stash.exe`,否则 cargo 报「拒绝访问」。 aa6f341 feat(search): 按参考稿重做搜索框并支持后台配置
- `/login` 是旧地址,`main.tsx` 会重定向到 `/`;登录已改为弹窗。 5ae2088 style(search): 引擎图标恢复 18px
- 前端路由:`/`、`/g/<分组 key>`、`/g/<分组 key>/i/<条目>`;旧 `/c/...` 地址按第一个路径段当分组 key 兼容,`/admin` 独立壳。 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 冒烟,确认基线; ```text
2. 若要继续产品化:条目移动 UI、回收站、图片上传、拖拽排序; 994436a feat(icons): 增加搜索引擎图标
3. 若要发布:确认 `stash-ui` 以 `file:` 引入的方式在发布构建里如何锁定版本(建议改为 git tag 依赖或构建前 `npm pack`)。 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`,确认工作区干净。
+16 -27
View File
@@ -55,7 +55,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
- **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除; - **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除;
- **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除; - **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除;
- **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示); - **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示);
- **资料管理**与**系统管理**(改密码、下载整库备份); - **资料管理**与**系统管理**(改密码、下载整库备份;系统管理里还能设正文字体:内置文楷 / 字体链接二选一,链接模式下可存多套字体(`font/` 目录里的文件或外部链接)并随时启用其中一套);
- 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效; - 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效;
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁; - 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
- `/admin` 的「系统管理」页可以改密码、下载整库备份(`VACUUM INTO` 快照); - `/admin` 的「系统管理」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
@@ -77,6 +77,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| `/c/**`、`/g/**` | 前端路由地址(分组页 / 条目详情),没有实体文件时同样返回 `index.html` | | `/c/**`、`/g/**` | 前端路由地址(分组页 / 条目详情),没有实体文件时同样返回 `index.html` |
| `/admin`、`/login`、`/interface` | 固定页面地址,同样返回 `index.html` | | `/admin`、`/login`、`/interface` | 固定页面地址,同样返回 `index.html` |
| `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) | | `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) |
| `/fonts/**` | 程序同级 `font/` 目录里的字体文件(后台字体设置用,`no-cache`) |
| `/favicon.svg` | 内嵌前端根目录文件 | | `/favicon.svg` | 内嵌前端根目录文件 |
| 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` | | 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` |
| 仍未命中 | 内置 404 页面(显示请求路径) | | 仍未命中 | 内置 404 页面(显示请求路径) |
@@ -109,7 +110,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
(`src/server.rs` 的 `SPA_PREFIXES` / `SPA_PAGES`),托管目录里写错的地址仍然是 404 页面—— (`src/server.rs` 的 `SPA_PREFIXES` / `SPA_PAGES`),托管目录里写错的地址仍然是 404 页面——
不做「所有未命中都返回 200」的万能回落,否则分不清是路由拼错还是文件真的不存在; 不做「所有未命中都返回 200」的万能回落,否则分不清是路由拼错还是文件真的不存在;
- 地址转换规则在 [`stash-web/src/data/view.ts`](stash-web/src/data/view.ts), - 地址转换规则在 [`stash-web/src/data/view.ts`](stash-web/src/data/view.ts),
与 history 打交道的部分在 [`stash-web/src/useViewRoute.ts`](stash-web/src/useViewRoute.ts)。 实际解析与跳转在 [`stash-web/src/AppNext.tsx`](stash-web/src/AppNext.tsx) 的 `useRoute` / `navigate`。
新增一类页面时两处都要改:前端加前缀,服务端把它加进 `SPA_PREFIXES` 或 `SPA_PAGES`; 新增一类页面时两处都要改:前端加前缀,服务端把它加进 `SPA_PREFIXES` 或 `SPA_PAGES`;
- 站内跳转统一走 `navigate()`(`AppNext.tsx`),不整页刷新;按住 Ctrl/Cmd 点卡片或链接仍可新标签打开。 - 站内跳转统一走 `navigate()`(`AppNext.tsx`),不整页刷新;按住 Ctrl/Cmd 点卡片或链接仍可新标签打开。
@@ -153,12 +154,6 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
../stash-ui/ # 同级目录:@stash/ui 组件库(令牌 / 组件 / 展示站) ../stash-ui/ # 同级目录:@stash/ui 组件库(令牌 / 组件 / 展示站)
``` ```
`templates/` 与 `assets/` 是上一版 Askama 控制台留下的文件,当前已无任何代码引用,
可以直接删除。
`templates/` 与 `assets/` 是上一版 Askama 控制台留下的文件,当前已无任何代码引用,
可以直接删除。
## 前端怎么被内嵌 ## 前端怎么被内嵌
1. `npm run build` 产出 `stash-web/dist`; 1. `npm run build` 产出 `stash-web/dist`;
@@ -207,19 +202,15 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| `parent_key` | TEXT? | 父节点编码:分组指向所属分区,分区为 NULL | | `parent_key` | TEXT? | 父节点编码:分组指向所属分区,分区为 NULL |
| `title` | TEXT | 展示名:分区标题 / 分组小标题 | | `title` | TEXT | 展示名:分区标题 / 分组小标题 |
| `kind` | TEXT | `section` 顶层分区 / `group` 分区下的分组 | | `kind` | TEXT | `section` 顶层分区 / `group` 分区下的分组 |
| `view_mode` | TEXT? | 仅分区:`apps` / `posters` / `articles`,决定渲染形态 | | `bar_color` | TEXT? | 标题左侧色条颜色(如 `#3858f6`) |
| `bar_color` | TEXT? | 仅分区:标题左侧 4×16 色条颜色(如 `#3858f6`) | | `icon` | TEXT? | 图标:可以是字符(`🎬`),也可以是 iconfont 图标名(`film_line`) |
| `icon` | TEXT? | 分组图标:可以是字符(`🎬`),也可以是 iconfont 图标名(`film_line`) |
| `preview_limit` | INTEGER? | 分组在卡片里的默认预览条数;为空时按类型取默认 | | `preview_limit` | INTEGER? | 分组在卡片里的默认预览条数;为空时按类型取默认 |
| `empty_text` | TEXT? | 分组为空时的提示文案 | | `enabled` | INTEGER | 是否启用,`0` / `1`;停用后侧栏与卡片都不再出现该子树 |
| `show_in_sidebar` | INTEGER | 是否出现在侧栏,`0` / `1`;0 表示只当首页卡片/内容,不进导航 |
| `allow_groups` | INTEGER | 是否允许新建子分组,`0` / `1`;与类型无关,后台开关 |
| `enabled` | INTEGER | 是否启用,`0` / `1`;**唯一可见性来源**,停用后侧栏与卡片都不再出现该子树 |
| `sort` | INTEGER | 同级顺序,越小越靠前 | | `sort` | INTEGER | 同级顺序,越小越靠前 |
两条约束写在表上: 两条约束写在表上:
- `CHECK`:`section` 必须 `parent_key IS NULL` 且有 `view_mode`;`group` 必须有父节点且无 `view_mode`; - `CHECK`:`section` 必须 `parent_key IS NULL`;`group` 必须有父节点;
- `key` 唯一(UNIQUE),全局不重复。 - `key` 唯一(UNIQUE),全局不重复。
#### entry —— 内容条目(网站 / 电影 / 文章共用) #### entry —— 内容条目(网站 / 电影 / 文章共用)
@@ -229,6 +220,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| `id` | INTEGER | 主键,库内唯一标识(自动分配) | | `id` | INTEGER | 主键,库内唯一标识(自动分配) |
| `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 | | `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 |
| `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 | | `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 |
| `format` | TEXT | 展示格式:`apps` / `posters` / `articles`;逐条设置,同一分组可混合 |
| `name` | TEXT | 名称:网站名 / 电影名 / 文章标题 | | `name` | TEXT | 名称:网站名 / 电影名 / 文章标题 |
| `description` | TEXT? | 一句话简介,列表卡片的副标题 | | `description` | TEXT? | 一句话简介,列表卡片的副标题 |
| `icon` | TEXT? | 字符图标,封面缺失时的占位 | | `icon` | TEXT? | 字符图标,封面缺失时的占位 |
@@ -257,7 +249,7 @@ WHERE COALESCE(p.parent_key, p.key) = 'recommend';
好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况; 好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况;
`node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。 `node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。
三种内容共用一张表,只是**用到的字段不同**,形态由所属分区的 `view_mode` 决定: 三种内容共用一张表,只是**用到的字段不同**,形态由每条自己的 `entry.format` 决定:
| 类型 | 常用字段 | | 类型 | 常用字段 |
| --- | --- | | --- | --- |
@@ -314,8 +306,8 @@ card_group 卡片包含哪些分组 + 每个分组的预览条数与先后
主键 `(card_id, group_key)`:同一张卡片里一个分组只出现一次。 主键 `(card_id, group_key)`:同一张卡片里一个分组只出现一次。
一张卡片可以混合展示多个分组(不同格式也能混),顺序与条数各自独立。 一张卡片可以混合展示多个分组(不同格式也能混),顺序与条数各自独立。
旧版的 `section_block` 表仍保留在库里(`CREATE TABLE IF NOT EXISTS` 不会删表), 旧版的 `section_block` 表已在 v23 迁移中删除;旧库升级时会先把数据回填到
但只作为从旧库迁移到 `card` / `card_group` 的来源,新代码不再读写它。 `card` / `card_group`,再删表,不会丢内容。
### 表关系 ### 表关系
@@ -353,7 +345,7 @@ card 1 ──── n card_group ──── 1 node(勾选的分组 + 每个
| 应用详情 | `entry`(apps) | `name` `description` `icon` `href` `cover` `banner` `badge` `comment` `views` `likes` | | 应用详情 | `entry`(apps) | `name` `description` `icon` `href` `cover` `banner` `badge` `comment` `views` `likes` |
| 海报详情 | `entry` + `entry_tag` | `name`(→`title`)`cover` `comment` `summary` `href` + `tags` | | 海报详情 | `entry` + `entry_tag` | `name`(→`title`)`cover` `comment` `summary` `href` + `tags` |
| 图文详情 | `entry` + `entry_tag` | `name`(→`title`)`description` `cover` `body` + `meta` | | 图文详情 | `entry` + `entry_tag` | `name`(→`title`)`description` `cover` `body` + `meta` |
| 侧栏导航 | `node` | 走 `parent_key` 的分组树,`show_in_sidebar = 0` 与 `enabled = 0` 的节点跳过;「本地服务」由前端排除 | | 侧栏导航 | `node` | 走 `parent_key` 的分组树,`enabled = 0` 的节点跳过;「本地服务」由前端排除 |
| 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 | | 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 |
### 侧栏导航 ### 侧栏导航
@@ -376,13 +368,13 @@ CES 现场报道(根分组,无子分组 → 直接打开)
- 有子分组时渲染成可折叠节点,子项就是子分组; - 有子分组时渲染成可折叠节点,子项就是子分组;
- 没有子分组时整行是链接; - 没有子分组时整行是链接;
- 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层; - 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层;
- `show_in_sidebar = 0` 的节点不出现;`enabled = 0` 的节点连同整棵子树都不出现; - `enabled = 0` 的节点连同整棵子树都不出现;
- 顺序取 `node.sort`(在「分组管理」里改「排序权重」); - 顺序取 `node.sort`(在「分组管理」里改「排序权重」);
- 「本地服务」是前端拼出来的分类,侧栏按 key 直接排除。 - 「本地服务」是前端拼出来的分类,侧栏按 key 直接排除。
### 首页卡片 ### 首页卡片
一张卡片 = 一个标题 + 一组勾选的分组,内容不混排: 一张卡片 = 一个标题 + 一组勾选的分组,分组各自渲染、可以混合格式:
```sql ```sql
-- 看看「常用推荐」这张卡片勾了哪些分组、各自几条 -- 看看「常用推荐」这张卡片勾了哪些分组、各自几条
@@ -508,7 +500,6 @@ GET /api/catalog2
"key": "pin", "key": "pin",
"title": "常用推荐", "title": "常用推荐",
"icon": "🔖", "icon": "🔖",
"format": "apps",
"enabled": true, "enabled": true,
"sort": 1, "sort": 1,
"entries": [{ "id": "1", "name": "1", "format": "apps" }], "entries": [{ "id": "1", "name": "1", "format": "apps" }],
@@ -526,11 +517,9 @@ GET /api/catalog2
} }
``` ```
「本地服务」卡片**不在**这个接口里,它由 `/api/projects` 扫描托管目录后在前端拼接。 「本地服务」卡片在 `cards` 里也有一行 `local-service`,但内容由 `/api/projects` 运行时生成。
侧栏不再单独下发,前端直接用 `groups` 派生。 侧栏不再单独下发,前端直接用 `groups` 派生。
`GET /api/catalog` 是旧版结构(`categories` 数组),仅为兼容保留。
### 订阅内容库变化 ### 订阅内容库变化
```http ```http
@@ -556,7 +545,7 @@ data: {"changed":true}
| --- | --- | | --- | --- |
| 健康与项目 | `GET /api/health`、`GET /api/projects`、`GET /api/projects/events`(SSE) | | 健康与项目 | `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 /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` | | 认证 | `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` 与本站同源。 写接口(POST / PATCH / PUT / DELETE)都要求登录会话,并校验请求的 `Origin` 与本站同源。
-1018
View File
File diff suppressed because it is too large Load Diff
-198
View File
@@ -1,198 +0,0 @@
(() => {
"use strict";
const grid = document.querySelector("#project-grid");
const refreshButton = document.querySelector("#refresh-projects");
const status = document.querySelector("#refresh-status");
const projectCounts = document.querySelectorAll("[data-project-count]");
const liveStatus = document.querySelector("#live-status");
const liveStatusLabel = document.querySelector("#live-status-label");
if (!grid || !refreshButton || !status || !liveStatus || !liveStatusLabel) {
return;
}
document.body.classList.add("is-enhanced");
const createProjectCard = (project) => {
if (
typeof project.name !== "string" ||
typeof project.route_path !== "string" ||
!project.route_path.startsWith("/") ||
project.route_path.startsWith("//")
) {
return null;
}
const link = document.createElement("a");
link.className = "site-item";
link.href = project.route_path;
link.title = project.name;
const cardTop = document.createElement("div");
cardTop.className = "site-card-top";
const avatar = document.createElement("span");
avatar.className = "site-avatar";
avatar.setAttribute("aria-hidden", "true");
avatar.textContent = Array.from(project.name)[0]?.toUpperCase() || "A";
const open = document.createElement("span");
open.className = "site-open";
open.setAttribute("aria-hidden", "true");
open.innerHTML = `
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 17 17 7M8 7h9v9"></path>
</svg>
`;
cardTop.append(avatar, open);
const info = document.createElement("div");
info.className = "site-info";
const name = document.createElement("h3");
name.className = "site-name";
name.textContent = project.name;
const description = document.createElement("span");
description.className = "site-desc";
description.textContent = "本地静态应用";
info.append(name, description);
const footer = document.createElement("div");
footer.className = "site-card-footer";
const path = document.createElement("span");
path.className = "site-path";
path.textContent = project.route_path;
const ready = document.createElement("span");
ready.className = "site-ready";
ready.textContent = "READY";
footer.append(path, ready);
link.append(cardTop, info, footer);
return link;
};
let renderedSignature = JSON.stringify(
Array.from(grid.querySelectorAll(".site-item")).map((link) => [
link.title,
link.getAttribute("href"),
]),
);
const projectsSignature = (projects) =>
JSON.stringify(
projects.map((project) => [project.name, project.route_path]),
);
const renderProjects = (projects) => {
const nextSignature = projectsSignature(projects);
if (nextSignature === renderedSignature) {
return false;
}
grid.replaceChildren();
if (projects.length === 0) {
const emptyState = document.createElement("div");
emptyState.className = "empty-state";
emptyState.innerHTML = `
<span class="empty-visual" aria-hidden="true">
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
<path d="M8 13h8M8 16h5"></path>
</svg>
</span>
<h3>这里还没有项目</h3>
<p>在托管根目录下创建子目录,并放入 <code>index.html</code>,刷新后即可在这里看到它。</p>
`;
grid.append(emptyState);
renderedSignature = nextSignature;
return true;
}
const cards = projects.map(createProjectCard).filter(Boolean);
grid.append(...cards);
renderedSignature = nextSignature;
return true;
};
const updateProjectCounts = (count) => {
projectCounts.forEach((element) => {
element.textContent = String(count);
});
};
refreshButton.addEventListener("click", async () => {
refreshButton.disabled = true;
refreshButton.classList.add("is-loading");
grid.setAttribute("aria-busy", "true");
status.textContent = "正在重新扫描项目...";
try {
const response = await fetch("/api/projects", {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
if (!Array.isArray(payload.projects)) {
throw new Error("项目列表格式无效");
}
const changed = renderProjects(payload.projects);
updateProjectCounts(payload.projects.length);
status.textContent = changed ? "项目列表已刷新" : "项目列表已是最新";
} catch (error) {
console.error("刷新项目列表失败", error);
status.textContent = "刷新失败,请稍后重试";
} finally {
refreshButton.disabled = false;
refreshButton.classList.remove("is-loading");
grid.removeAttribute("aria-busy");
}
});
if (!("EventSource" in window)) {
liveStatusLabel.textContent = "手动刷新";
return;
}
const eventSource = new EventSource("/api/projects/events");
eventSource.addEventListener("open", () => {
liveStatusLabel.textContent = "实时同步";
liveStatus.classList.add("is-live");
liveStatus.classList.remove("is-reconnecting");
});
eventSource.addEventListener("projects", (event) => {
try {
const payload = JSON.parse(event.data);
if (!Array.isArray(payload.projects)) {
throw new Error("项目列表格式无效");
}
const changed = renderProjects(payload.projects);
updateProjectCounts(payload.projects.length);
if (changed) {
status.textContent = "检测到目录变化,项目列表已自动更新";
}
} catch (error) {
console.error("处理项目更新事件失败", error);
status.textContent = "收到项目更新,但无法解析";
}
});
eventSource.addEventListener("error", () => {
liveStatusLabel.textContent = "同步重连中";
liveStatus.classList.remove("is-live");
liveStatus.classList.add("is-reconnecting");
});
})();
+26
View File
@@ -4,6 +4,8 @@ use crate::error::StashError;
/// 数据目录名:与二进制同级,存放可被用户直接修改的内容库。 /// 数据目录名:与二进制同级,存放可被用户直接修改的内容库。
pub const DATA_DIR_NAME: &str = "data"; pub const DATA_DIR_NAME: &str = "data";
/// 字体目录名:与二进制同级,后台「系统管理 → 字体」只从这个目录列字体。
pub const FONT_DIR_NAME: &str = "font";
/// 内容库文件名:固定为这个名字,一个实例一个库。 /// 内容库文件名:固定为这个名字,一个实例一个库。
pub const DB_FILE_NAME: &str = "stash.db"; pub const DB_FILE_NAME: &str = "stash.db";
@@ -13,6 +15,8 @@ pub struct Config {
pub root: PathBuf, pub root: PathBuf,
/// 数据目录:存放内容库与将来的备份 / 导出文件 /// 数据目录:存放内容库与将来的备份 / 导出文件
pub data_dir: PathBuf, pub data_dir: PathBuf,
/// 字体目录:后台字体下拉只列这里的文件,通过 `/fonts/<文件名>` 对外提供
pub font_dir: PathBuf,
/// 内容库路径,固定为 `data/stash.db` /// 内容库路径,固定为 `data/stash.db`
pub db_path: PathBuf, pub db_path: PathBuf,
/// 监听端口 /// 监听端口
@@ -34,6 +38,7 @@ impl Default for Config {
root: PathBuf::from("../public"), root: PathBuf::from("../public"),
db_path: resolve_db_path(&data_dir), db_path: resolve_db_path(&data_dir),
data_dir, data_dir,
font_dir: PathBuf::from(FONT_DIR_NAME),
port: 8080, port: 8080,
host: "127.0.0.1".to_string(), host: "127.0.0.1".to_string(),
directory_listing: true, directory_listing: true,
@@ -65,6 +70,18 @@ pub fn resolve_db_path(data_dir: &Path) -> PathBuf {
data_dir.join(DB_FILE_NAME) 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` 下的所有文件当静态资源对外提供, /// `ServeDir` 会把 `--root` 下的所有文件当静态资源对外提供,
@@ -151,6 +168,15 @@ mod tests {
assert_eq!(default_data_dir_from(None), PathBuf::from(DATA_DIR_NAME)); 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] #[test]
fn db_path_is_fixed_inside_the_data_dir() { fn db_path_is_fixed_inside_the_data_dir() {
assert_eq!( assert_eq!(
+12 -343
View File
@@ -11,23 +11,10 @@ use rusqlite::Connection;
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
use crate::{ use crate::{
db::{Catalog, map_error}, db::map_error,
error::StashError, error::StashError,
}; };
/// 容器节点行:分区与分组共用
struct NodeRow {
key: String,
title: String,
view_mode: Option<String>,
bar_color: Option<String>,
icon: Option<String>,
preview_limit: Option<i64>,
empty_text: Option<String>,
show_in_sidebar: bool,
allow_groups: bool,
}
/// 条目行:与 `entry` 表的列一一对应 /// 条目行:与 `entry` 表的列一一对应
struct EntryRow { struct EntryRow {
key: String, key: String,
@@ -47,19 +34,11 @@ struct EntryRow {
likes: i64, likes: i64,
} }
/// 读出整个内容目录。
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
Ok(Catalog {
categories: load_sections(conn)?,
})
}
/// 分组树节点(新视图用) /// 分组树节点(新视图用)
struct TreeNode { struct TreeNode {
key: String, key: String,
parent: Option<String>, parent: Option<String>,
title: String, title: String,
view_mode: Option<String>,
icon: Option<String>, icon: Option<String>,
bar_color: Option<String>, bar_color: Option<String>,
preview_limit: Option<i64>, preview_limit: Option<i64>,
@@ -67,11 +46,11 @@ struct TreeNode {
enabled: bool, enabled: bool,
} }
/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。 /// 新的原子视图:完整分组树(多层、带条目),供新首页/侧栏/后台使用。
pub fn load_tree(conn: &Connection) -> Result<Value, StashError> { pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT key, parent_key, title, view_mode, icon, bar_color, preview_limit, sort, enabled "SELECT key, parent_key, title, icon, bar_color, preview_limit, sort, enabled
FROM node ORDER BY sort, key", FROM node ORDER BY sort, key",
) )
.map_err(map_error)?; .map_err(map_error)?;
@@ -82,12 +61,11 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
key: row.get(0)?, key: row.get(0)?,
parent: row.get(1)?, parent: row.get(1)?,
title: row.get(2)?, title: row.get(2)?,
view_mode: row.get(3)?, icon: row.get(3)?,
icon: row.get(4)?, bar_color: row.get(4)?,
bar_color: row.get(5)?, preview_limit: row.get(5)?,
preview_limit: row.get(6)?, sort: row.get(6)?,
sort: row.get(7)?, enabled: row.get(7)?,
enabled: row.get(8)?,
}) })
}) })
.map_err(map_error)? .map_err(map_error)?
@@ -96,48 +74,22 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
drop(stmt); drop(stmt);
let index: HashMap<String, usize> = nodes
.iter()
.enumerate()
.map(|(idx, node)| (node.key.clone(), idx))
.collect();
// 子节点索引(按 sort 已排好序) // 子节点索引(按 sort 已排好序)
let mut children: HashMap<Option<String>, Vec<usize>> = HashMap::new(); let mut children: HashMap<Option<String>, Vec<usize>> = HashMap::new();
for (idx, node) in nodes.iter().enumerate() { for (idx, node) in nodes.iter().enumerate() {
children.entry(node.parent.clone()).or_default().push(idx); children.entry(node.parent.clone()).or_default().push(idx);
} }
// 每个节点继承最近根分组的格式
let mut formats: Vec<String> = vec![String::new(); nodes.len()];
for idx in 0..nodes.len() {
let mut cursor = Some(idx);
let mut format = "apps".to_string();
while let Some(current) = cursor {
if let Some(mode) = &nodes[current].view_mode {
format = mode.clone();
break;
}
cursor = nodes[current]
.parent
.as_ref()
.and_then(|parent| index.get(parent).copied());
}
formats[idx] = format;
}
let tags = load_all_tags(conn)?; let tags = load_all_tags(conn)?;
fn render( fn render(
conn: &Connection, conn: &Connection,
nodes: &[TreeNode], nodes: &[TreeNode],
children: &HashMap<Option<String>, Vec<usize>>, children: &HashMap<Option<String>, Vec<usize>>,
formats: &[String],
tags: &HashMap<String, Vec<String>>, tags: &HashMap<String, Vec<String>>,
idx: usize, idx: usize,
) -> Result<Value, StashError> { ) -> Result<Value, StashError> {
let node = &nodes[idx]; let node = &nodes[idx];
let format = &formats[idx];
let entries = load_node_entries(conn, &node.key)?; let entries = load_node_entries(conn, &node.key)?;
let items = entries let items = entries
.iter() .iter()
@@ -148,7 +100,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
.get(&Some(node.key.clone())) .get(&Some(node.key.clone()))
.map(|list| { .map(|list| {
list.iter() list.iter()
.map(|child| render(conn, nodes, children, formats, tags, *child)) .map(|child| render(conn, nodes, children, tags, *child))
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
}) })
.transpose()? .transpose()?
@@ -158,7 +110,6 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
"key": node.key, "key": node.key,
"parentKey": node.parent, "parentKey": node.parent,
"title": node.title, "title": node.title,
"format": format,
"icon": node.icon.clone().unwrap_or_default(), "icon": node.icon.clone().unwrap_or_default(),
"barColor": node.bar_color.clone().unwrap_or_default(), "barColor": node.bar_color.clone().unwrap_or_default(),
"previewLimit": node.preview_limit, "previewLimit": node.preview_limit,
@@ -173,7 +124,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
.get(&None) .get(&None)
.map(|list| { .map(|list| {
list.iter() list.iter()
.map(|idx| render(conn, &nodes, &children, &formats, &tags, *idx)) .map(|idx| render(conn, &nodes, &children, &tags, *idx))
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
}) })
.transpose()? .transpose()?
@@ -203,164 +154,6 @@ fn load_all_tags(conn: &Connection) -> Result<HashMap<String, Vec<String>>, Stas
Ok(grouped) Ok(grouped)
} }
/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。
fn load_sections(conn: &Connection) -> Result<Vec<Value>, StashError> {
let mut stmt = conn
.prepare(
"SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups
FROM node
WHERE kind = 'section'
ORDER BY sort, key",
)
.map_err(map_error)?;
let sections = stmt
.query_map([], |row| {
Ok(NodeRow {
key: row.get(0)?,
title: row.get(1)?,
view_mode: row.get(2)?,
bar_color: row.get(3)?,
icon: row.get(4)?,
preview_limit: row.get(5)?,
empty_text: row.get(6)?,
show_in_sidebar: row.get(7)?,
allow_groups: row.get(8)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
// 先释放语句再发后续查询,避免同一连接上的借用冲突
drop(stmt);
// 每个分区对应的首页卡片:展示哪个节点、几条
let blocks = load_blocks(conn)?;
sections
.iter()
.map(|section| build_section(conn, section, blocks.get(&section.key)))
.collect()
}
/// 读出所有卡片配置,按推导出的分区业务键索引。
///
/// 分区不单独存:节点是分组就取它的 `parent_key`,节点是分区就取它自己。
/// `title` 同样在 SQL 里解析:卡片自己配了标题就用它,否则用被展示节点的标题。
fn load_blocks(conn: &Connection) -> Result<HashMap<String, Value>, StashError> {
let mut stmt = conn
.prepare(
"SELECT COALESCE(p.key, n.key) AS section_key,
b.node_key,
COALESCE(b.title, n.title),
b.preview_limit
FROM section_block b
JOIN node n ON n.key = b.node_key
LEFT JOIN node p ON p.key = n.parent_key
ORDER BY b.sort, b.node_key",
)
.map_err(map_error)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<i64>>(3)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
let mut blocks = HashMap::new();
for (section_key, node_key, title, preview_limit) in rows {
let mut block = Map::new();
block.insert("nodeKey".into(), json!(node_key));
block.insert("title".into(), json!(title));
if let Some(limit) = preview_limit {
block.insert("previewLimit".into(), json!(limit));
}
// 同一分区配了多个卡片时只保留第一个:当前 UI 一张卡片展示一个节点
blocks.entry(section_key).or_insert(Value::Object(block));
}
Ok(blocks)
}
/// 按 `view_mode` 分派到不同的组装方式。
fn build_section(
conn: &Connection,
section: &NodeRow,
block: Option<&Value>,
) -> Result<Value, StashError> {
let view_mode = section
.view_mode
.as_deref()
.ok_or_else(|| StashError::Database(format!("分区 {} 缺少 view_mode", section.key)))?;
let mut object = Map::new();
object.insert("id".into(), json!(section.key));
object.insert("kind".into(), json!(view_mode));
object.insert("title".into(), json!(section.title));
object.insert(
"barColor".into(),
json!(section.bar_color.clone().unwrap_or_default()),
);
object.insert("icon".into(), json!(section.icon.clone().unwrap_or_default()));
object.insert("showInSidebar".into(), json!(section.show_in_sidebar));
object.insert("allowGroups".into(), json!(section.allow_groups));
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
if let Some(limit) = section.preview_limit {
object.insert("previewLimit".into(), json!(limit));
}
if let Some(empty_text) = &section.empty_text {
object.insert("emptyText".into(), json!(empty_text));
}
if let Some(block) = block {
object.insert("block".into(), block.clone());
}
match view_mode {
"apps" | "posters" | "articles" => {}
other => {
return Err(StashError::Database(format!(
"分区 {} 的 view_mode 非法: {other}",
section.key
)));
}
}
// 「允许子分组」开启:分区 → 分组 → 条目;关闭:全部条目平铺
let tags = load_tags(conn, &section.key)?;
if section.allow_groups {
object.insert(
"groups".into(),
build_groups(conn, section, &tags)?,
);
let direct = load_node_entries(conn, &section.key)?;
let items = direct
.iter()
.map(|entry| item_for(entry, &tags))
.collect::<Vec<_>>();
object.insert("items".into(), json!(items));
} else {
object.insert("groups".into(), json!([]));
// 平铺:把(可能存在的)分组里的条目一起取出来,关闭开关时不丢内容
let entries = load_section_entries(conn, &section.key)?;
let items = entries
.iter()
.map(|entry| item_for(entry, &tags))
.collect::<Vec<_>>();
object.insert("items".into(), json!(items));
}
Ok(Value::Object(object))
}
/// 按条目自身的格式组装列表项。 /// 按条目自身的格式组装列表项。
fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value { fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value {
let mut value = match entry.format.as_str() { let mut value = match entry.format.as_str() {
@@ -374,55 +167,6 @@ fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value {
value value
} }
/// 取出某个分区下的分组,并把条目按 `node_key` 分派进去。
fn build_groups(
conn: &Connection,
section: &NodeRow,
tags: &HashMap<String, Vec<String>>,
) -> Result<Value, StashError> {
let mut stmt = conn
.prepare(
"SELECT key, title, show_in_sidebar FROM node
WHERE kind = 'group' AND parent_key = ?1
ORDER BY sort, key",
)
.map_err(map_error)?;
let groups = stmt
.query_map([&section.key], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, bool>(2)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
let mut rendered_groups = Vec::with_capacity(groups.len());
for (group_key, group_title, show_in_sidebar) in groups {
let entries = load_node_entries(conn, &group_key)?;
let items = entries
.iter()
.map(|entry| item_for(entry, tags))
.collect::<Vec<_>>();
rendered_groups.push(json!({
// 对外用业务键,不用数字主键
"id": group_key,
"name": group_title,
"showInSidebar": show_in_sidebar,
"items": items,
}));
}
Ok(Value::Array(rendered_groups))
}
/// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。
fn app_item(entry: &EntryRow) -> Value { fn app_item(entry: &EntryRow) -> Value {
let mut object = Map::new(); let mut object = Map::new();
object.insert("id".into(), json!(entry.key)); object.insert("id".into(), json!(entry.key));
@@ -504,55 +248,9 @@ fn insert_optional(object: &mut Map<String, Value>, key: &str, value: &Option<St
} }
} }
/// 取某个分区下的全部条目,按 `sort` 升序。 /// 取某个节点直接挂着的条目,按 `sort` 升序。
/// fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> {
/// 条目只存 `node_key`,所属分区由父节点推导:
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
/// 取某个分区下的全部条目(含分组内的),海报 / 图文平铺用。
fn load_section_entries(
conn: &Connection,
section_key: &str,
) -> Result<Vec<EntryRow>, StashError> {
let mut stmt = conn let mut stmt = conn
.prepare(
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
COALESCE(e.format, 'apps')
FROM entry e
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = ?1
ORDER BY e.sort, e.key",
)
.map_err(map_error)?;
let entries = stmt
.query_map([section_key], |row| {
Ok(EntryRow {
key: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
icon: row.get(3)?,
href: row.get(4)?,
cover: row.get(5)?,
banner: row.get(6)?,
badge: row.get(7)?,
comment: row.get(8)?,
summary: row.get(9)?,
body: row.get(10)?,
hot: row.get(11)?,
views: row.get(12)?,
likes: row.get(13)?,
format: row.get(14)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
Ok(entries)
}
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> { let mut stmt = conn
.prepare( .prepare(
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover, "SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes, e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
@@ -589,32 +287,3 @@ fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>,
Ok(entries) Ok(entries)
} }
/// 取某个分区下所有条目的标签,按条目业务键聚合;一次查询避免 N+1。
fn load_tags(conn: &Connection, section_key: &str) -> Result<HashMap<String, Vec<String>>, StashError> {
let mut stmt = conn
.prepare(
"SELECT t.entry_key, t.label
FROM entry_tag t
JOIN entry e ON e.key = t.entry_key
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = ?1
ORDER BY t.entry_key, t.sort",
)
.map_err(map_error)?;
let rows = stmt
.query_map([section_key], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
for (entry_key, label) in rows {
grouped.entry(entry_key).or_default().push(label);
}
Ok(grouped)
}
-827
View File
@@ -1,827 +0,0 @@
//! 分区(首页卡片 / 侧栏项)的新增与删除。
//!
//! 分区就是 `node` 表里 `kind='section'` 的行;删除会级联分组、条目与展示块。
//! 新增时顺手建一条首页展示块,保证它立刻出现在首页。
use rusqlite::{Connection, OptionalExtension, params};
use serde::Deserialize;
use crate::{db::map_error, error::StashError, db::entries};
const KINDS: [&str; 3] = ["apps", "posters", "articles"];
#[derive(Debug, Clone, Deserialize)]
pub struct NewCategory {
/// 业务键,只允许小写字母、数字与连字符
pub id: String,
pub title: String,
/// apps / posters / articles
pub kind: String,
}
/// 修改分区:字段都可选,只改传进来的。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCategory {
pub title: Option<String>,
pub icon: Option<String>,
pub bar_color: Option<String>,
/// 改类型(应用 / 海报 / 图文)时会顺带迁移条目归属
pub kind: Option<String>,
pub preview_limit: Option<i64>,
pub empty_text: Option<String>,
/// 首页卡片预览哪个节点(分区自己或它下面的分组)
pub block_node_key: Option<String>,
/// 是否允许子分组(与类型无关,后台开关)
pub allow_groups: Option<bool>,
}
/// 新建分组(子分类)
#[derive(Debug, Clone, Deserialize)]
pub struct NewGroup {
pub id: String,
pub title: String,
}
/// 修改分组:改名 / 侧栏显隐
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateGroup {
pub title: Option<String>,
pub show_in_sidebar: Option<bool>,
}
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 32
&& id
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
}
pub fn create(conn: &mut Connection, new: NewCategory) -> Result<(), StashError> {
let id = new.id.trim();
let title = new.title.trim();
if !valid_id(id) {
return Err(StashError::InvalidDirectory(
"标识只能用小写字母、数字与连字符(≤32)".into(),
));
}
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into()));
}
if !KINDS.contains(&new.kind.as_str()) {
return Err(StashError::InvalidDirectory("类型不合法".into()));
}
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| row.get(0))
.map_err(map_error)?;
if exists > 0 {
return Err(StashError::Conflict(format!("分区已存在: {id}")));
}
let tx = conn.transaction().map_err(map_error)?;
let sort: i64 = tx
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE kind = 'section'",
[],
|row| row.get(0),
)
.map_err(map_error)?;
tx.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, allow_groups, sort, show_in_sidebar)
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, 1)",
params![id, title, new.kind, i64::from(new.kind == "apps"), sort],
)
.map_err(map_error)?;
let block_sort: i64 = tx
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block",
[],
|row| row.get(0),
)
.map_err(map_error)?;
tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort) VALUES (?1, NULL, NULL, ?2)",
params![id, block_sort],
)
.map_err(map_error)?;
entries::audit(&tx, "category.create", id, title)?;
tx.commit().map_err(map_error)?;
Ok(())
}
struct SectionRow {
title: String,
icon: Option<String>,
bar_color: Option<String>,
view_mode: Option<String>,
preview_limit: Option<i64>,
empty_text: Option<String>,
allow_groups: bool,
}
fn valid_color(color: &str) -> bool {
if color.is_empty() {
return true;
}
let hex = color.strip_prefix('#').unwrap_or("");
(hex.len() == 3 || hex.len() == 6) && hex.chars().all(|ch| ch.is_ascii_hexdigit())
}
/// 修改分区:标题 / 图标 / 色条 / 预览条数 / 空态文案,以及「归类」类型。
///
/// 改类型时迁移数据,保证切换后条目不丢:
/// - 海报 / 图文 → 应用:确保有一个分组,把直接挂在分区上的条目挪进去,
/// 首页卡片也改指向该分组;
/// - 应用 → 海报 / 图文:卡片从分组挪回分区,分组保留(不影响条目读取)。
pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<(), StashError> {
if id == "local" {
return Err(StashError::InvalidDirectory(
"「本地服务」是运行时分类,不能编辑".into(),
));
}
let tx = conn.transaction().map_err(map_error)?;
let current = tx
.query_row(
"SELECT title, icon, bar_color, view_mode, preview_limit, empty_text, allow_groups
FROM node WHERE key = ?1 AND kind = 'section'",
[id],
|row| {
Ok(SectionRow {
title: row.get(0)?,
icon: row.get(1)?,
bar_color: row.get(2)?,
view_mode: row.get(3)?,
preview_limit: row.get(4)?,
empty_text: row.get(5)?,
allow_groups: row.get(6)?,
})
},
)
.optional()
.map_err(map_error)?;
let Some(current) = current else {
return Err(StashError::NotFound(format!("分区不存在: {id}")));
};
let title = match patch.title {
Some(title) => {
let title = title.trim().to_string();
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into()));
}
title
}
None => current.title,
};
let icon = match patch.icon {
Some(icon) => {
let icon = icon.trim().to_string();
if icon.chars().count() > 8 {
return Err(StashError::InvalidDirectory("图标最多 8 个字符".into()));
}
(!icon.is_empty()).then_some(icon)
}
None => current.icon,
};
let bar_color = match patch.bar_color {
Some(color) => {
let color = color.trim().to_ascii_lowercase();
if !valid_color(&color) {
return Err(StashError::InvalidDirectory(
"色条需为 #rgb 或 #rrggbb".into(),
));
}
(!color.is_empty()).then_some(color)
}
None => current.bar_color,
};
let empty_text = match patch.empty_text {
Some(text) => {
let text = text.trim().to_string();
(!text.is_empty()).then_some(text)
}
None => current.empty_text,
};
let preview_limit = match patch.preview_limit {
Some(limit) => {
if !(1..=60).contains(&limit) {
return Err(StashError::InvalidDirectory("预览条数需在 1–60 之间".into()));
}
Some(limit)
}
None => current.preview_limit,
};
let kind = match patch.kind {
Some(kind) => {
if !KINDS.contains(&kind.as_str()) {
return Err(StashError::InvalidDirectory("类型不合法".into()));
}
kind
}
None => current
.view_mode
.clone()
.ok_or_else(|| StashError::Database(format!("分区 {id} 缺少 view_mode")))?,
};
let was_apps = current.view_mode.as_deref() == Some("apps");
let is_apps = kind == "apps";
let allow_groups = patch.allow_groups.unwrap_or(current.allow_groups);
if is_apps && !was_apps && allow_groups {
// 切到应用且开启子分组:条目必须有分组可挂;没有就建一个「全部」
let group_count: i64 = tx
.query_row(
"SELECT COUNT(*) FROM node WHERE kind = 'group' AND parent_key = ?1",
[id],
|row| row.get(0),
)
.map_err(map_error)?;
let group_key = if group_count == 0 {
let group_key = format!("{id}-all");
tx.execute(
"INSERT INTO node (key, parent_key, title, kind, sort, show_in_sidebar)
VALUES (?1, ?2, '全部', 'group', 1, 1)",
params![group_key, id],
)
.map_err(map_error)?;
group_key
} else {
tx.query_row(
"SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1
ORDER BY sort, key LIMIT 1",
[id],
|row| row.get(0),
)
.map_err(map_error)?
};
tx.execute(
"UPDATE entry SET node_key = ?1 WHERE node_key = ?2",
params![group_key, id],
)
.map_err(map_error)?;
tx.execute(
"UPDATE section_block SET node_key = ?1 WHERE node_key = ?2",
params![group_key, id],
)
.map_err(map_error)?;
}
// 关闭子分组:首页卡片如果指着分组,挪回分区,避免标题/预览错位
if !allow_groups {
tx.execute(
"UPDATE section_block SET node_key = ?1, title = NULL
WHERE node_key IN (
SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1
)",
[id],
)
.map_err(map_error)?;
}
tx.execute(
"UPDATE node SET title = ?1, icon = ?2, bar_color = ?3, view_mode = ?4,
preview_limit = ?5, empty_text = ?6, allow_groups = ?7
WHERE key = ?8",
params![
title,
icon,
bar_color,
kind,
preview_limit,
empty_text,
allow_groups,
id
],
)
.map_err(map_error)?;
// 首页卡片预览哪个节点:分区自己或它下面的分组
if let Some(block_node_key) = patch.block_node_key {
let block_node_key = block_node_key.trim().to_string();
let valid: i64 = tx
.query_row(
"SELECT COUNT(*) FROM node n LEFT JOIN node p ON p.key = n.parent_key
WHERE n.key = ?1 AND (n.key = ?2 OR p.key = ?2)",
params![block_node_key, id],
|row| row.get(0),
)
.map_err(map_error)?;
if valid == 0 {
return Err(StashError::InvalidDirectory(
"首页卡片只能预览本分区或它的分组".into(),
));
}
let updated = tx
.execute(
"UPDATE section_block SET node_key = ?1, title = NULL
WHERE id IN (
SELECT b.id FROM section_block b
JOIN node n ON n.key = b.node_key
LEFT JOIN node p ON p.key = n.parent_key
WHERE COALESCE(p.key, n.key) = ?2
)",
params![block_node_key, id],
)
.map_err(map_error)?;
if updated == 0 {
let sort: i64 = tx
.query_row("SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block", [], |row| {
row.get(0)
})
.map_err(map_error)?;
tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort)
VALUES (?1, NULL, NULL, ?2)",
params![block_node_key, sort],
)
.map_err(map_error)?;
}
}
entries::audit(&tx, "category.update", id, &title)?;
tx.commit().map_err(map_error)?;
Ok(())
}
/// 在应用类分区下新建分组(子分类)。
pub fn create_group(conn: &mut Connection, section_id: &str, new: NewGroup) -> Result<(), StashError> {
let id = new.id.trim();
let title = new.title.trim();
if !valid_id(id) {
return Err(StashError::InvalidDirectory(
"分组标识只能用小写字母、数字与连字符(≤32)".into(),
));
}
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
}
let allow_groups: Option<bool> = conn
.query_row(
"SELECT allow_groups FROM node WHERE key = ?1 AND kind = 'section'",
[section_id],
|row| row.get(0),
)
.optional()
.map_err(map_error)?;
let Some(allow_groups) = allow_groups else {
return Err(StashError::NotFound(format!("分区不存在: {section_id}")));
};
if !allow_groups {
return Err(StashError::InvalidDirectory(
"该分区未开启「允许子分组」".into(),
));
}
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| {
row.get(0)
})
.map_err(map_error)?;
if exists > 0 {
return Err(StashError::Conflict(format!("分组已存在: {id}")));
}
let sort: i64 = conn
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1",
[section_id],
|row| row.get(0),
)
.map_err(map_error)?;
conn.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar)
VALUES (?1, ?2, ?3, 'group', NULL, ?4, 1)",
params![id, section_id, title, sort],
)
.map_err(map_error)?;
entries::audit(conn, "group.create", id, title)?;
Ok(())
}
/// 修改分组:改名 / 侧栏显隐。
pub fn update_group(conn: &Connection, id: &str, patch: UpdateGroup) -> Result<(), StashError> {
let current: Option<String> = conn
.query_row(
"SELECT title FROM node WHERE key = ?1 AND kind = 'group'",
[id],
|row| row.get(0),
)
.optional()
.map_err(map_error)?;
let Some(current) = current else {
return Err(StashError::NotFound(format!("分组不存在: {id}")));
};
let title = match patch.title {
Some(title) => {
let title = title.trim().to_string();
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
}
title
}
None => current,
};
match patch.show_in_sidebar {
Some(show) => conn.execute(
"UPDATE node SET title = ?1, show_in_sidebar = ?2 WHERE key = ?3",
params![title, show, id],
),
None => conn.execute(
"UPDATE node SET title = ?1 WHERE key = ?2",
params![title, id],
),
}
.map_err(map_error)?;
entries::audit(conn, "group.update", id, &title)?;
Ok(())
}
/// 删除分组:组内条目会一起删除(前端需二次确认)。
pub fn remove_group(conn: &Connection, id: &str) -> Result<(), StashError> {
let removed = conn
.execute("DELETE FROM node WHERE key = ?1 AND kind = 'group'", [id])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("分组不存在: {id}")));
}
entries::audit(conn, "group.delete", id, "")?;
Ok(())
}
pub fn remove(conn: &mut Connection, id: &str) -> Result<(), StashError> {
if id == "local" {
return Err(StashError::InvalidDirectory("本地服务不能删除".into()));
}
let removed = conn
.execute("DELETE FROM node WHERE key = ?1 AND kind = 'section'", [id])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("分区不存在: {id}")));
}
entries::audit(conn, "category.delete", id, "")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE node (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
title TEXT NOT NULL,
kind TEXT NOT NULL,
view_mode TEXT,
icon TEXT,
bar_color TEXT,
preview_limit INTEGER,
empty_text TEXT,
sort INTEGER NOT NULL DEFAULT 0,
show_in_sidebar INTEGER NOT NULL DEFAULT 1,
allow_groups INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE entry (
key TEXT PRIMARY KEY,
node_key TEXT NOT NULL
);
CREATE TABLE section_block (
id INTEGER PRIMARY KEY,
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
title TEXT,
preview_limit INTEGER,
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at INTEGER NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT
);",
)
.expect("应能建表");
conn
}
#[test]
fn create_then_delete() {
let mut conn = db();
create(
&mut conn,
NewCategory {
id: "books".into(),
title: "书单".into(),
kind: "posters".into(),
},
)
.expect("应能创建");
let blocks: i64 = conn
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
.expect("应能查");
assert_eq!(blocks, 1);
remove(&mut conn, "books").expect("应能删除");
let nodes: i64 = conn
.query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0))
.expect("应能查");
assert_eq!(nodes, 0);
}
#[test]
fn rejects_bad_id_and_duplicate() {
let mut conn = db();
assert!(
create(
&mut conn,
NewCategory {
id: "Bad Id".into(),
title: "x".into(),
kind: "apps".into()
}
)
.is_err()
);
create(
&mut conn,
NewCategory {
id: "books".into(),
title: "书单".into(),
kind: "apps".into(),
},
)
.expect("应能创建");
let error = create(
&mut conn,
NewCategory {
id: "books".into(),
title: "书单".into(),
kind: "apps".into(),
},
)
.expect_err("重复应失败");
assert!(matches!(error, StashError::Conflict(_)));
}
fn section(id: &str, title: &str, kind: &str) -> NewCategory {
NewCategory {
id: id.into(),
title: title.into(),
kind: kind.into(),
}
}
#[test]
fn updates_fields_and_rejects_bad_values() {
let mut conn = db();
create(&mut conn, section("books", "书单", "posters")).expect("应能创建");
update(
&mut conn,
"books",
UpdateCategory {
title: Some("读过的书".into()),
icon: Some("📚".into()),
bar_color: Some("#ff6034".into()),
preview_limit: Some(8),
..Default::default()
},
)
.expect("应能更新");
let (title, icon, color, limit): (String, Option<String>, Option<String>, Option<i64>) =
conn.query_row(
"SELECT title, icon, bar_color, preview_limit FROM node WHERE key='books'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.expect("应能查");
assert_eq!(title, "读过的书");
assert_eq!(icon.as_deref(), Some("📚"));
assert_eq!(color.as_deref(), Some("#ff6034"));
assert_eq!(limit, Some(8));
assert!(update(&mut conn, "books", UpdateCategory { bar_color: Some("red".into()), ..Default::default() }).is_err());
assert!(update(&mut conn, "books", UpdateCategory { kind: Some("videos".into()), ..Default::default() }).is_err());
assert!(update(&mut conn, "local", UpdateCategory { title: Some("x".into()), ..Default::default() }).is_err());
assert!(matches!(
update(&mut conn, "none", UpdateCategory::default()).expect_err("应不存在"),
StashError::NotFound(_)
));
}
#[test]
fn switching_apps_to_posters_keeps_groups_until_disabled() {
let mut conn = db();
create(&mut conn, section("books", "书单", "apps")).expect("应能创建");
conn.execute(
"INSERT INTO node (key, parent_key, title, kind, sort) VALUES ('books-g', 'books', '分组', 'group', 1)",
[],
)
.expect("应能建分组");
conn.execute("INSERT INTO entry (key, node_key) VALUES ('b1', 'books-g')", [])
.expect("应能建条目");
// create() 会顺手建一张分区卡片;这里只保留分组卡片,模拟真实配置
conn.execute("DELETE FROM section_block", []).expect("应能清卡片");
conn.execute(
"INSERT INTO section_block (node_key, title, sort) VALUES ('books-g', '分组', 1)",
[],
)
.expect("应能建卡片");
// 类型切到海报但子分组仍开启:分组和卡片都保留
update(
&mut conn,
"books",
UpdateCategory {
kind: Some("posters".into()),
..Default::default()
},
)
.expect("应能改类型");
let block: String = conn
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
.expect("应能查");
assert_eq!(block, "books-g");
// 关闭子分组:卡片挪回分区
update(
&mut conn,
"books",
UpdateCategory {
allow_groups: Some(false),
..Default::default()
},
)
.expect("应能关闭");
let block: String = conn
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
.expect("应能查");
assert_eq!(block, "books", "关闭子分组后卡片应指回分区");
}
#[test]
fn creates_updates_and_removes_group() {
let mut conn = db();
create(&mut conn, section("apps1", "应用", "apps")).expect("应能创建");
create_group(
&mut conn,
"apps1",
NewGroup {
id: "g1".into(),
title: "常用".into(),
},
)
.expect("应能建分组");
update_group(
&conn,
"g1",
UpdateGroup {
title: Some("改名".into()),
show_in_sidebar: Some(false),
},
)
.expect("应能改分组");
let (title, show): (String, i64) = conn
.query_row(
"SELECT title, show_in_sidebar FROM node WHERE key='g1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("应能查");
assert_eq!(title, "改名");
assert_eq!(show, 0);
remove_group(&conn, "g1").expect("应能删分组");
let left: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key='g1'", [], |row| {
row.get(0)
})
.expect("应能查");
assert_eq!(left, 0);
}
#[test]
fn groups_only_under_apps_sections() {
let mut conn = db();
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
assert!(
create_group(
&mut conn,
"films",
NewGroup {
id: "g1".into(),
title: "片单".into()
}
)
.is_err(),
"未开启子分组的分区不能建组"
);
// 打开「允许子分组」后,海报分区也可以建组
update(
&mut conn,
"films",
UpdateCategory {
allow_groups: Some(true),
..Default::default()
},
)
.expect("应能开启");
create_group(
&mut conn,
"films",
NewGroup {
id: "g1".into(),
title: "片单".into(),
},
)
.expect("开启后应能建组");
}
#[test]
fn switching_posters_to_apps_creates_group_and_moves_entries() { let mut conn = db();
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
conn.execute("INSERT INTO entry (key, node_key) VALUES ('f1', 'films')", [])
.expect("应能建条目");
conn.execute(
"INSERT INTO section_block (node_key, title, sort) VALUES ('films', '电影', 1)",
[],
)
.expect("应能建卡片");
update(
&mut conn,
"films",
UpdateCategory {
kind: Some("apps".into()),
allow_groups: Some(true),
..Default::default()
},
)
.expect("应能改类型");
let (entry_node, block_node): (String, String) = conn
.query_row(
"SELECT (SELECT node_key FROM entry WHERE key='f1'),
(SELECT node_key FROM section_block)",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("应能查");
assert_eq!(entry_node, "films-all", "条目应挂到默认分组");
assert_eq!(block_node, "films-all", "首页卡片应指向默认分组");
let groups: i64 = conn
.query_row(
"SELECT COUNT(*) FROM node WHERE kind='group' AND parent_key='films'",
[],
|row| row.get(0),
)
.expect("应能查");
assert_eq!(groups, 1);
}
}
+16 -52
View File
@@ -1,16 +1,14 @@
//! 分组树的管理:多层分组、格式、父级移动与排序。 //! 分组树的管理:多层分组、父级移动与排序。
//! //!
//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`,带 //! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`),
//! `view_mode` 格式),下面的节点都叫分组(`kind='group'`),可以继续嵌套。 //! 下面的节点都叫分组(`kind='group'`),可以继续嵌套。
//! 条目挂在任意节点上,格式由最近的根分组决定。 //! 条目的展示格式由 `entry.format` 决定,分组不再保存格式。
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::Deserialize; use serde::Deserialize;
use crate::{db::entries, db::map_error, error::StashError}; use crate::{db::entries, db::map_error, error::StashError};
const FORMATS: [&str; 3] = ["apps", "posters", "articles"];
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct NewNode { pub struct NewNode {
@@ -20,9 +18,6 @@ pub struct NewNode {
/// 为空表示新建一个根分组 /// 为空表示新建一个根分组
#[serde(default)] #[serde(default)]
pub parent_key: Option<String>, pub parent_key: Option<String>,
/// 根分组的格式:apps / posters / articles(子分组继承,不需要传)
#[serde(default)]
pub format: Option<String>,
/// 图标:可以是 iconfont 名或字符;空字符串表示不显示 /// 图标:可以是 iconfont 名或字符;空字符串表示不显示
#[serde(default)] #[serde(default)]
pub icon: Option<String>, pub icon: Option<String>,
@@ -41,8 +36,6 @@ pub struct UpdateNode {
/// 传入即移动到新的父节点;不支持移回根(根分组请直接新建) /// 传入即移动到新的父节点;不支持移回根(根分组请直接新建)
pub parent_key: Option<String>, pub parent_key: Option<String>,
pub sort: Option<i64>, pub sort: Option<i64>,
/// 只对根分组生效
pub format: Option<String>,
/// 图标:空字符串表示清除图标 /// 图标:空字符串表示清除图标
pub icon: Option<String>, pub icon: Option<String>,
pub enabled: Option<bool>, pub enabled: Option<bool>,
@@ -78,7 +71,7 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
return Err(StashError::Conflict(format!("分组已存在: {key}"))); return Err(StashError::Conflict(format!("分组已存在: {key}")));
} }
let (kind, view_mode) = match new.parent_key.as_deref() { let kind = match new.parent_key.as_deref() {
Some(parent) => { Some(parent) => {
let parent_exists: i64 = conn let parent_exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [parent], |row| { .query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [parent], |row| {
@@ -88,15 +81,9 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
if parent_exists == 0 { if parent_exists == 0 {
return Err(StashError::NotFound(format!("父分组不存在: {parent}"))); return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
} }
("group", None) "group"
}
None => {
let format = new.format.unwrap_or_else(|| "apps".into());
if !FORMATS.contains(&format.as_str()) {
return Err(StashError::InvalidDirectory("格式不合法".into()));
}
("section", Some(format))
} }
None => "section",
}; };
let default_sort: i64 = match new.parent_key.as_deref() { let default_sort: i64 = match new.parent_key.as_deref() {
@@ -126,14 +113,13 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
let tx = conn.transaction().map_err(map_error)?; let tx = conn.transaction().map_err(map_error)?;
tx.execute( tx.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, icon, enabled, sort, show_in_sidebar, allow_groups) "INSERT INTO node (key, parent_key, title, kind, icon, enabled, sort)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, 1)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![ params![
key, key,
new.parent_key, new.parent_key,
title, title,
kind, kind,
view_mode,
icon, icon,
enabled, enabled,
sort sort
@@ -146,9 +132,9 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
} }
pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> { pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> {
let current: Option<(String, Option<String>, Option<String>, i64, Option<String>, bool)> = conn let current: Option<(String, Option<String>, i64, Option<String>, bool)> = conn
.query_row( .query_row(
"SELECT title, parent_key, view_mode, sort, icon, enabled FROM node WHERE key = ?1", "SELECT title, parent_key, sort, icon, enabled FROM node WHERE key = ?1",
[key], [key],
|row| { |row| {
Ok(( Ok((
@@ -157,15 +143,13 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
row.get(2)?, row.get(2)?,
row.get(3)?, row.get(3)?,
row.get(4)?, row.get(4)?,
row.get(5)?,
)) ))
}, },
) )
.optional() .optional()
.map_err(map_error)?; .map_err(map_error)?;
let Some((current_title, current_parent, current_format, current_sort, current_icon, current_enabled)) = let Some((current_title, current_parent, current_sort, current_icon, current_enabled)) = current
current
else { else {
return Err(StashError::NotFound(format!("分组不存在: {key}"))); return Err(StashError::NotFound(format!("分组不存在: {key}")));
}; };
@@ -218,26 +202,6 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
None => current_parent.clone(), None => current_parent.clone(),
}; };
let is_root = parent_key.is_none();
let format = if is_root {
match patch.format {
Some(format) => {
if !FORMATS.contains(&format.as_str()) {
return Err(StashError::InvalidDirectory("格式不合法".into()));
}
Some(format)
}
None => current_format,
}
} else {
// 子分组没有自己的格式,跟随根分组
None
};
if is_root && format.is_none() {
return Err(StashError::InvalidDirectory("根分组缺少格式".into()));
}
let icon = match patch.icon { let icon = match patch.icon {
Some(icon) => { Some(icon) => {
let icon = icon.trim().to_string(); let icon = icon.trim().to_string();
@@ -268,10 +232,10 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
}; };
tx.execute( tx.execute(
"UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4, "UPDATE node SET title = ?1, parent_key = ?2, sort = ?3,
icon = ?5, enabled = ?6 icon = ?4, enabled = ?5
WHERE key = ?7", WHERE key = ?6",
params![title, parent_key, format, final_sort, icon, enabled, key], params![title, parent_key, final_sort, icon, enabled, key],
) )
.map_err(map_error)?; .map_err(map_error)?;
entries::audit(&tx, "node.update", key, &title)?; entries::audit(&tx, "node.update", key, &title)?;
+93 -127
View File
@@ -10,7 +10,6 @@
pub mod auth; pub mod auth;
pub mod cards; pub mod cards;
pub mod categories;
mod catalog; mod catalog;
pub mod entries; pub mod entries;
pub mod groups; pub mod groups;
@@ -34,15 +33,6 @@ pub use settings::{Settings, SettingsDocument};
/// 条目编辑(P2)。 /// 条目编辑(P2)。
pub use entries::{EntryPatch, EntryRecord}; pub use entries::{EntryPatch, EntryRecord};
/// 一次查询得到的完整内容目录。
///
/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。
#[derive(Debug)]
pub struct Catalog {
/// 分类树,结构对应前端 `types.ts` 的 `Category[]`
pub categories: Vec<Value>,
}
/// 内容库句柄。 /// 内容库句柄。
/// ///
/// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来: /// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来:
@@ -107,12 +97,6 @@ impl Db {
}) })
} }
/// 读出完整的内容目录,供 `/api/catalog` 直接序列化。
pub fn load_catalog(&self) -> Result<Catalog, StashError> {
let conn = self.lock()?;
catalog::load(&conn)
}
/// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。 /// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。
/// ///
/// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。 /// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。
@@ -217,41 +201,6 @@ impl Db {
entries::set_like(&conn, key, liked) entries::set_like(&conn, key, liked)
} }
// ---------- 分区(首页卡片 / 侧栏项) ----------
pub fn create_category(&self, new: categories::NewCategory) -> Result<(), StashError> {
let mut conn = self.lock()?;
categories::create(&mut conn, new)
}
pub fn update_category(
&self,
id: &str,
patch: categories::UpdateCategory,
) -> Result<(), StashError> {
let mut conn = self.lock()?;
categories::update(&mut conn, id, patch)
}
pub fn create_group(
&self,
section_id: &str,
new: categories::NewGroup,
) -> Result<(), StashError> {
let mut conn = self.lock()?;
categories::create_group(&mut conn, section_id, new)
}
pub fn update_group(&self, id: &str, patch: categories::UpdateGroup) -> Result<(), StashError> {
let conn = self.lock()?;
categories::update_group(&conn, id, patch)
}
pub fn remove_group(&self, id: &str) -> Result<(), StashError> {
let conn = self.lock()?;
categories::remove_group(&conn, id)
}
// ---------- 原子模型:分组树 / 卡片 ---------- // ---------- 原子模型:分组树 / 卡片 ----------
/// 完整分组树(多层,带条目) /// 完整分组树(多层,带条目)
@@ -303,11 +252,6 @@ impl Db {
let mut conn = self.lock()?; let mut conn = self.lock()?;
cards::set_groups(&mut conn, id, groups) cards::set_groups(&mut conn, id, groups)
} }
pub fn remove_category(&self, id: &str) -> Result<(), StashError> {
let mut conn = self.lock()?;
categories::remove(&mut conn, id)
}
} }
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。 /// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
@@ -363,8 +307,8 @@ mod tests {
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
.expect("应能统计条目"); .expect("应能统计条目");
let blocks: i64 = conn let blocks: i64 = conn
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM card", [], |row| row.get(0))
.expect("应能统计展示块"); .expect("应能统计首页卡片");
let tags: i64 = conn let tags: i64 = conn
.query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0))
.expect("应能统计标签"); .expect("应能统计标签");
@@ -383,27 +327,26 @@ mod tests {
assert_eq!(tags, 48, "标签与元信息共用 entry_tag"); assert_eq!(tags, 48, "标签与元信息共用 entry_tag");
// 正文是一篇一段 Markdown,只有 12 篇文章有 // 正文是一篇一段 Markdown,只有 12 篇文章有
assert_eq!(articles_with_body, 12, "只有图文条目带正文"); assert_eq!(articles_with_body, 12, "只有图文条目带正文");
assert_eq!(blocks, 3, "每个分区一个首页展示块"); assert_eq!(blocks, 3, "每个分区一张首页卡片");
} }
/// 分区与分组是同一张表:分组必须挂在分区下,且不能有 view_mode。 /// 分区与分组是同一张表:分组必须挂在分区下。
#[test] #[test]
fn groups_are_nodes_under_their_section() { fn groups_are_nodes_under_their_section() {
let conn = seeded(); let conn = seeded();
let (parent_key, view_mode): (String, Option<String>) = conn let parent_key: String = conn
.query_row( .query_row(
"SELECT p.key, g.view_mode "SELECT p.key
FROM node g FROM node g
JOIN node p ON p.key = g.parent_key JOIN node p ON p.key = g.parent_key
WHERE g.kind = 'group' AND g.key = 'ui'", WHERE g.kind = 'group' AND g.key = 'ui'",
[], [],
|row| Ok((row.get(0)?, row.get(1)?)), |row| row.get(0),
) )
.expect("应能查到分组及其父节点"); .expect("应能查到分组及其父节点");
assert_eq!(parent_key, "recommend"); assert_eq!(parent_key, "recommend");
assert!(view_mode.is_none(), "分组不应带 view_mode");
// 分区一定是顶层节点 // 分区一定是顶层节点
let root_sections: i64 = conn let root_sections: i64 = conn
@@ -419,92 +362,102 @@ mod tests {
#[test] #[test]
fn catalog_matches_the_frontend_model() { fn catalog_matches_the_frontend_model() {
let conn = seeded(); let conn = seeded();
let catalog = catalog::load(&conn).expect("应能组装内容目录"); let tree = catalog::load_tree(&conn).expect("应能组装分组树");
let categories = &catalog.categories; let roots = tree.as_array().expect("根节点应为数组");
assert_eq!(roots.len(), 3);
assert_eq!(categories.len(), 3); let apps = roots
.iter()
.find(|node| node["key"] == "recommend")
.expect("应有常用推荐");
let groups = apps["children"].as_array().expect("应有子分组");
assert_eq!(groups.len(), 5);
let apps = &categories[0]; let common = groups
assert_eq!(apps["id"], "recommend"); .iter()
assert_eq!(apps["kind"], "apps"); .find(|node| node["key"] == "common")
assert_eq!(apps["barColor"], "#3858f6"); .expect("应有常用站点");
assert_eq!(apps["showInSidebar"], true); let first_app = &common["entries"][0];
assert_eq!(apps["groups"].as_array().expect("应有分组").len(), 5);
// 常用站点只作为首页卡片展示,不进侧栏
assert_eq!(
apps["groups"][0]["showInSidebar"], false,
"常用站点不应出现在侧栏"
);
assert_eq!(apps["groups"][1]["showInSidebar"], true);
let first_app = &apps["groups"][0]["items"][0];
assert_eq!(first_app["name"], "GitHub"); assert_eq!(first_app["name"], "GitHub");
assert_eq!(first_app["format"], "apps");
assert_eq!(first_app["hot"], true); assert_eq!(first_app["hot"], true);
// 计数从 JSON 提到了独立列,这里确认读取时又合并回来了
assert_eq!(first_app["views"], 429); assert_eq!(first_app["views"], 429);
let posters = &categories[1]; let posters = roots
assert_eq!(posters["kind"], "posters"); .iter()
// 海报与图文用 title 而不是 name .find(|node| node["key"] == "movie")
assert_eq!( .expect("应有推荐电影");
posters["items"][0].get("name"), let first_poster = &posters["entries"][0];
None, assert_eq!(first_poster["format"], "posters");
assert!(
first_poster.get("name").is_none(),
"海报不应同时下发 name 字段" "海报不应同时下发 name 字段"
); );
assert!(posters["items"][0]["title"].is_string()); assert!(first_poster["title"].is_string());
// tags 来自 entry_tag 子表
assert_eq!( assert_eq!(
posters["items"][0]["tags"] first_poster["tags"]
.as_array() .as_array()
.expect("海报应有标签") .expect("海报应有标签")
.len(), .len(),
2 2
); );
let articles = &categories[2]; let articles = roots
assert_eq!(articles["kind"], "articles"); .iter()
// 正文是一整段 Markdown,而不是块数组 .find(|node| node["key"] == "ces")
let body = articles["items"][0]["body"] .expect("应有 CES 现场报道");
.as_str() let first_article = &articles["entries"][0];
.expect("图文应有正文"); assert_eq!(first_article["format"], "articles");
let body = first_article["body"].as_str().expect("图文应有正文");
assert!(body.contains("## 设计细节"), "正文应含小标题: {body}"); assert!(body.contains("## 设计细节"), "正文应含小标题: {body}");
assert!(body.contains("!["), "正文应含配图语法: {body}"); assert!(body.contains("!["), "正文应含配图语法: {body}");
assert!(body.contains("\n\n"), "段落之间用空行分隔"); assert!(body.contains("\n\n"), "段落之间用空行分隔");
assert!( assert!(
articles["items"][0].get("blocks").is_none(), first_article.get("blocks").is_none(),
"不应再下发 blocks 数组" "不应再下发 blocks 数组"
); );
// meta 与海报的 tags 共用 entry_tag 表
assert_eq!( assert_eq!(
articles["items"][0]["meta"] first_article["meta"]
.as_array() .as_array()
.expect("图文应有元信息") .expect("图文应有元信息")
.len(), .len(),
2 2
); );
// ---- 首页展示块 ---- // 卡片只记录勾选的分组;常用推荐卡片展示「常用站点」,最多 4 条
// 常用推荐卡片展示「常用站点」分组,最多 4 条 let (group_key, preview_limit): (String, Option<i64>) = conn
assert_eq!(apps["block"]["nodeKey"], "common"); .query_row(
assert_eq!(apps["block"]["previewLimit"], 4); "SELECT cg.group_key, cg.preview_limit
// 电影/文章没有分组,块直接指向分区自己 FROM card c JOIN card_group cg ON cg.card_id = c.id
assert_eq!(posters["block"]["nodeKey"], "movie"); WHERE c.key = 'card-recommend'",
assert_eq!(articles["block"]["nodeKey"], "ces"); [],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("应能查到常用推荐卡片");
assert_eq!(group_key, "common");
assert_eq!(preview_limit, Some(4));
} }
/// 删除分区时,它的展示块一起消失(外键级联),不会留下指向空分区的卡片。 /// 删除分组时,卡片里勾选它的记录一起消失(外键级联)。
#[test] #[test]
fn section_blocks_cascade_with_their_section() { fn card_groups_cascade_with_their_group() {
let conn = seeded(); let conn = seeded();
conn.execute("DELETE FROM node WHERE key = 'recommend' AND kind = 'section'", [])
.expect("应能删除分区"); conn.execute(
"DELETE FROM node WHERE kind = 'group' AND key = 'common' AND parent_key = 'recommend'",
[],
)
.expect("应能删除分组");
let remaining: i64 = conn let remaining: i64 = conn
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0)) .query_row(
.expect("应能统计展示块"); "SELECT COUNT(*) FROM card_group WHERE group_key = 'common'",
[],
|row| row.get(0),
)
.expect("应能统计卡片分组");
assert_eq!(remaining, 2, "三个展示块应随分区删除只剩两个"); assert_eq!(remaining, 0, "卡片里对 common 的勾选应被级联删除");
} }
/// 删除分组节点时,组内条目也要一起删掉。 /// 删除分组节点时,组内条目也要一起删掉。
@@ -534,7 +487,7 @@ mod tests {
fn primary_key_is_id_and_key_is_unique() { fn primary_key_is_id_and_key_is_unique() {
let conn = seeded(); let conn = seeded();
for table in ["node", "entry", "section_block"] { for table in ["node", "entry", "card"] {
let pk: String = conn let pk: String = conn
.query_row( .query_row(
&format!("SELECT name FROM pragma_table_info('{table}') WHERE pk = 1"), &format!("SELECT name FROM pragma_table_info('{table}') WHERE pk = 1"),
@@ -576,21 +529,22 @@ mod tests {
conn.execute("UPDATE node SET key = 'recommend-v2' WHERE key = 'recommend'", []) conn.execute("UPDATE node SET key = 'recommend-v2' WHERE key = 'recommend'", [])
.expect("应能改分区业务键"); .expect("应能改分区业务键");
let catalog = catalog::load(&conn).expect("应能组装内容目录"); let tree = catalog::load_tree(&conn).expect("应能组装分组树");
let apps = catalog let roots = tree.as_array().expect("根节点应为数组");
.categories let apps = roots
.iter() .iter()
.find(|category| category["id"] == "recommend-v2") .find(|node| node["key"] == "recommend-v2")
.expect("应能找到改名后的分区"); .expect("应能找到改名后的分区");
let total: usize = apps["groups"] fn count(node: &serde_json::Value) -> usize {
let own = node["entries"].as_array().map_or(0, |items| items.len());
let children = node["children"]
.as_array() .as_array()
.expect("应有分组") .map_or(0, |items| items.iter().map(count).sum());
.iter() own + children
.map(|group| group["items"].as_array().expect("应有条目").len()) }
.sum();
assert_eq!(total, 18, "改名后条目仍应归在这个分区下"); assert_eq!(count(apps), 18, "改名后条目仍应归在这个分区下");
} }
/// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。 /// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。
@@ -654,7 +608,19 @@ mod tests {
let fresh = Connection::open_in_memory().expect("内存库应能打开"); let fresh = Connection::open_in_memory().expect("内存库应能打开");
fresh fresh
.execute_batch( .execute_batch(
"CREATE TABLE node (key TEXT PRIMARY KEY, parent_key TEXT, title TEXT, kind TEXT, view_mode TEXT); "CREATE TABLE node (
key TEXT PRIMARY KEY,
parent_key TEXT,
title TEXT,
kind TEXT,
view_mode TEXT,
bar_color TEXT,
icon TEXT,
preview_limit INTEGER,
empty_text TEXT,
show_in_sidebar INTEGER NOT NULL DEFAULT 1,
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL); CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL);
CREATE TABLE section_block (id INTEGER PRIMARY KEY, node_key TEXT NOT NULL, title TEXT, preview_limit INTEGER, sort INTEGER NOT NULL DEFAULT 0); CREATE TABLE section_block (id INTEGER PRIMARY KEY, node_key TEXT NOT NULL, title TEXT, preview_limit INTEGER, sort INTEGER NOT NULL DEFAULT 0);
PRAGMA user_version = 15;", PRAGMA user_version = 15;",
+67 -64
View File
@@ -27,9 +27,8 @@ const SEED_JSON: &str = include_str!("seed.json");
/// 种子会自动重新灌一份干净数据。 /// 种子会自动重新灌一份干净数据。
const SCHEMA_SQL: &str = r#" const SCHEMA_SQL: &str = r#"
-- 内容节点:分区与分组共用一张自引用树 -- 内容节点:分区与分组共用一张自引用树
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_code 必须为 NULL -- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_key 必须为 NULL
-- kind = 'group' → 分区下的分组,parent_code 指向所属分区 -- kind = 'group' → 分区下的分组,parent_key 指向分区或上层分组
-- view_mode 只对分区有意义,决定首页与明细页怎么渲染
CREATE TABLE IF NOT EXISTS node ( CREATE TABLE IF NOT EXISTS node (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT) id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT)
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui) key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui)
@@ -38,22 +37,15 @@ CREATE TABLE IF NOT EXISTS node (
title TEXT NOT NULL, -- 展示名:分区标题 / 分组小标题 title TEXT NOT NULL, -- 展示名:分区标题 / 分组小标题
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')), kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
-- 容器层级 -- 容器层级
view_mode TEXT CHECK (view_mode IN ('apps', 'posters', 'articles')), bar_color TEXT, -- 标题左侧色条颜色
-- 分区渲染形态;分组为 NULL icon TEXT, -- 字符图标
bar_color TEXT, -- 分区标题左侧色条颜色(分组为 NULL)
icon TEXT, -- 分区 hero 字符图标(分组为 NULL)
preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认 preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认
empty_text TEXT, -- 分区为空时的提示文案
show_in_sidebar INTEGER NOT NULL DEFAULT 1 CHECK (show_in_sidebar IN (0, 1)),
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
allow_groups INTEGER NOT NULL DEFAULT 0 CHECK (allow_groups IN (0, 1)),
-- 是否允许子分组(与类型无关,后台开关)
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
-- 是否启用(禁用后前台侧栏/卡片不展示) -- 是否启用(禁用后前台侧栏/卡片不展示)
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前 sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
CHECK ( CHECK (
(kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL) (kind = 'section' AND parent_key IS NULL)
OR (kind = 'group' AND parent_key IS NOT NULL AND view_mode IS NULL) OR (kind = 'group' AND parent_key IS NOT NULL)
) )
); );
@@ -95,18 +87,6 @@ CREATE TABLE IF NOT EXISTS entry_tag (
PRIMARY KEY (entry_key, sort) PRIMARY KEY (entry_key, sort)
); );
-- 首页卡片:一行一个卡片,说明展示哪个节点
-- node_key 指向要展示的节点(通常是分组;电影/文章这类无分组的填分区自己)
-- 「属于哪个分区」不单独存:分组看 parent_key,分区就是它自己,查询时推导
CREATE TABLE IF NOT EXISTS section_block (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 要展示的节点业务键
title TEXT, -- 块标题;NULL 时用节点 title
preview_limit INTEGER, -- 卡片里显示几条;NULL 时用前端默认
sort INTEGER NOT NULL DEFAULT 0 -- 卡片顺序
);
-- ========== v16:登录与设置 ========== -- ========== v16:登录与设置 ==========
-- 管理员:单用户场景只允许一行 -- 管理员:单用户场景只允许一行
@@ -226,6 +206,41 @@ const V22_SQL: &str = r#"
INSERT OR IGNORE INTO card (key, title, sort) VALUES ('local-service', '本地服务', -1); INSERT OR IGNORE INTO card (key, title, sort) VALUES ('local-service', '本地服务', -1);
"#; "#;
/// v22 → v23:删除旧分区展示块与 node 上的兼容字段,node 只保留当前模型需要的列。
const V23_SQL: &str = r#"
PRAGMA foreign_keys = OFF;
DROP TABLE IF EXISTS section_block;
CREATE TABLE node_new (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
title TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
bar_color TEXT,
icon TEXT,
preview_limit INTEGER,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
sort INTEGER NOT NULL DEFAULT 0,
CHECK (
(kind = 'section' AND parent_key IS NULL)
OR (kind = 'group' AND parent_key IS NOT NULL)
)
);
INSERT INTO node_new (key, parent_key, title, kind, bar_color, icon, preview_limit, enabled, sort)
SELECT key, parent_key, title, kind, bar_color, icon, preview_limit, enabled, sort
FROM node;
DROP TABLE node;
ALTER TABLE node_new RENAME TO node;
CREATE INDEX IF NOT EXISTS node_parent_idx ON node(parent_key, sort);
PRAGMA foreign_keys = ON;
"#;
/// v20 → v21:格式从分组下沉到条目;老数据按所属根分组的 view_mode 回填。 /// v20 → v21:格式从分组下沉到条目;老数据按所属根分组的 view_mode 回填。
const V21_SQL: &str = r#" const V21_SQL: &str = r#"
ALTER TABLE entry ADD COLUMN format TEXT NOT NULL DEFAULT 'apps'; ALTER TABLE entry ADD COLUMN format TEXT NOT NULL DEFAULT 'apps';
@@ -302,7 +317,7 @@ pub struct SeedReport {
/// ///
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来, /// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。 /// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
pub const SCHEMA_VERSION: i64 = 22; pub const SCHEMA_VERSION: i64 = 23;
fn current_version(conn: &Connection) -> Result<i64, StashError> { fn current_version(conn: &Connection) -> Result<i64, StashError> {
conn.pragma_query_value(None, "user_version", |row| row.get(0)) conn.pragma_query_value(None, "user_version", |row| row.get(0))
@@ -372,6 +387,11 @@ pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
version = 22; version = 22;
} }
if version == 22 {
conn.execute_batch(V23_SQL).map_err(map_error)?;
version = 23;
}
conn.pragma_update(None, "user_version", version) conn.pragma_update(None, "user_version", version)
.map_err(map_error)?; .map_err(map_error)?;
@@ -416,18 +436,14 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
} }
tx.execute( tx.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups, sort) "INSERT INTO node (key, parent_key, title, kind, bar_color, icon, preview_limit, sort)
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6)",
params![ params![
section.key, section.key,
section.title, section.title,
view,
section.bar_color, section.bar_color,
section.icon, section.icon,
section.preview_limit, section.preview_limit,
section.empty_text,
i64::from(section.show_in_sidebar),
i64::from(view == "apps"),
section.sort, section.sort,
], ],
) )
@@ -437,15 +453,9 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
if view == "apps" { if view == "apps" {
for group in &section.groups { for group in &section.groups {
tx.execute( tx.execute(
"INSERT INTO node (key, parent_key, title, kind, show_in_sidebar, sort) "INSERT INTO node (key, parent_key, title, kind, sort)
VALUES (?1, ?2, ?3, 'group', ?4, ?5)", VALUES (?1, ?2, ?3, 'group', ?4)",
params![ params![group.key, section.key, group.title, group.sort],
group.key,
section.key,
group.title,
i64::from(group.show_in_sidebar),
group.sort
],
) )
.map_err(map_error)?; .map_err(map_error)?;
report.groups += 1; report.groups += 1;
@@ -461,17 +471,26 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
} }
} }
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身) // 首页卡片:仓库里直接生成卡片与勾选的分组
tx.execute( tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort) "INSERT INTO card (key, title, sort) VALUES (?1, ?2, ?3)",
VALUES (?1, ?2, ?3, ?4)",
params![ params![
section.block.node_key, format!("card-{}", section.key),
section.block.title, section
section.block.preview_limit, .block
.title
.clone()
.unwrap_or_else(|| section.title.clone()),
section.sort, section.sort,
], ],
) )
.map_err(map_error)?;
let card_id = tx.last_insert_rowid();
tx.execute(
"INSERT INTO card_group (card_id, group_key, sort, preview_limit)
VALUES (?1, ?2, 0, ?3)",
params![card_id, section.block.node_key, section.block.preview_limit],
)
.map_err(|error| { .map_err(|error| {
StashError::Database(format!( StashError::Database(format!(
"分区 {} 的展示节点 {} 无效: {error}", "分区 {} 的展示节点 {} 无效: {error}",
@@ -481,9 +500,6 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
report.blocks += 1; report.blocks += 1;
} }
// 老结构(分区 → 展示块)同步生成卡片,保证新首页立刻有内容
tx.execute_batch(BACKFILL_CARDS_SQL).map_err(map_error)?;
tx.commit().map_err(map_error)?; tx.commit().map_err(map_error)?;
Ok(report) Ok(report)
@@ -565,11 +581,6 @@ struct SeedSection {
sort: i64, sort: i64,
#[serde(default)] #[serde(default)]
preview_limit: Option<i64>, preview_limit: Option<i64>,
#[serde(default)]
empty_text: Option<String>,
/// 是否出现在侧栏;默认 true
#[serde(default = "default_true")]
show_in_sidebar: bool,
/// 仅 apps 分区使用 /// 仅 apps 分区使用
#[serde(default)] #[serde(default)]
groups: Vec<SeedGroup>, groups: Vec<SeedGroup>,
@@ -601,17 +612,9 @@ struct SeedGroup {
key: String, key: String,
title: String, title: String,
sort: i64, sort: i64,
/// 是否出现在侧栏;默认 true
#[serde(default = "default_true")]
show_in_sidebar: bool,
entries: Vec<SeedEntry>, entries: Vec<SeedEntry>,
} }
/// serde 默认值:侧栏默认显示
fn default_true() -> bool {
true
}
/// 一条内容:字段与 `entry` 表的列一一对应。 /// 一条内容:字段与 `entry` 表的列一一对应。
/// ///
/// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta); /// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta);
+310 -59
View File
@@ -13,6 +13,10 @@ pub const SETTINGS_KEY: &str = "site";
pub const MAX_HEADER_ITEMS: usize = 8; pub const MAX_HEADER_ITEMS: usize = 8;
pub const MIN_SPEED: u32 = 8; pub const MIN_SPEED: u32 = 8;
pub const MAX_SPEED: u32 = 120; pub const MAX_SPEED: u32 = 120;
pub const MAX_FONT_LINKS: usize = 20;
/// 内置搜索引擎:有品牌图标,后台只能改名称 / 地址 / 启停,不能删除。
pub const BUILTIN_ENGINE_IDS: [&str; 6] =
["google", "bing", "baidu", "sogou", "360", "duckduckgo"];
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -92,13 +96,6 @@ impl Default for SidebarSettings {
show_profile: true, show_profile: true,
show_service: true, show_service: true,
show_nav: 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 +112,122 @@ pub struct SidebarSettings {
/// 是否显示「分组导航」卡片(首页 / 分组树) /// 是否显示「分组导航」卡片(首页 / 分组树)
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub show_nav: bool, 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)] #[serde(default)]
pub struct SectionsSettings { pub struct BackgroundSettings {
/// 隐藏的首页卡片(分类 id) /// 亮色背景图地址;空表示用内置图
pub hidden: Vec<String>, pub light: String,
/// 卡片顺序(分类 id) /// 暗色背景图地址;空表示用内置图
pub order: Vec<String>, pub dark: String,
/// 标题覆盖:id → 标题 }
pub titles: std::collections::HashMap<String, String>,
/// 预览条数覆盖:id → 条数 /// 正文字体来源:内置霞鹜文楷,或用户存的字体链接。
pub limits: std::collections::HashMap<String, u32>, #[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,
/// 自定义图标地址(png / svg);空表示用内置品牌图标或通用地球
pub icon: 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(),
icon: String::new(),
enabled: true,
},
SearchEngineSettings {
id: "bing".into(),
name: "Bing".into(),
url: "https://www.bing.com/search?q={query}".into(),
icon: String::new(),
enabled: true,
},
SearchEngineSettings {
id: "baidu".into(),
name: "百度".into(),
url: "https://www.baidu.com/s?wd={query}".into(),
icon: String::new(),
enabled: true,
},
SearchEngineSettings {
id: "sogou".into(),
name: "搜狗".into(),
url: "https://www.sogou.com/web?query={query}".into(),
icon: String::new(),
enabled: true,
},
SearchEngineSettings {
id: "360".into(),
name: "360".into(),
url: "https://www.so.com/s?q={query}".into(),
icon: String::new(),
enabled: true,
},
SearchEngineSettings {
id: "duckduckgo".into(),
name: "DuckDuckGo".into(),
url: "https://duckduckgo.com/?q={query}".into(),
icon: String::new(),
enabled: true,
},
],
}
}
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -254,7 +340,9 @@ impl Default for SiteMetaSettings {
pub struct Settings { pub struct Settings {
pub header: HeaderSettings, pub header: HeaderSettings,
pub sidebar: SidebarSettings, pub sidebar: SidebarSettings,
pub sections: SectionsSettings, pub background: BackgroundSettings,
pub font: FontSettings,
pub search: SearchSettings,
pub profile: ProfileSettings, pub profile: ProfileSettings,
pub site: SiteMetaSettings, pub site: SiteMetaSettings,
} }
@@ -266,6 +354,16 @@ pub struct SettingsDocument {
pub settings: Settings, pub settings: Settings,
} }
/// 引擎 id:小写字母 / 数字 / `-` / `_`,1–30 位;`internal` 是站内搜索的保留 id。
fn valid_engine_id(id: &str) -> bool {
let count = id.chars().count();
if count == 0 || count > 30 || id == "internal" {
return false;
}
id.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
/// 校验:条数、长度、速度范围。 /// 校验:条数、长度、速度范围。
pub fn validate(settings: &Settings) -> Result<(), StashError> { pub fn validate(settings: &Settings) -> Result<(), StashError> {
if settings.header.items.is_empty() { if settings.header.items.is_empty() {
@@ -298,33 +396,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 { if settings.profile.name.chars().count() > 40 {
return Err(StashError::InvalidDirectory("名称最多 40 个字".into())); return Err(StashError::InvalidDirectory("名称最多 40 个字".into()));
} }
@@ -337,6 +408,117 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
if settings.profile.location.chars().count() > 40 { if settings.profile.location.chars().count() > 40 {
return Err(StashError::InvalidDirectory("位置最多 40 个字".into())); 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 {
let id = engine.id.trim();
if !valid_engine_id(id) {
return Err(StashError::InvalidDirectory(
"搜索引擎标识只能用小写字母 / 数字 / - / _(1–30 位,internal 为保留字)".into(),
));
}
if !ids.insert(id) {
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 icon = engine.icon.trim();
if icon.chars().count() > 500 {
return Err(StashError::InvalidDirectory("引擎图标地址最多 500 个字符".into()));
}
if !icon.is_empty()
&& !(icon.starts_with("http://")
|| icon.starts_with("https://")
|| icon.starts_with('/'))
{
return Err(StashError::InvalidDirectory(
"引擎图标需为空、/ 开头的托管路径或 http(s):// 链接".into(),
));
}
}
for builtin in BUILTIN_ENGINE_IDS {
if !ids.contains(builtin) {
return Err(StashError::InvalidDirectory(format!(
"内置搜索引擎不能删除(缺少 {builtin})"
)));
}
}
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 { if settings.site.title.chars().count() > 120 {
return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into())); return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into()));
} }
@@ -457,7 +639,6 @@ mod tests {
let settings = Settings::default(); let settings = Settings::default();
assert!(settings.header.enabled); assert!(settings.header.enabled);
assert_eq!(settings.header.items.len(), 3); assert_eq!(settings.header.items.len(), 3);
assert!(settings.sections.hidden.is_empty());
assert!(validate(&settings).is_ok()); assert!(validate(&settings).is_ok());
} }
@@ -485,6 +666,56 @@ mod tests {
assert!(matches!(error, StashError::Conflict(_))); assert!(matches!(error, StashError::Conflict(_)));
} }
#[test]
fn custom_search_engines_use_safe_ids() {
let mut conn = db();
let mut settings = Settings::default();
settings.search.engines.push(SearchEngineSettings {
id: "my-engine_1".into(),
name: "自定义".into(),
url: "https://example.com/search?q={query}".into(),
icon: "/favicon.svg".into(),
enabled: true,
});
assert!(save(&mut conn, settings, 0).is_ok(), "合法自定义 id 应能保存");
for bad_id in ["Bad Id", "internal", "", "引擎"] {
let mut bad = Settings::default();
bad.search.engines.push(SearchEngineSettings {
id: bad_id.into(),
name: "坏".into(),
url: "https://example.com/?q={query}".into(),
icon: String::new(),
enabled: true,
});
assert!(save(&mut conn, bad, 0).is_err(), "{bad_id} 应被拒绝");
}
let mut bad_icon = Settings::default();
bad_icon.search.engines.push(SearchEngineSettings {
id: "custom-x".into(),
name: "坏图标".into(),
url: "https://example.com/?q={query}".into(),
icon: "javascript:alert(1)".into(),
enabled: true,
});
assert!(save(&mut conn, bad_icon, 0).is_err(), "非法图标地址应被拒绝");
}
#[test]
fn builtin_engines_cannot_be_removed() {
let mut conn = db();
let mut missing = Settings::default();
missing.search.engines.retain(|engine| engine.id != "duckduckgo");
assert!(save(&mut conn, missing, 0).is_err(), "缺内置引擎应被拒绝");
// 改名 / 停用不算删除,允许
let mut renamed = Settings::default();
renamed.search.engines[0].name = "改过的名字".into();
renamed.search.engines[0].enabled = false;
assert!(save(&mut conn, renamed, 0).is_ok());
}
#[test] #[test]
fn invalid_settings_are_rejected() { fn invalid_settings_are_rejected() {
let mut conn = db(); let mut conn = db();
@@ -495,6 +726,26 @@ mod tests {
let mut empty = Settings::default(); let mut empty = Settings::default();
empty.header.items.clear(); empty.header.items.clear();
assert!(save(&mut conn, empty, 0).is_err()); 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] #[test]
-11
View File
@@ -7,17 +7,6 @@ pub struct AppProject {
pub route_path: String, 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> { pub fn scan_projects(root: &Path) -> Vec<AppProject> {
let mut projects = Vec::new(); let mut projects = Vec::new();
+9 -1
View File
@@ -3,7 +3,7 @@ use clap::Parser;
use colored::*; use colored::*;
use stash::{ use stash::{
Config, 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, start,
}; };
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 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 data_dir = data_dir.canonicalize().unwrap_or(data_dir);
let db_path = resolve_db_path(&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. 构造配置并启动 // 4. 构造配置并启动
let config = Config { let config = Config {
root: abs_root.clone(), root: abs_root.clone(),
data_dir: data_dir.clone(), data_dir: data_dir.clone(),
font_dir,
db_path: db_path.clone(), db_path: db_path.clone(),
port: cli.port, port: cli.port,
host: cli.host, host: cli.host,
@@ -121,6 +128,7 @@ async fn main() -> anyhow::Result<()> {
println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline()); println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline());
println!(" ➜ 托管路径: {}", display_path(&abs_root).white()); println!(" ➜ 托管路径: {}", display_path(&abs_root).white());
println!(" ➜ 内容库: {}", display_path(&db_path).white()); println!(" ➜ 内容库: {}", display_path(&db_path).white());
println!(" ➜ 字体目录: {}", font_dir_display.white());
println!(" ➜ 前端资源: {}", "已内嵌 (stash-web/dist)".white()); println!(" ➜ 前端资源: {}", "已内嵌 (stash-web/dist)".white());
println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed()); println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed());
println!("{}", "--------------------------------------------------".bright_blue()); println!("{}", "--------------------------------------------------".bright_blue());
+103 -199
View File
@@ -206,31 +206,6 @@ async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
})) }))
} }
/// API: 完整内容目录(分区 / 分组 / 条目 + 首页展示块)
///
/// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。
/// 侧栏由这份数据派生,不再单独下发导航。
async fn catalog_api(State(state): State<AppState>) -> Response {
let db = state.db.clone();
// rusqlite 是同步的,查询扔进阻塞线程池,别占住 async 执行器
match tokio::task::spawn_blocking(move || db.load_catalog()).await {
Ok(Ok(catalog)) => Json(serde_json::json!({
"categories": catalog.categories,
}))
.into_response(),
Ok(Err(error)) => {
// 典型原因是用户手改坏了 JSON,错误信息里已带条目 id
warn!("读取内容库失败: {error}");
error.into_response()
}
Err(error) => {
warn!("内容库查询任务崩溃: {error}");
StashError::Database(format!("内容库查询任务崩溃: {error}")).into_response()
}
}
}
/// SSE:内容库被外部修改时通知前端重新拉取。 /// SSE:内容库被外部修改时通知前端重新拉取。
/// ///
/// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件: /// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件:
@@ -614,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} —— 编辑表单的初始值(公开,便于未登录时预览) /// GET /api/items/{key} —— 编辑表单的初始值(公开,便于未登录时预览)
@@ -964,172 +1013,6 @@ async fn set_card_groups(
} }
} }
/// POST /api/categories —— 新建分区(需要登录)
async fn create_category(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<crate::db::categories::NewCategory>,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.create_category(body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message),
Ok(Err(StashError::InvalidDirectory(message))) => {
api_error(StatusCode::BAD_REQUEST, &message)
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// PATCH /api/categories/{id} —— 修改分区(需要登录)
async fn update_category(
State(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
Json(body): Json<crate::db::categories::UpdateCategory>,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.update_category(&id, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
Ok(Err(StashError::InvalidDirectory(message))) => {
api_error(StatusCode::BAD_REQUEST, &message)
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// POST /api/categories/{id}/groups —— 新建分组(需要登录)
async fn create_group(
State(state): State<AppState>,
Path(section_id): Path<String>,
headers: HeaderMap,
Json(body): Json<crate::db::categories::NewGroup>,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.create_group(&section_id, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message),
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
Ok(Err(StashError::InvalidDirectory(message))) => {
api_error(StatusCode::BAD_REQUEST, &message)
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// PATCH /api/groups/{id} —— 分组改名 / 显隐(需要登录)
async fn update_group(
State(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
Json(body): Json<crate::db::categories::UpdateGroup>,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.update_group(&id, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
Ok(Err(StashError::InvalidDirectory(message))) => {
api_error(StatusCode::BAD_REQUEST, &message)
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// DELETE /api/groups/{id} —— 删除分组(需要登录,组内条目级联删除)
async fn delete_group(
State(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.remove_group(&id)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
Ok(Err(StashError::InvalidDirectory(message))) => {
api_error(StatusCode::BAD_REQUEST, &message)
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// DELETE /api/categories/{id} —— 删除分区(需要登录,级联内容)
async fn delete_category(
State(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.remove_category(&id)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
Ok(Err(StashError::InvalidDirectory(message))) => {
api_error(StatusCode::BAD_REQUEST, &message)
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// 组装路由:API + 内嵌前端 + 托管目录。 /// 组装路由:API + 内嵌前端 + 托管目录。
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、 /// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
/// 起监听任务,没法在单元测试里用。 /// 起监听任务,没法在单元测试里用。
@@ -1148,7 +1031,6 @@ fn build_router(state: AppState) -> Router {
.route("/api/health", get(health_check)) .route("/api/health", get(health_check))
.route("/api/projects", get(list_projects_api)) .route("/api/projects", get(list_projects_api))
.route("/api/projects/events", get(project_events)) .route("/api/projects/events", get(project_events))
.route("/api/catalog", get(catalog_api))
.route("/api/catalog2", get(catalog2_api)) .route("/api/catalog2", get(catalog2_api))
.route("/api/nodes", post(create_node)) .route("/api/nodes", post(create_node))
.route("/api/nodes/{key}", patch(update_node).delete(delete_node)) .route("/api/nodes/{key}", patch(update_node).delete(delete_node))
@@ -1162,17 +1044,12 @@ fn build_router(state: AppState) -> Router {
.route("/api/auth/logout", post(auth_logout)) .route("/api/auth/logout", post(auth_logout))
.route("/api/auth/password", patch(auth_password)) .route("/api/auth/password", patch(auth_password))
.route("/api/settings", get(get_settings).patch(patch_settings)) .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/{key}", get(get_item).patch(patch_item).delete(delete_item))
.route("/api/items", post(create_item)) .route("/api/items", post(create_item))
.route("/api/items/{key}/view", post(post_view)) .route("/api/items/{key}/view", post(post_view))
.route("/api/items/{key}/like", post(post_like)) .route("/api/items/{key}/like", post(post_like))
.route("/api/categories", post(create_category))
.route(
"/api/categories/{id}",
patch(update_category).delete(delete_category),
)
.route("/api/categories/{id}/groups", post(create_group))
.route("/api/groups/{id}", patch(update_group).delete(delete_group))
.route("/api/backup", get(backup_db)) .route("/api/backup", get(backup_db))
.route("/", get(web_index)) .route("/", get(web_index))
.route("/index.html", get(web_index)) .route("/index.html", get(web_index))
@@ -1291,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,响应体)。 /// 发一个 GET,返回(状态码,Content-Type,响应体)。
async fn get(path: &str) -> (StatusCode, String, String) { async fn get(path: &str) -> (StatusCode, String, String) {
let response = router() let response = router()
+11 -3
View File
@@ -4,14 +4,22 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" /> <meta name="color-scheme" content="light dark" />
<!-- 与 Alright 主题一致的正文字体:霞鹜文楷 --> <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 <link
rel="stylesheet" rel="stylesheet"
href="https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/style.min.css" href="https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/style.min.css"
crossorigin="anonymous" crossorigin="anonymous"
/> />
<title>Stash · 本地应用工作区</title> <title>Stash</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -24,7 +24,7 @@
}, },
"../../stash-ui": { "../../stash-ui": {
"name": "@stash/ui", "name": "@stash/ui",
"version": "0.1.0", "version": "0.3.1",
"devDependencies": { "devDependencies": {
"@types/react": "^19.3.0", "@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0", "@types/react-dom": "^19.3.0",
+388 -199
View File
@@ -1,12 +1,26 @@
/* 页面级样式:只保留「组件库没有」的部分(背景、外壳、封面兜底、正文版式)。 /* 页面级样式:只保留「组件库没有」的部分(背景、外壳、封面兜底、正文版式)。
颜色 / 间距 / 字号全部取自 @stash/ui 的令牌。 */ 颜色 / 间距 / 字号全部取自 @stash/ui 的令牌。 */
/* 主题级覆盖:放 :root 而不是 .app,前后台两个壳才能拿到同一套阴影 */ /* 主题级覆盖:放 :root 而不是 .app,前后台两个壳才能拿到同一套描边/阴影。
卡片保留一层很浅的投影;浅色默认 gray-1 几乎看不见,描边提一档。 */
:root { :root {
--su-card-border: var(--su-gray-3);
--su-shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02); --su-shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
--su-shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06); --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 { .app {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -14,14 +28,27 @@
background-color: transparent; background-color: transparent;
} }
/* 顶部背景图:固定 65vh,底部渐隐到页面底色 */ /* 顶部背景:固定 65vh,底部渐隐到页面底色;明暗两层交叉淡入 */
.site-background { .site-background {
position: fixed; position: fixed;
inset: 0 0 auto; inset: 0 0 auto;
z-index: 0; z-index: 0;
height: 65vh; 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; 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 { .site-background::after {
@@ -34,17 +61,20 @@
background: linear-gradient(to bottom, transparent, var(--su-bg-page)); background: linear-gradient(to bottom, transparent, var(--su-bg-page));
} }
/* 这张背景图是浅色渐变图,深色下留着会糊成一层脏灰,直接不显示 */ [data-scheme='dark'] .site-background-light {
[data-scheme='dark'] .site-background { opacity: 0;
display: none; }
[data-scheme='dark'] .site-background-dark {
opacity: 1;
} }
/* ---------- 页头 ---------- */ /* ---------- 页头 ---------- */
.site-header-inner { .site-header-inner {
display: flex; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 720px) minmax(0, 1fr);
align-items: center; align-items: center;
justify-content: space-between;
gap: var(--su-space-4); gap: var(--su-space-4);
width: 100%; width: 100%;
max-width: var(--su-layout-width); max-width: var(--su-layout-width);
@@ -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,这里只管外框 */ /* 后台「首页 Logo」的实时预览:外观交给库里的 Brand,这里只管外框 */
.brand-preview { .brand-preview {
display: flex; display: flex;
@@ -133,14 +183,22 @@
white-space: nowrap; white-space: nowrap;
} }
/* 滚动信息只占中间约 2/3,并靠右贴近操作图标 */ /* 滚动信息居中,左右列等宽 */
.header-ticker-slot {
justify-self: center;
width: 100%;
min-width: 0;
}
.header-ticker { .header-ticker {
max-width: 720px; width: 100%;
margin-left: auto; max-width: none;
margin: 0;
} }
.header-actions { .header-actions {
/* 靠右交给 .header-ticker 的 margin-left: auto;没有滚动信息时由 space-between 兜住 */ justify-self: end;
/* 靠右对齐到页头右边缘 */
flex: none; flex: none;
} }
@@ -334,17 +392,6 @@
margin-top: var(--su-space-4); 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 { .group-child-block {
margin-top: var(--su-space-2); margin-top: var(--su-space-2);
} }
@@ -470,18 +517,6 @@
color: var(--su-color-primary); 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 { .item-cover img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -795,21 +830,6 @@
gap: var(--su-space-2); 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 { .admin-item-row .su-switch {
justify-self: start; justify-self: start;
} }
@@ -817,25 +837,20 @@
/* ---------- 响应式 ---------- */ /* ---------- 响应式 ---------- */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.sidebar { /* 页头:品牌和操作图标按内容宽度,滚动信息吃剩下的空间,
display: grid; 否则中间那列固定抢 720px,品牌文字会被挤没 */
grid-template-columns: repeat(2, minmax(0, 1fr)); .site-header-inner {
gap: var(--su-space-3); grid-template-columns: auto minmax(0, 1fr) auto;
padding: 0 var(--su-space-4) var(--su-space-4);
} }
.profile-card { .profile-card {
grid-row: span 2; grid-row: auto;
} }
.service-panel { .service-panel {
display: none; display: none;
} }
.header-info {
display: none;
}
.search-toolbar { .search-toolbar {
padding: var(--su-space-5) 0 var(--su-space-6); padding: var(--su-space-5) 0 var(--su-space-6);
} }
@@ -845,13 +860,21 @@
} }
} }
@media (max-width: 820px) { @media (max-width: 640px) {
.sidebar { /* 手机页头只留品牌 + 操作图标:跑马灯放不下,会盖到下面的搜索条上 */
.header-ticker-slot {
display: none; display: none;
} }
.site-header-inner { .site-header-inner {
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--su-space-2);
height: 56px; height: 56px;
padding: 0 var(--su-space-3);
}
.header-actions {
gap: var(--su-space-1);
} }
} }
@@ -896,69 +919,20 @@
margin-bottom: var(--su-space-5); 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 最小宽度,网格项必须允许收缩,否则会盖住「新增」按钮 */ /* 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 { .admin-node-title {
flex: 1; flex: 1;
min-width: 0; 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 { .group-toolbar {
display: flex; display: flex;
@@ -1090,14 +1064,6 @@
font-weight: 500; 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);
}
@@ -1184,7 +1150,9 @@ a.entry-href:hover {
} }
/* 左栏吸顶:sticky 由滚动引擎处理,不会像 JS 改 transform 那样落后平滑滚动一帧。 /* 左栏吸顶:sticky 由滚动引擎处理,不会像 JS 改 transform 那样落后平滑滚动一帧。
高度锁在滚动区高度内,栏内只有「分组导航」可滚,资料卡与本地服务保持不动。 */ 高度锁在滚动区高度内,栏内只有「分组导航」可滚,资料卡与本地服务保持不动。
只在两栏布局(>1100)生效;窄屏堆叠交给 Layout 自己处理。 */
@media (min-width: 1101px) {
#page-scroll > aside { #page-scroll > aside {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -1193,6 +1161,17 @@ a.entry-href:hover {
max-height: 100%; max-height: 100%;
overflow: hidden; 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-wrap,
#page-scroll > aside .sidebar-home { #page-scroll > aside .sidebar-home {
@@ -1235,27 +1214,15 @@ a.entry-href:hover {
/* 侧栏到内容区共用一条滚动虚化遮罩,同一水平高度 */ /* 侧栏到内容区共用一条滚动虚化遮罩,同一水平高度 */
.sidebar-fade.is-visible { /* 侧栏卡片保持实底:原来 64% 半透明 + 毛玻璃,同一张卡落在背景图不同亮度的位置
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);
}
.sidebar-home .nav-card { .sidebar-home .nav-card {
overflow: hidden; overflow: hidden;
border: 1px solid var(--su-card-border);
border-radius: var(--su-radius-md); border-radius: var(--su-radius-md);
} background-color: var(--su-bg-card);
box-shadow: var(--su-shadow-card);
.sidebar-divider {
margin: var(--su-space-2) 0;
border-top: 1px dashed var(--su-border-strong);
} }
/* 首页卡片:左侧卡片列表 + 右侧分组表格 */ /* 首页卡片:左侧卡片列表 + 右侧分组表格 */
@@ -1334,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 { .card-state {
@@ -1365,11 +1322,6 @@ a.entry-href:hover {
color: var(--su-color-danger); color: var(--su-color-danger);
} }
.group-state-on {
font-size: var(--su-font-size-xs);
color: var(--su-text-muted);
}
.card-order-index { .card-order-index {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -1388,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 { .admin-content-form {
@@ -1439,15 +1353,6 @@ a.entry-href:hover {
grid-column: 1 / -1; 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 { .admin-item-actions .admin-remove:disabled {
@@ -1479,3 +1384,287 @@ a.entry-href:hover {
color: var(--su-color-danger); color: var(--su-color-danger);
background-color: color-mix(in srgb, var(--su-color-danger) 10%, transparent); 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-actions {
display: flex;
align-items: center;
gap: var(--su-space-2);
}
.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;
}
}
+348 -46
View File
@@ -1,5 +1,9 @@
import { import {
Breadcrumb,
Card,
Drawer,
Empty, Empty,
EngineIcon,
Layout, Layout,
Menu, Menu,
Notice, Notice,
@@ -26,7 +30,10 @@ import { ServicePanel } from './components/sidebar/ServicePanel'
import { useProjects } from './useProjects' import { useProjects } from './useProjects'
import { fetchSession, logout, type SessionState } from './auth' import { fetchSession, logout, type SessionState } from './auth'
import { import {
DEFAULT_SEARCH_ENGINES,
fetchSettings, fetchSettings,
readCachedBackground,
writeCachedBackground,
type SettingsDocument, type SettingsDocument,
type SiteSettings, type SiteSettings,
} from './settings' } from './settings'
@@ -62,6 +69,27 @@ type Route =
| { kind: 'item'; key: string; itemId: string } | { kind: 'item'; key: string; itemId: string }
| { kind: 'api' } | { kind: 'api' }
const INTERNAL_SEARCH = { id: 'internal', name: '站内搜索', url: '', icon: '' } 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 { function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean) const segments = pathname.split('/').filter(Boolean)
if (segments[0] === 'interface') return { kind: 'api' } if (segments[0] === 'interface') return { kind: 'api' }
@@ -181,6 +209,24 @@ function matches(entry: Record<string, unknown>, keyword: string): boolean {
return text.includes(keyword) 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() { export default function AppNext() {
const { status, projects, error, live, refreshing, refresh } = useProjects() const { status, projects, error, live, refreshing, refresh } = useProjects()
const { route, navigate } = useRoute() const { route, navigate } = useRoute()
@@ -189,10 +235,17 @@ export default function AppNext() {
const [keyword, setKeyword] = useState('') const [keyword, setKeyword] = useState('')
const [session, setSession] = useState<SessionState | null>(null) const [session, setSession] = useState<SessionState | null>(null)
const [settings, setSettings] = useState<SettingsDocument | null>(null) const [settings, setSettings] = useState<SettingsDocument | null>(null)
/** 上次缓存的背景:设置回来之前先用它上屏,避免刷新时先闪内置背景 */
const [cachedBackground] = useState(readCachedBackground)
const [loginOpen, setLoginOpen] = useState(false) const [loginOpen, setLoginOpen] = useState(false)
const [editingKey, setEditingKey] = useState<string | null>(null) const [editingKey, setEditingKey] = useState<string | null>(null)
const [scrolled, setScrolled] = useState(false) const [scrolled, setScrolled] = useState(false)
/** 小屏的分组导航抽屉 */
const [navOpen, setNavOpen] = useState(false)
const searchRef = useRef<HTMLInputElement>(null) const searchRef = useRef<HTMLInputElement>(null)
const [searchEngineId, setSearchEngineId] = useState<string | null>(readSearchEngine)
const [engineOpen, setEngineOpen] = useState(false)
const engineRef = useRef<HTMLDivElement>(null)
const load = () => { const load = () => {
void fetchManage().then((data) => { void fetchManage().then((data) => {
@@ -218,6 +271,11 @@ export default function AppNext() {
return () => source.close() return () => source.close()
}, []) }, [])
// 背景地址跟着设置存一份到本机:下次刷新首帧直接用它,不用等接口回来再换
useEffect(() => {
if (settings !== null) writeCachedBackground(settings.settings.background)
}, [settings])
useEffect(() => { useEffect(() => {
const content = document.getElementById('content-scroll') const content = document.getElementById('content-scroll')
const main = content?.parentElement const main = content?.parentElement
@@ -275,15 +333,13 @@ export default function AppNext() {
show_profile: true, show_profile: true,
show_service: true, show_service: true,
show_nav: 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: { profile: {
name: '本地管理员', name: '本地管理员',
role: '管理员', role: '管理员',
@@ -302,19 +358,102 @@ export default function AppNext() {
const keywordLower = keyword.trim().toLowerCase() 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,
icon: engine.icon ?? '',
})),
]
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> 上, // 配色:本机偏好优先于站点默认,写在 <html data-scheme> 上,
// 深色由组件库的令牌块接管,页面与组件都不用改 // 深色由组件库的令牌块接管,页面与组件都不用改
const [themeOverride, setThemeOverride] = useState<ThemeChoice | null>(() => const [themeOverride, setThemeOverride] = useState<ThemeChoice | null>(() =>
readThemeOverride(), readThemeOverride(),
) )
const effectiveTheme = themeOverride ?? site.site.theme const effectiveTheme = themeOverride ?? site.site.theme
useEffect(() => {
document.documentElement.dataset.scheme = effectiveTheme
}, [effectiveTheme])
/** 当前实际是深色吗(auto 时按系统偏好算) */ /** 当前实际是深色吗(auto 时按系统偏好算) */
const isDark = isDarkScheme(effectiveTheme) 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 = () => { const toggleTheme = () => {
@@ -323,6 +462,59 @@ export default function AppNext() {
writeThemeOverride(next) 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(空值回落到内置) // 站点级设置:浏览器标签标题与 favicon(空值回落到内置)
useEffect(() => { useEffect(() => {
const title = site.site.title.trim() const title = site.site.title.trim()
@@ -367,12 +559,35 @@ export default function AppNext() {
? `g:${route.key}` ? `g:${route.key}`
: `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( function renderEntries(
group: GroupNode, group: GroupNode,
entries: Record<string, unknown>[], entries: Record<string, unknown>[],
options: { detail?: boolean; limit?: number } = {}, 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 limit = options.limit ?? filtered.length
const items = filtered.slice(0, limit) const items = filtered.slice(0, limit)
const open = (itemId: string) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(itemId)}`) const open = (itemId: string) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(itemId)}`)
@@ -404,7 +619,7 @@ export default function AppNext() {
const source = rootGroup const source = rootGroup
? (group.entries as unknown as Record<string, unknown>[]) ? (group.entries as unknown as Record<string, unknown>[])
: collectEntries(group) : collectEntries(group)
const all = source.filter((entry) => matches(entry, keywordLower)) const all = source.filter((entry) => matches(entry, localKeyword))
if (all.length === 0) return null if (all.length === 0) return null
return ( return (
<div key={group.key} className="card-group-block"> <div key={group.key} className="card-group-block">
@@ -473,7 +688,7 @@ export default function AppNext() {
{cards.map((card) => {cards.map((card) =>
card.key === 'local-service' ? renderLocalService(card) : renderCard(card), card.key === 'local-service' ? renderLocalService(card) : renderCard(card),
)} )}
{keywordLower !== '' && cards.length > 0 && ( {localKeyword !== '' && cards.length > 0 && (
<Notice title={`搜索结果:${keyword.trim()}`}>在卡片与分组内按标题过滤。</Notice> <Notice title={`搜索结果:${keyword.trim()}`}>在卡片与分组内按标题过滤。</Notice>
)} )}
</div> </div>
@@ -492,23 +707,19 @@ export default function AppNext() {
return ( return (
<div className="detail-view"> <div className="detail-view">
<nav className="detail-breadcrumb group-crumbs"> <Card padding="none" className="detail-nav">
<button type="button" className="link-button" onClick={() => navigate('/')}> <Breadcrumb
首页 className="detail-breadcrumb"
</button> items={[
{trail.map((node) => ( { label: '首页', href: '/' },
<span key={node.key}> ...trail.map((node) => ({
<span className="group-crumb-sep">/</span> label: node.title,
<button href: `/g/${encodeURIComponent(node.key)}`,
type="button" })),
className="link-button" ]}
onClick={() => navigate(`/g/${encodeURIComponent(node.key)}`)} onNavigate={navigate}
> />
{node.title} </Card>
</button>
</span>
))}
</nav>
<div className="detail-hero"> <div className="detail-hero">
<span className="detail-hero-icon" aria-hidden="true"> <span className="detail-hero-icon" aria-hidden="true">
@@ -519,7 +730,9 @@ export default function AppNext() {
</div> </div>
{group.entries.length > 0 ? ( {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 : ( ) : group.children.some((child) => groupEntryCount(child) > 0) ? null : (
<Empty text="这个分组还没有内容" /> <Empty text="这个分组还没有内容" />
)} )}
@@ -528,7 +741,7 @@ export default function AppNext() {
{group.children {group.children
.filter((child) => child.enabled !== false) .filter((child) => child.enabled !== false)
.map((child) => ( .map((child) => (
<section key={child.key} className="group-child-block"> <Card key={child.key} padding="md" className="group-child-block">
<div className="group-title"> <div className="group-title">
<span <span
className="group-bar" className="group-bar"
@@ -551,7 +764,7 @@ export default function AppNext() {
) : ( ) : (
<Empty text="暂无内容" /> <Empty text="暂无内容" />
)} )}
</section> </Card>
))} ))}
</Space> </Space>
</div> </div>
@@ -644,7 +857,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 <Layout
contained contained
@@ -671,6 +901,7 @@ export default function AppNext() {
headerSpeed={site.header.speed} headerSpeed={site.header.speed}
headerGap={site.header.gap} headerGap={site.header.gap}
site={site.site} site={site.site}
ready={settings !== null}
authenticated={session?.authenticated === true} authenticated={session?.authenticated === true}
username={session?.username ?? undefined} username={session?.username ?? undefined}
refreshing={refreshing} refreshing={refreshing}
@@ -685,26 +916,90 @@ export default function AppNext() {
void logout().then(() => setSession({ authenticated: false, has_admin: true })) void logout().then(() => setSession({ authenticated: false, has_admin: true }))
}} }}
onSettings={() => window.location.assign('/admin')} onSettings={() => window.location.assign('/admin')}
onOpenNav={() => setNavOpen(true)}
/> />
} }
toolbar={ toolbar={
<div className="search-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} src={searchEngine.icon} size={14} />
<span>{searchEngine.name}</span>
</button>
<SearchField <SearchField
value={keyword} value={keyword}
onChange={setKeyword} onChange={setKeyword}
inputRef={searchRef} inputRef={searchRef}
placeholder="搜索本地项目…" maxWidth="100%"
label="搜索本地项目" placeholder={
searchEngine.id === 'internal'
? '搜索站内项目…'
: `使用 ${searchEngine.name} 搜索...`
}
label={
searchEngine.id === 'internal'
? '搜索站内项目,回车打开第一个匹配项'
: `使用 ${searchEngine.name} 搜索,回车打开新标签页`
}
variant="card" variant="card"
hotkey="k" 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} src={engine.icon} size={18} />
<span>{engine.name}</span>
</button>
))}
</>
)}
</div>
)}
</div>
</div> </div>
} }
sider={ sider={
<div className="sidebar-wrap"> <div className="sidebar-wrap">
<div className="sidebar sidebar-home"> <div className="sidebar sidebar-home">
{site.sidebar.show_profile !== false && <ProfileCard profile={site.profile} />} {settings !== null && site.sidebar.show_profile !== false && (
{site.sidebar.show_service !== false && ( <ProfileCard profile={site.profile} />
)}
{settings !== null && site.sidebar.show_service !== false && (
<ServicePanel <ServicePanel
projects={projects} projects={projects}
live={live} live={live}
@@ -713,16 +1008,13 @@ export default function AppNext() {
onOpenApi={() => navigate('/interface')} onOpenApi={() => navigate('/interface')}
/> />
)} )}
{site.sidebar.show_nav !== false && ( {settings !== null && !loading && site.sidebar.show_nav !== false && (
<div className="nav-card"> <div className="nav-card">
<Menu <Menu
items={sidebarItems} items={sidebarItems}
activeKey={activeKey} activeKey={activeKey}
itemHeight={39} itemHeight={39}
onSelect={(key) => { onSelect={selectNav}
if (key === 'home') navigate('/')
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
}}
/> />
</div> </div>
)} )}
@@ -751,6 +1043,16 @@ export default function AppNext() {
</Layout> </Layout>
<BackToTop visible={scrolled} targetId="page-scroll" /> <BackToTop visible={scrolled} targetId="page-scroll" />
<Drawer
open={navOpen}
onClose={() => setNavOpen(false)}
title="分组导航"
placement="left"
width={300}
>
<Menu items={sidebarItems} activeKey={activeKey} itemHeight={44} onSelect={selectNav} />
</Drawer>
</> </>
) )
} }
+19 -63
View File
@@ -1,5 +1,3 @@
import type { Category } from './data/types'
export type Project = { export type Project = {
name: string name: string
route_path: string route_path: string
@@ -26,12 +24,30 @@ export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
return Array.isArray(payload.projects) ? payload.projects : [] return Array.isArray(payload.projects) ? payload.projects : []
} }
export type ProjectEventsHandlers = { type ProjectEventsHandlers = {
onProjects: (projects: Project[]) => void onProjects: (projects: Project[]) => void
/** SSE 连接建立 / 断开时回调,浏览器的 EventSource 会自行重连。 */ /** SSE 连接建立 / 断开时回调,浏览器的 EventSource 会自行重连。 */
onConnectionChange?: (connected: boolean) => void 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 时静默降级为空清理函数,页面仍可用手动刷新。 * 浏览器不支持 SSE 时静默降级为空清理函数,页面仍可用手动刷新。
@@ -68,63 +84,3 @@ export function subscribeProjects(handlers: ProjectEventsHandlers): () => void {
source.close() 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

+395 -8
View File
@@ -4,6 +4,7 @@ import {
Card, Card,
Caret, Caret,
Empty, Empty,
EngineIcon,
FormField, FormField,
Icon, Icon,
ICON_NAMES, ICON_NAMES,
@@ -36,10 +37,16 @@ import { LoginDialog } from '../auth/LoginDialog'
import { GroupMark as GroupGlyph } from '../common/GroupMark' import { GroupMark as GroupGlyph } from '../common/GroupMark'
import { isDarkScheme, readThemeOverride, writeThemeOverride } from '../../theme' import { isDarkScheme, readThemeOverride, writeThemeOverride } from '../../theme'
import { import {
BUILTIN_SEARCH_ENGINE_IDS,
DEFAULT_SEARCH_ENGINES,
fetchSettings, fetchSettings,
saveSettings, saveSettings,
TONE_OPTIONS, TONE_OPTIONS,
type BackgroundSettings,
type FontLink,
type FontSource,
type ProfileSettings, type ProfileSettings,
type SearchSettings,
type SettingsDocument, type SettingsDocument,
type SettingsTone, type SettingsTone,
type SiteSettings, type SiteSettings,
@@ -62,12 +69,14 @@ import {
} from '../../manage' } from '../../manage'
import { createItem, deleteItem, fetchItem, saveItem } from '../../items' import { createItem, deleteItem, fetchItem, saveItem } from '../../items'
import { useProjects } from '../../useProjects' 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-sidebar'
| 'home-cards' | 'home-cards'
| 'home-header' | 'home-header'
| 'home-background'
| 'home-search'
| 'profile' | 'profile'
| 'system' | 'system'
@@ -89,6 +100,8 @@ const ADMIN_NAV: MenuItem[] = [
label: '首页管理', label: '首页管理',
children: [ children: [
{ key: 'home-header', label: '页头' }, { key: 'home-header', label: '页头' },
{ key: 'home-background', label: '背景管理' },
{ key: 'home-search', label: '搜索框' },
{ key: 'home-sidebar', label: '侧边栏' }, { key: 'home-sidebar', label: '侧边栏' },
{ key: 'home-cards', label: '内容卡片' }, { key: 'home-cards', label: '内容卡片' },
], ],
@@ -264,10 +277,13 @@ export function AdminNext() {
<div className="site-header-inner"> <div className="site-header-inner">
<Brand href="/" name={brandName} mark={brandMark} logo={brandLogo} /> <Brand href="/" name={brandName} mark={brandMark} logo={brandLogo} />
{/* 占住前台的中间列,让右侧操作和前台的右边缘对齐;后台没有滚动信息 */}
<div className="header-ticker-slot" aria-hidden="true" />
<div className="header-actions"> <div className="header-actions">
<IconButton <IconButton
label={isDark ? '切换到浅色' : '切换到深色'} label={isDark ? '切换到浅色' : '切换到深色'}
icon={<Icon name={isDark ? 'sun_line' : 'moon_line'} />} icon={<Icon name={isDark ? 'moon_line' : 'sun_line'} />}
onClick={() => void toggleTheme()} onClick={() => void toggleTheme()}
disabled={!authenticated || saving} disabled={!authenticated || saving}
/> />
@@ -376,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' && ( {tab === 'profile' && (
<ProfilePanel <ProfilePanel
value={draft.profile} value={draft.profile}
@@ -383,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"> <Space gap="sm" justify="end" className="admin-actions">
<Button <Button
variant="secondary" variant="secondary"
@@ -572,7 +611,7 @@ function GroupsPanel({
return ( return (
<Space direction="column" gap="md" align="stretch" className="admin-form"> <Space direction="column" gap="md" align="stretch" className="admin-form">
<Text size="sm" tone="muted"> <Text size="sm" tone="muted">
说明:分组用于控制前台侧边栏与卡片;可多层嵌套,根分组决定格式。内容(条目)只挂在分组上。 说明:分组用于控制前台侧边栏与卡片,可多层嵌套。内容(条目)只挂在分组上,格式由每条内容自己决定。
</Text> </Text>
<div className="group-toolbar"> <div className="group-toolbar">
@@ -590,7 +629,7 @@ function GroupsPanel({
columns={[ columns={[
{ key: 'name', title: '分组名称' }, { key: 'name', title: '分组名称' },
{ key: 'sort', title: '排序权重', width: 110 }, { key: 'sort', title: '排序权重', width: 110 },
{ key: 'count', title: '关联网页数', width: 120 }, { key: 'count', title: '关联内容数', width: 120 },
{ key: 'status', title: '状态', width: 90 }, { key: 'status', title: '状态', width: 90 },
{ key: 'actions', title: '操作', width: 260, align: 'right' }, { key: 'actions', title: '操作', width: 260, align: 'right' },
]} ]}
@@ -2034,6 +2073,213 @@ 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 })
}
/** 自定义引擎:id 只是内部键,点「添加」自动生成,后台不用手填 */
const addEngine = () => {
if (value.engines.length >= 10) return
onChange({
...value,
engines: [
...value.engines,
{
id: `custom-${Date.now().toString(36)}`,
name: '新搜索引擎',
url: '',
icon: '',
enabled: true,
},
],
})
}
const removeEngine = (id: string) => {
// 内置引擎只能停用,不能删
if (BUILTIN_SEARCH_ENGINE_IDS.some((builtin) => builtin === id)) return
const engines = value.engines.filter((engine) => engine.id !== id)
onChange({
...value,
engines,
default: value.default === id ? 'internal' : value.default,
})
}
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">
说明:站内搜索始终可用;外部搜索引擎可以改名称、地址和启停,也可以自己加(最多 10 个),
地址里的 {'{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} src={engine.icon} size={18} />
<Text strong>{engine.name || engine.id}</Text>
<div className="search-engine-actions">
<Switch
checked={engine.enabled}
label={`启用 ${engine.name || engine.id}`}
onChange={(enabled) => updateEngine(engine.id, { enabled })}
/>
{!BUILTIN_SEARCH_ENGINE_IDS.some((builtin) => builtin === engine.id) && (
<TextButton onClick={() => removeEngine(engine.id)}>删除</TextButton>
)}
</div>
</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>
<FormField
label="图标地址"
htmlFor={`search-icon-${engine.id}`}
hint="png / svg;留空用默认图标(内置品牌或通用地球),站内图片放 public/ 后填 /xxx.svg"
>
<Input
id={`search-icon-${engine.id}`}
value={engine.icon ?? ''}
placeholder="/icons/xxx.svg 或 https://…"
onChange={(event) => updateEngine(engine.id, { icon: event.target.value })}
/>
</FormField>
</div>
))}
</div>
<div>
<Space gap="sm" wrap>
<Button variant="secondary" onClick={addEngine} disabled={value.engines.length >= 10}>
添加搜索引擎
</Button>
<Button
variant="secondary"
onClick={() =>
onChange({
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
})
}
>
恢复默认引擎
</Button>
</Space>
</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({ function ProfilePanel({
value, value,
onChange, onChange,
@@ -2084,11 +2330,23 @@ function ProfilePanel({
) )
} }
function SystemPanel() { function SystemPanel({
font,
onChangeFont,
}: {
font: SiteSettings['font']
onChangeFont: (font: SiteSettings['font']) => void
}) {
const [current, setCurrent] = useState('') const [current, setCurrent] = useState('')
const [next, setNext] = useState('') const [next, setNext] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<{ tone: 'info' | 'error'; text: string } | null>(null) 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 () => { const change = async () => {
setBusy(true) setBusy(true)
@@ -2104,8 +2362,137 @@ function SystemPanel() {
setNext('') 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 ( return (
<Space direction="column" gap="lg" align="stretch" className="admin-form"> <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"> <FormField label="当前密码" htmlFor="current-password">
<PasswordInput <PasswordInput
id="current-password" id="current-password"
+5 -12
View File
@@ -26,14 +26,13 @@ const GROUPS: { title: string; desc: string; items: Endpoint[] }[] = [
}, },
{ {
title: '内容库', title: '内容库',
desc: '分区 / 分组 / 条目 / 卡片。写操作都需要登录会话与同源 Origin。', desc: '分组树 / 条目 / 卡片。写操作都需要登录会话与同源 Origin。',
items: [ 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: '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: '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: 'GET', path: '/api/items/{key}', summary: '条目详情' },
{ method: 'POST', path: '/api/items', summary: '新建条目', auth: true }, { method: 'POST', path: '/api/items', summary: '新建条目', auth: true },
{ method: 'PATCH', path: '/api/items/{key}', summary: '就地编辑(带 updatedAt 乐观锁)', 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: 'PATCH', path: '/api/cards/{id}', summary: '改卡片标题 / 顺序', auth: true },
{ method: 'DELETE', 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: '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> <Title level={2}>接口一览</Title>
<Text size="sm" tone="muted"> <Text size="sm" tone="muted">
说明:这是 Stash 自带的 HTTP 接口清单,全部挂在同一个端口上。读接口公开,写接口需要登录会话并校验同源 说明:这是 Stash 自带的 HTTP 接口清单,全部挂在同一个端口上。读接口公开,写接口需要登录会话并校验同源
Origin;带 <Tag tone="primary">需登录</Tag> 标记的即为写操作。 Origin;带 <Tag tone="primary">需登录</Tag> 标记的需要登录。
</Text> </Text>
</Space> </Space>
@@ -1,13 +0,0 @@
import { Badge } from '@stash/ui'
/**
* 数量角标:贴在分区标题旁边的小红标。
* 组件库的 Badge 已经处理了两位数和 99+ 的宽度,这里只做语义封装。
*/
type CountBadgeProps = {
count: number
}
export function CountBadge({ count }: CountBadgeProps) {
return <Badge count={count} tone="danger" />
}
-57
View File
@@ -1,57 +0,0 @@
import { Icon } from '@stash/ui'
/**
* 语义图标:stash 内部一直按用途引用(SearchIcon / RefreshIcon…),
* 这里统一映射到 @stash/ui 的 iconfont 字形,避免各页面记字形名。
*/
type IconProps = {
className?: string
}
export function SearchIcon({ className }: IconProps) {
return <Icon name="search_3_line" className={className} />
}
export function UserIcon({ className }: IconProps) {
return <Icon name="user_3_line" className={className} />
}
export function RefreshIcon({ className }: IconProps) {
return <Icon name="refresh_3_line" className={className} />
}
export function LinkIcon({ className }: IconProps) {
return <Icon name="link_3_line" className={className} />
}
export function ExternalIcon({ className }: IconProps) {
return <Icon name="a-external_link_line1" className={className} />
}
export function EyeIcon({ className }: IconProps) {
return <Icon name="eye_line" className={className} />
}
export function ThumbUpIcon({ className }: IconProps) {
return <Icon name="thumb_up_2_line" className={className} />
}
export function ShareIcon({ className }: IconProps) {
return <Icon name="share_3_line" className={className} />
}
export function CloseIcon({ className }: IconProps) {
return <Icon name="close_line" className={className} />
}
export function MoreIcon({ className }: IconProps) {
return <Icon name="more_1_line" className={className} />
}
export function ArrowUpIcon({ className }: IconProps) {
return <Icon name="arrow_up_line" className={className} />
}
export function ArrowDownIcon({ className }: IconProps) {
return <Icon name="arrow_down_line" className={className} />
}
@@ -1,23 +0,0 @@
import { Cover } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
/**
* 首页图文卡:4:3 封面 + 底部渐变条上的标题。
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
*/
type ArticleCardProps = {
item: ArticleItem
onOpen?: (itemId: string) => void
}
export function ArticleCard({ item, onOpen }: ArticleCardProps) {
return (
<Cover
src={item.cover}
alt={item.title}
caption={item.title}
ratio="article"
onClick={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
)
}
@@ -1,20 +0,0 @@
import { Grid } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
import { ArticleCard } from './ArticleCard'
/** 图文网格:桌面 4 列,平板 2 列,手机 1 列 */
type ArticleGridProps = {
items: ArticleItem[]
/** 点击卡片打开文章详情 */
onOpen?: (itemId: string) => void
}
export function ArticleGrid({ items, onOpen }: ArticleGridProps) {
return (
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="md">
{items.map((item) => (
<ArticleCard key={item.id} item={item} onOpen={onOpen} />
))}
</Grid>
)
}
@@ -1,138 +0,0 @@
import { Breadcrumb, Card, Empty, Space, Title } from '@stash/ui'
import {
categoryCount,
type AppItem,
type ArticleItem,
type Category,
type PosterItem,
} from '../../data/types'
import { viewPath } from '../../data/view'
import { AppGrid } from './AppGrid'
import { CesCardGrid } from './CesCardGrid'
import { GroupBlock } from './GroupBlock'
import { PosterGrid } from './PosterGrid'
/**
* 分类明细视图:面包屑 + hero(图标 / 标题 / 数量)+ 内容区。
*
* 内容区按分类类型切换:
* - apps:指定分组时只渲染该组,否则单组扁平渲染、多组带小标题分组渲染;
* - posters:6 列海报;
* - articles:上图下文白卡网格。
*/
type DetailViewProps = {
category: Category
/** 从侧栏子菜单进入时指定的分组 */
groupId?: string
/** 点击面包屑中间项(SPA 路由) */
onNavigate: (href: string) => void
/** 点击卡片 / 海报进入条目详情 */
onOpenItem: (itemId: string) => void
}
export function DetailView({ category, groupId, onNavigate, onOpenItem }: DetailViewProps) {
// 三种类型共用「分区 → 分组 → 条目」结构;这里抹平联合类型,渲染时再按 kind 分派
type RenderItem = AppItem | PosterItem | ArticleItem
const groups = category.groups as {
id: string
name: string
items: RenderItem[]
}[]
const activeGroup =
groupId !== undefined ? groups.find((group) => group.id === groupId) : undefined
const title = activeGroup?.name ?? category.title
const count = activeGroup !== undefined ? activeGroup.items.length : categoryCount(category)
const crumbs =
activeGroup !== undefined
? [
{ label: '首页', href: '/' },
{ label: category.title, href: viewPath({ type: 'detail', categoryId: category.id }) },
{ label: activeGroup.name },
]
: [
{ label: '首页', href: '/' },
{ label: category.title, href: viewPath({ type: 'detail', categoryId: category.id }) },
]
/** 按类型渲染一组条目 */
function renderItems(items: RenderItem[]) {
if (category.kind === 'posters') {
return <PosterGrid items={items as PosterItem[]} variant="detail" onOpen={onOpenItem} />
}
if (category.kind === 'articles') {
return <CesCardGrid items={items as ArticleItem[]} onOpen={onOpenItem} />
}
return <AppGrid items={items as AppItem[]} variant="detail" onOpen={onOpenItem} />
}
function renderBody() {
if (activeGroup !== undefined) {
return renderItems(activeGroup.items)
}
const direct = (category.items ?? []) as RenderItem[]
// 空分组不占篇幅(管理后台仍然保留,之后可以往里加条目)
const filledGroups = groups.filter((group) => group.items.length > 0)
if (filledGroups.length === 0) {
return direct.length > 0 ? (
renderItems(direct)
) : (
<Empty text={'emptyText' in category ? (category.emptyText ?? '暂无内容') : '暂无内容'} />
)
}
if (filledGroups.length === 1 && direct.length === 0) {
return renderItems(filledGroups[0].items)
}
return (
<Space direction="column" gap="lg" align="stretch">
{filledGroups.map((group) => (
<GroupBlock
key={group.id}
name={group.name}
barColor={category.barColor !== '' ? category.barColor : undefined}
>
{renderItems(group.items)}
</GroupBlock>
))}
{direct.length > 0 && (
<GroupBlock
name="未分组"
barColor={category.barColor !== '' ? category.barColor : undefined}
>
{renderItems(direct)}
</GroupBlock>
)}
</Space>
)
}
return (
<div className="detail-view">
<Card padding="none" className="detail-nav">
<Breadcrumb
className="detail-breadcrumb"
items={crumbs}
onNavigate={onNavigate}
/>
</Card>
<div className="detail-hero">
<span className="detail-hero-icon" aria-hidden="true">
{category.icon}
</span>
<Title level={1} as="h2" weight="strong" className="detail-hero-title">
{title}
</Title>
<span className="detail-hero-count">[{count}]</span>
</div>
{renderBody()}
</div>
)
}
@@ -1,33 +0,0 @@
import { Space, Title } from '@stash/ui'
import type { ReactNode } from 'react'
/**
* 明细页的分组块:色条 + 小标题 + 该组的内容网格。
*
* 色条沿用分区的 `barColor`,与首页卡片标题保持同一套视觉语言;
* 具体网格由调用方传入(应用 / 海报 / 图文共用同一层外壳)。
*/
type GroupBlockProps = {
name: string
/** 分区色条颜色;缺省用主题主色 */
barColor?: string
children: ReactNode
}
export function GroupBlock({ name, barColor, children }: GroupBlockProps) {
return (
<Space direction="column" gap="sm" align="stretch" className="group-block">
<div className="group-title">
<span
className="group-bar"
style={barColor !== undefined ? { backgroundColor: barColor } : undefined}
aria-hidden="true"
/>
<Title level={4} as="h3">
{name}
</Title>
</div>
{children}
</Space>
)
}
@@ -1,118 +0,0 @@
import { categoryCount, flattenAppItems, type Category } from '../../data/types'
import { AppGrid } from './AppGrid'
import { ArticleGrid } from './ArticleGrid'
import { Empty } from '@stash/ui'
import { PosterGrid } from './PosterGrid'
import { SectionCard } from './SectionCard'
import {
applySectionSettings,
sectionLimit,
sectionTitle,
type SectionSettings,
} from '../../settings'
/**
* 首页:把每个分类渲染成一张分区卡片。
*
* 每个分区只展示前若干条;标题、条数、顺序与显隐都可以在 `/admin`
* 的「首页卡片」里覆盖(见 settings.sections)。
*/
type HomeViewProps = {
categories: Category[]
sections: SectionSettings
onOpenDetail: (categoryId: string) => void
onOpenItem: (categoryId: string, itemId: string) => void
}
export function HomeView({ categories, sections, onOpenDetail, onOpenItem }: HomeViewProps) {
const cards = applySectionSettings(categories, sections)
return (
<div className="home-view">
{cards.map((category) => (
<SectionCard
key={category.id}
// 卡片标题跟着实际展示的内容走:配了展示块就用块标题(如「常用站点」),
// 没配(如「本地服务」)才用分类标题;设置里的覆盖值优先
title={sectionTitle(
category.id,
category.block?.title ?? category.title,
sections,
)}
count={cardCount(category)}
barColor={category.barColor}
onMore={() => onOpenDetail(category.id)}
>
{renderPreview(category, sections, onOpenItem)}
</SectionCard>
))}
</div>
)
}
/**
* 卡片右上角的数量角标。
*
* 配了「分组展示块」时,卡片实际展示的是那个分组,所以数量也应该只数它;
* 否则(电影 / 文章 / 本地服务)数整个分区。
*/
function cardCount(category: Category): number {
if (category.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) return group.items.length
}
return categoryCount(category)
}
/** 某个分区配的「首页预览分组」;没配或找不到返回 undefined */
function blockGroup<T>(category: { block?: { nodeKey?: string }; groups: { id: string; items: T[] }[] }) {
if (category.block?.nodeKey === undefined) return undefined
return category.groups.find((group) => group.id === category.block?.nodeKey)
}
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
function renderPreview(
category: Category,
sections: SectionSettings,
onOpenItem: (categoryId: string, itemId: string) => void,
) {
if (category.kind === 'apps') {
// 配了展示块且指向分组时只展示该组;展示块指向分区本身(「整个分区」)时展示全部条目
const group = blockGroup(category)
const source = group !== undefined ? group.items : flattenAppItems(category)
const fallback = category.block?.previewLimit ?? category.previewLimit ?? 4
const limit = sectionLimit(category.id, fallback, sections)
const items = source.slice(0, limit)
if (items.length === 0) {
return <Empty text={category.emptyText ?? '暂无内容'} />
}
return <AppGrid items={items} onOpen={(itemId) => onOpenItem(category.id, itemId)} />
}
if (category.kind === 'posters') {
const group = blockGroup(category)
const source = group
? group.items
: [...category.items, ...category.groups.flatMap((item) => item.items)]
return (
<PosterGrid
items={source.slice(0, sectionLimit(category.id, category.previewLimit ?? 6, sections))}
onOpen={(itemId) => onOpenItem(category.id, itemId)}
/>
)
}
const group = blockGroup(category)
const source = group
? group.items
: [...category.items, ...category.groups.flatMap((item) => item.items)]
return (
<ArticleGrid
items={source.slice(0, sectionLimit(category.id, category.previewLimit ?? 4, sections))}
onOpen={(itemId) => onOpenItem(category.id, itemId)}
/>
)
}
+27 -3
View File
@@ -25,6 +25,8 @@ type SiteHeaderProps = {
headerGap?: number headerGap?: number
/** 品牌与操作工具配置 */ /** 品牌与操作工具配置 */
site: SiteMetaSettings site: SiteMetaSettings
/** 站点设置是否已加载;未加载时不渲染依赖配置的图标,避免先显示再消失 */
ready?: boolean
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */ /** 是否已登录:决定右侧是「登录」还是「退出登录」 */
authenticated: boolean authenticated: boolean
username?: string username?: string
@@ -37,6 +39,8 @@ type SiteHeaderProps = {
onLogin: () => void onLogin: () => void
onLogout: () => void onLogout: () => void
onSettings: () => void onSettings: () => void
/** 小屏打开分组导航抽屉;不传则不显示菜单按钮 */
onOpenNav?: () => void
} }
const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = { const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = {
@@ -55,6 +59,7 @@ export function SiteHeader({
headerSpeed, headerSpeed,
headerGap, headerGap,
site, site,
ready = true,
authenticated, authenticated,
username, username,
refreshing = false, refreshing = false,
@@ -64,6 +69,7 @@ export function SiteHeader({
onLogin, onLogin,
onLogout, onLogout,
onSettings, onSettings,
onOpenNav,
}: SiteHeaderProps) { }: SiteHeaderProps) {
const context = { count: projectCount, project: sampleProject, live } const context = { count: projectCount, project: sampleProject, live }
@@ -76,7 +82,8 @@ export function SiteHeader({
logo={site.brand.logo} logo={site.brand.logo}
/> />
{headerEnabled && ( <div className="header-ticker-slot">
{ready && headerEnabled && (
<Ticker speed={headerSpeed} gap={headerGap} label="运行状态" className="header-ticker"> <Ticker speed={headerSpeed} gap={headerGap} label="运行状态" className="header-ticker">
{headerItems.map((item, index) => ( {headerItems.map((item, index) => (
<span className="header-ticker-item" key={`${index}-${item.text}`}> <span className="header-ticker-item" key={`${index}-${item.text}`}>
@@ -90,12 +97,23 @@ export function SiteHeader({
))} ))}
</Ticker> </Ticker>
)} )}
</div>
<div className="header-actions"> <div className="header-actions">
{onOpenNav !== undefined && (
<IconButton
className="header-nav-toggle"
label="分组导航"
icon={<Icon name="menu_line" />}
onClick={onOpenNav}
/>
)}
{ready && (
<>
{site.actions.theme && ( {site.actions.theme && (
<IconButton <IconButton
label={dark ? '切换到浅色' : '切换到深色'} label={dark ? '切换到浅色' : '切换到深色'}
icon={<Icon name={dark ? 'sun_line' : 'moon_line'} />} icon={<Icon name={dark ? 'moon_line' : 'sun_line'} />}
onClick={onToggleTheme} onClick={onToggleTheme}
/> />
)} )}
@@ -108,7 +126,11 @@ export function SiteHeader({
/> />
)} )}
{site.actions.settings && authenticated && ( {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 && {site.actions.session &&
(authenticated ? ( (authenticated ? (
@@ -120,6 +142,8 @@ export function SiteHeader({
) : ( ) : (
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} /> <IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
))} ))}
</>
)}
</div> </div>
</div> </div>
) )
@@ -64,19 +64,23 @@ export function ServicePanel({
重新扫描 重新扫描
</Button> </Button>
<a <Button
className="link-button" size="sm"
href="/interface" variant="secondary"
className="service-action"
icon={<Icon name="link_3_line" />}
onClick={(event) => { onClick={(event) => {
// 无修饰键时接管点击,走站内路由,只换右侧内容;按住修饰键仍可新标签打开 // 无修饰键时走站内路由;按住修饰键仍可新标签打开接口页
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
event.preventDefault() window.open('/interface', '_blank', 'noopener')
return
}
onOpenApi() onOpenApi()
}} }}
title="查看接口一览"
> >
<Icon name="link_3_line" />
查看接口 查看接口
</a> </Button>
</div> </div>
</Card> </Card>
) )
-49
View File
@@ -1,49 +0,0 @@
import type { Category } from './types'
/**
* 按关键字过滤分类内容。
*
* 应用类分类会逐组过滤并丢掉空组,海报 / 图文类按标题或描述匹配;
* 空关键字直接原样返回,避免无谓的对象重建。
*/
export function filterCategories(categories: Category[], keyword: string): Category[] {
const query = keyword.trim().toLowerCase()
if (query === '') return categories
const result: Category[] = []
for (const category of categories) {
if (category.kind === 'apps') {
const groups = category.groups
.map((group) => ({
...group,
items: group.items.filter((item) => matches(query, item.name, item.desc)),
}))
.filter((group) => group.items.length > 0)
if (groups.length > 0) {
result.push({ ...category, groups })
}
continue
}
if (category.kind === 'posters') {
const items = category.items.filter((item) => matches(query, item.title))
if (items.length > 0) {
result.push({ ...category, items })
}
continue
}
const items = category.items.filter((item) => matches(query, item.title, item.desc))
if (items.length > 0) {
result.push({ ...category, items })
}
}
return result
}
function matches(query: string, ...fields: string[]): boolean {
return fields.some((field) => field.toLowerCase().includes(query))
}
-63
View File
@@ -1,63 +0,0 @@
import type { AppCategory, AppItem, Category } from './types'
/**
* 「本地服务」分类的组装逻辑。
*
* 这一块不来自内容库:托管目录里有哪些项目是由文件系统决定的,
* 后端每次扫描后通过 `/api/projects` 推送,所以本地分类始终在前端拼出来,
* 内容库只负责其余的分类(常用推荐 / 推荐电影 / CES 现场报道)。
*/
/** 后端 `/api/projects` 返回的项目结构 */
export type HostedProject = {
name: string
route_path: string
}
/**
* 把托管项目映射成应用条目。
*
* id 用项目名(就是目录名):它在前端各处(首页卡片、明细、详情)唯一,
* 又正好是可以直接放进地址栏的一段——条目详情地址是 `/c/local/<分组>/i/<项目名>`。
* 访问地址仍然用 `route_path`(`/demo/`),两者不再混用。
* 本地项目没有封面图,详情页会退化成字符头像。
*/
function toLocalItem(project: HostedProject): AppItem {
return {
id: project.name,
name: project.name,
desc: project.route_path,
icon: project.name.slice(0, 1).toUpperCase(),
href: project.route_path,
comment: '本地静态站点,由 Stash 直接托管。',
badge: '本地项目',
views: 0,
likes: 0,
}
}
/** 生成「本地服务」分类;没有项目时给出一句引导文案 */
export function buildLocalCategory(projects: HostedProject[]): AppCategory {
const items = projects.map(toLocalItem)
return {
id: 'local',
kind: 'apps',
title: '本地服务',
barColor: '#3858f6',
icon: '🖥️',
groups: items.length > 0 ? [{ id: 'local-all', name: '全部项目', items }] : [],
previewLimit: 8,
emptyText: '还没有项目:在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。',
}
}
/**
* 拼接首页全部分类:本地服务永远排第一,其后跟进内容库返回的分类。
*/
export function withLocalCategory(
projects: HostedProject[],
categories: Category[],
): Category[] {
return [buildLocalCategory(projects), ...categories]
}
-12
View File
@@ -1,12 +0,0 @@
/**
* 导航目标。
*
* 侧栏现在直接由内容树(分区 → 分组)派生,不再有独立的导航数据模型;
* 这里只保留「点击后要去哪」的最小类型。
*/
export type NavTarget = {
categoryId: string
/** 指定后明细页只展示该分组 */
groupId?: string
}
+1 -172
View File
@@ -1,8 +1,5 @@
/** /**
* 内容数据模型。 * 内容数据模型:应用 / 海报 / 图文三种条目,以及详情页的推荐条目。
*
* 首页把每个「分类」渲染成一张卡片,明细页按分类的 kind 渲染不同网格,
* 因此这里用可辨识联合把三种展示形态分开。
*/ */
/** 一个可点击的应用 / 站点条目 */ /** 一个可点击的应用 / 站点条目 */
@@ -30,17 +27,6 @@ export type AppItem = {
likes?: number likes?: number
} }
/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */
export type ContentGroup<T> = {
id: string
name: string
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
items: T[]
}
export type AppGroup = ContentGroup<AppItem>
/** 竖版海报条目(电影) */ /** 竖版海报条目(电影) */
export type PosterItem = { export type PosterItem = {
id: string id: string
@@ -79,160 +65,3 @@ export type ArticleItem = {
body?: string body?: string
} }
/** 首页展示块:分区卡片展示哪个节点、显示几条 */
export type SectionBlock = {
/** 要展示的节点业务键:通常是分组;电影/文章这类无分组的填分区自己 */
nodeKey: string
/** 块标题,缺省用节点标题 */
title?: string
/** 卡片显示几条,缺省用分类的 previewLimit 或默认值 */
previewLimit?: number
}
/** 应用类分类:内部按分组组织 */
export type AppCategory = {
id: string
kind: 'apps'
title: string
/** 标题左侧色条颜色,各分类用不同颜色区分 */
barColor: string
/** 明细页 hero 区的字符图标 */
icon: string
groups: AppGroup[]
/** 直接挂在分区上的条目(未分组) */
items?: AppItem[]
/** 首页预览条数,默认 4 */
previewLimit?: number
/** 首页预览为空时的提示文案 */
emptyText?: string
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
/** 海报类分类:竖版海报网格 */
export type PosterCategory = {
id: string
kind: 'posters'
title: string
barColor: string
icon: string
groups: ContentGroup<PosterItem>[]
/** 直接挂在分区上的条目(未分组) */
items: PosterItem[]
/** 首页预览条数,默认 6 */
previewLimit?: number
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
/** 图文类分类:上图下文卡片网格 */
export type ArticleCategory = {
id: string
kind: 'articles'
title: string
barColor: string
icon: string
groups: ContentGroup<ArticleItem>[]
/** 直接挂在分区上的条目(未分组) */
items: ArticleItem[]
/** 首页预览条数,默认 4 */
previewLimit?: number
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
export type Category = AppCategory | PosterCategory | ArticleCategory
/** 分类内的条目总数(直属 + 分组的),用于角标与明细页计数 */
export function categoryCount(category: Category): number {
const direct = category.items?.length ?? 0
const grouped = category.groups.reduce((total, group) => total + group.items.length, 0)
return direct + grouped
}
/** 把应用类分类的条目拍平(分组 + 直属),首页预览用 */
export function flattenAppItems(category: AppCategory): AppItem[] {
return [
...category.groups.flatMap((group) => group.items),
...(category.items ?? []),
]
}
/** 按 id 在分类里查找条目;详情页用 */
export function findAppItem(category: Category, itemId: string): AppItem | undefined {
if (category.kind !== 'apps') return undefined
const direct = (category.items ?? []).find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
/** 取同分类下的其他条目,用于详情页的「推荐阅读」 */
export function relatedAppItems(
category: Category,
excludeId: string | undefined,
limit = 4,
): AppItem[] {
if (category.kind !== 'apps') return []
return flattenAppItems(category)
.filter((item) => item.id !== excludeId)
.slice(0, limit)
}
/** 按 id 查找海报条目 */
export function findPosterItem(
category: Category,
itemId: string,
): PosterItem | undefined {
if (category.kind !== 'posters') return undefined
const direct = category.items.find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
/** 取同分类下的其他海报,用于详情页的「推荐阅读」 */
export function relatedPosterItems(
category: Category,
excludeId: string | undefined,
limit = 4,
): PosterItem[] {
if (category.kind !== 'posters') return []
return [
...category.items,
...category.groups.flatMap((group) => group.items),
]
.filter((item) => item.id !== excludeId)
.slice(0, limit)
}
/** 按 id 查找图文条目 */
export function findArticleItem(
category: Category,
itemId: string,
): ArticleItem | undefined {
if (category.kind !== 'articles') return undefined
const direct = category.items.find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
+2 -60
View File
@@ -16,7 +16,7 @@
* 条目地址带上它自己所在的分组,URL 才是自解释的:刷新之后点「返回」 * 条目地址带上它自己所在的分组,URL 才是自解释的:刷新之后点「返回」
* 仍然落回条目所属的分组,而不是去猜一个返回目标。 * 仍然落回条目所属的分组,而不是去猜一个返回目标。
* *
* 这里只做「地址 ↔ 视图」的纯转换,读写 history 的部分在 `useViewRoute.ts`。 * 这里只做「视图 → 地址」的纯转换,读写 history 的部分在 `AppNext.tsx`。
*/ */
export type DetailRoute = { export type DetailRoute = {
@@ -45,15 +45,10 @@ export type View =
| ItemRoute | ItemRoute
/** 前端路由前缀;改动这里要同步 `src/server.rs` 的 `SPA_PREFIXES`。 */ /** 前端路由前缀;改动这里要同步 `src/server.rs` 的 `SPA_PREFIXES`。 */
const CATEGORY_PREFIX = '/c/' const CATEGORY_SEGMENT = 'c'
/** 前缀去掉两侧斜杠后的那一段,拼地址时当第一个路径段用 */
const CATEGORY_SEGMENT = CATEGORY_PREFIX.slice(1, -1)
/** 条目段标记:`/c/<分区>[/<分组>]/i/<条目>` */ /** 条目段标记:`/c/<分区>[/<分组>]/i/<条目>` */
const ITEM_MARK = 'i' const ITEM_MARK = 'i'
/** 首页视图;未知地址一律归到这里 */
export const HOME_VIEW: View = { type: 'home' }
/** 把视图转成地址。 */ /** 把视图转成地址。 */
export function viewPath(view: View): string { export function viewPath(view: View): string {
if (view.type === 'home') return '/' if (view.type === 'home') return '/'
@@ -65,60 +60,7 @@ export function viewPath(view: View): string {
return `/${segments.join('/')}` 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 里可能带斜杠、空格、中文 */ /** 单个路径段编码:id 里可能带斜杠、空格、中文 */
function encodeSegment(value: string): string { function encodeSegment(value: string): string {
return encodeURIComponent(value) return encodeURIComponent(value)
} }
/** 解码单个路径段;百分号编码非法时返回 undefined */
function decodeSegment(segment: string): string | undefined {
try {
return decodeURIComponent(segment)
} catch {
return undefined
}
}
+4
View File
@@ -11,6 +11,10 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
html[data-scheme='dark'] {
color-scheme: dark;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
+1 -3
View File
@@ -19,7 +19,6 @@ export type GroupNode = {
key: string key: string
parentKey?: string | null parentKey?: string | null
title: string title: string
format: GroupFormat
icon: string icon: string
barColor: string barColor: string
previewLimit?: number | null previewLimit?: number | null
@@ -189,13 +188,12 @@ export async function setCardGroups(
export function flattenGroups( export function flattenGroups(
groups: GroupNode[], groups: GroupNode[],
depth = 0, depth = 0,
): { key: string; title: string; depth: number; format: GroupFormat; enabled: boolean }[] { ): { key: string; title: string; depth: number; enabled: boolean }[] {
return groups.flatMap((group) => [ return groups.flatMap((group) => [
{ {
key: group.key, key: group.key,
title: group.title, title: group.title,
depth, depth,
format: group.format,
enabled: group.enabled !== false, enabled: group.enabled !== false,
}, },
...flattenGroups(group.children, depth + 1), ...flattenGroups(group.children, depth + 1),
-19
View File
@@ -1,19 +0,0 @@
/**
* 登录后展示的用户模块数据。
*
* 后端目前没有鉴权 / 用户信息接口,这里先用占位数据,
* 等类似 `/api/me` 的接口就绪后替换为真实数据即可。
*/
export type UserProfile = {
name: string
role: string
bio: string
location: string
}
export const PLACEHOLDER_PROFILE: UserProfile = {
name: '本地管理员',
role: '管理员',
bio: '本地静态站点托管与预览,所有项目都运行在这台机器上。',
location: '局域网',
}
+140 -259
View File
@@ -1,10 +1,9 @@
/** /**
* 站点设置:页头滚动信息 + 侧栏顺序/显隐。 * 站点设置:页头滚动信息 + 侧栏卡片显隐。
* *
* 接口是公开读、登录后写;写的时候带 `version` 做乐观锁, * 接口是公开读、登录后写;写的时候带 `version` 做乐观锁,
* 冲突(多标签页同时改)时返回 409,页面提示刷新。 * 冲突(多标签页同时改)时返回 409,页面提示刷新。
*/ */
import type { Category } from './data/types'
export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral' export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral'
@@ -14,13 +13,6 @@ export type HeaderItem = {
tone: SettingsTone tone: SettingsTone
} }
export type SectionSettings = {
hidden: string[]
order: string[]
titles: Record<string, string>
limits: Record<string, number>
}
export type ProfileSettings = { export type ProfileSettings = {
name: string name: string
role: string role: string
@@ -31,6 +23,134 @@ export type ProfileSettings = {
avatar: string 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 SearchEngineSettings = {
/** 内置引擎用固定 id(对应品牌图标);自定义引擎由后台生成 custom-xxx */
id: string
name: string
/** 搜索地址模板,用 {query} 表示关键词 */
url: string
/** 自定义图标地址(png / svg);空表示用品牌图标或通用地球 */
icon: 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}',
icon: '',
enabled: true,
},
{
id: 'bing',
name: 'Bing',
url: 'https://www.bing.com/search?q={query}',
icon: '',
enabled: true,
},
{
id: 'baidu',
name: '百度',
url: 'https://www.baidu.com/s?wd={query}',
icon: '',
enabled: true,
},
{
id: 'sogou',
name: '搜狗',
url: 'https://www.sogou.com/web?query={query}',
icon: '',
enabled: true,
},
{
id: '360',
name: '360',
url: 'https://www.so.com/s?q={query}',
icon: '',
enabled: true,
},
{
id: 'duckduckgo',
name: 'DuckDuckGo',
url: 'https://duckduckgo.com/?q={query}',
icon: '',
enabled: true,
},
]
/** 内置引擎:有品牌图标、后台不能删除(只能改名 / 改地址 / 停用) */
export const BUILTIN_SEARCH_ENGINE_IDS = [
'google',
'bing',
'baidu',
'sogou',
'360',
'duckduckgo',
] as const
export type SiteSettings = { export type SiteSettings = {
header: { header: {
enabled: boolean enabled: boolean
@@ -47,20 +167,10 @@ export type SiteSettings = {
show_service: boolean show_service: boolean
/** 是否显示「分组导航」卡片(首页 / 分组树) */ /** 是否显示「分组导航」卡片(首页 / 分组树) */
show_nav: 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 profile: ProfileSettings
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */ /** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
site: SiteMetaSettings site: SiteMetaSettings
@@ -127,15 +237,13 @@ export function defaultSettings(): SiteSettings {
show_profile: true, show_profile: true,
show_service: true, show_service: true,
show_nav: 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: { profile: {
name: '本地管理员', name: '本地管理员',
role: '管理员', role: '管理员',
@@ -177,7 +285,8 @@ export async function fetchSettings(): Promise<SettingsDocument> {
export async function saveSettings( export async function saveSettings(
version: number, version: number,
settings: SiteSettings, settings: SiteSettings,
): Promise<SettingsResult> { try { ): Promise<SettingsResult> {
try {
const response = await fetch('/api/settings', { const response = await fetch('/api/settings', {
method: 'PATCH', method: 'PATCH',
credentials: 'same-origin', credentials: 'same-origin',
@@ -203,231 +312,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 : '网络错误' }
}
}
-79
View File
@@ -1,79 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { fetchCatalog, subscribeCatalog } from './api'
import type { Category } from './data/types'
export type CatalogStatus = 'loading' | 'ready' | 'error'
export type CatalogState = {
status: CatalogStatus
categories: Category[]
error: string | null
}
export type CatalogController = CatalogState & {
/** 手动重新拉取;内容库被外部修改后也会自动调用 */
reload: () => void
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* 内容目录:首屏拉取 + 监听服务端推送 + 手动重载。
*
* 与 `useProjects` 的区别是失败策略:项目列表失败会保留旧数据,
* 内容目录同样保留旧数据 —— 用户正在改库时偶尔读到坏 JSON 不应该把页面清空。
*/
export function useCatalog(): CatalogController {
const [state, setState] = useState<CatalogState>({
status: 'loading',
categories: [],
error: null,
})
const controllerRef = useRef<AbortController | null>(null)
/**
* 拉一次内容目录。
*
* 用 ref 保存当前的 AbortController:外部改动可能连续触发多次刷新,
* 只保留最后一次请求,避免旧响应把新数据覆盖回去。
*/
const load = useCallback(() => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
fetchCatalog(controller.signal)
.then((catalog) => {
if (!controller.signal.aborted) {
setState({
status: 'ready',
categories: catalog.categories,
error: null,
})
}
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setState((previous) => ({
// 已经有内容时只提示错误,不清空列表
status: previous.categories.length > 0 ? 'ready' : 'error',
categories: previous.categories,
error: toErrorMessage(error),
}))
}
})
}, [])
// 首屏加载:setState 只发生在 Promise 回调里,避免 effect 内同步 setState
useEffect(() => {
load()
return () => controllerRef.current?.abort()
}, [load])
// 内容库被外部修改时,服务端推送一次「变了」,这里重新拉取
useEffect(() => subscribeCatalog(load), [load])
return { ...state, reload: load }
}
-105
View File
@@ -1,105 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { parseViewPath, viewPath, type View } from './data/view'
/**
* 视图 ↔ 地址同步。
*
* 三个入口都要覆盖:首屏(解析地址)、点击(写地址)、浏览器前进后退(popstate)。
* 视图本身仍然放在 React state 里,地址只是它的镜像,避免每次渲染都去读 URL;
* 地址与视图的换算规则收在 `data/view.ts`,这里只管 history。
*/
/** 存在 history.state 里的键:当前这条记录是应用内第几页(0 = 没有上一页) */
const HISTORY_INDEX_KEY = 'stashIndex'
type StashHistoryState = { [HISTORY_INDEX_KEY]?: number }
/** 读取当前历史深度;不是本应用写的记录一律当作 0 */
function historyIndex(): number {
const state = window.history.state as StashHistoryState | null
const index = state === null ? undefined : state[HISTORY_INDEX_KEY]
return typeof index === 'number' ? index : 0
}
/** 当前 history.state 的副本,写入前先保留别人(如浏览器扩展)放的字段 */
function currentHistoryState(): StashHistoryState {
const state = window.history.state as StashHistoryState | null
return state === null ? {} : { ...state }
}
export type ViewRoute = {
/** 当前视图,初值来自地址栏 */
view: View
/** 打开一个视图:默认压一条新历史,`replace` 表示替换当前这条 */
go: (view: View, options?: { replace?: boolean }) => void
/**
* 返回。
*
* 应用内有上一页时交给浏览器(`history.back()`),
* 直接打开条目地址(收藏 / 刷新后没有上一页)时退到 `fallback`,
* 这时它替换当前记录,避免在历史里留下一条「自己指向自己」的条目。
*/
back: (fallback: View) => void
}
export function useViewRoute(): ViewRoute {
const [view, setView] = useState<View>(() => parseViewPath(window.location.pathname))
// 首屏把地址规范化:去掉多余的斜杠 / 尾部斜杠,未知地址归到首页,
// 并补上历史深度(直接打开时是 0,说明应用内没有上一页)
useEffect(() => {
const canonical = viewPath(parseViewPath(window.location.pathname))
const state = currentHistoryState()
const recorded = state[HISTORY_INDEX_KEY] !== undefined
if (canonical === window.location.pathname && recorded) return
window.history.replaceState({ ...state, [HISTORY_INDEX_KEY]: historyIndex() }, '', canonical)
}, [])
// 前进后退:地址变了,视图跟着变
useEffect(() => {
const handlePopState = () => setView(parseViewPath(window.location.pathname))
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
const go = useCallback((next: View, options?: { replace?: boolean }) => {
const path = viewPath(next)
const index = historyIndex()
// 已经在同一页(例如重复点同一个侧栏项):不写历史,也不改深度
if (path === window.location.pathname) {
setView(next)
return
}
const replace = options?.replace === true
const state: StashHistoryState = {
...currentHistoryState(),
[HISTORY_INDEX_KEY]: replace ? index : index + 1,
}
if (replace) {
window.history.replaceState(state, '', path)
} else {
window.history.pushState(state, '', path)
}
setView(next)
}, [])
const back = useCallback(
(fallback: View) => {
if (historyIndex() > 0) {
window.history.back()
return
}
go(fallback, { replace: true })
},
[go],
)
return { view, go, back }
}
-17
View File
@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - 资源未找到</title>
<link rel="stylesheet" href="/__stash__/dashboard.css">
</head>
<body class="error-page">
<main class="error-box">
<div class="error-code">404</div>
<h1>资源或页面不存在</h1>
<p>请求的路径 <code>{{ path }}</code> 不存在或缺少 index.html</p>
<a href="/public" class="button">返回应用大厅</a>
</main>
</body>
</html>
-120
View File
@@ -1,120 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#f7f8fa">
<title>Stash · 本地应用工作区</title>
<link rel="stylesheet" href="/__stash__/dashboard.css">
<script src="/__stash__/dashboard.js" defer></script>
</head>
<body class="dashboard-page">
<div class="ambient ambient-blue" aria-hidden="true"></div>
<div class="ambient ambient-warm" aria-hidden="true"></div>
<div class="workspace">
<aside class="sidebar">
<header class="sidebar-header">
<a class="brand" href="/public" aria-label="Stash 首页">
<span class="brand-mark" aria-hidden="true">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="2"></rect>
<rect x="14" y="3" width="7" height="7" rx="2"></rect>
<rect x="3" y="14" width="7" height="7" rx="2"></rect>
<path d="M17.5 14v7M14 17.5h7"></path>
</svg>
</span>
<span class="brand-name">stash<span>.</span></span>
</a>
<span class="local-badge">LOCAL</span>
</header>
<div class="sidebar-intro">
<p class="eyebrow"><span class="eyebrow-dot"></span>YOUR WORKSPACE</p>
<h1>把项目,<br>放在手边。</h1>
<p class="sidebar-lead">Stash 正在整理当前目录下的静态前端应用,让每次预览都从一个清爽入口开始。</p>
</div>
<section class="overview-card" aria-label="工作区概览">
<div class="overview-label">项目概览</div>
<div class="project-total">
<strong data-project-count>{{ projects.len() }}</strong>
<span>个应用</span>
</div>
<div class="service-status">
<span class="status-dot"></span>
<span>本地服务运行中</span>
</div>
</section>
<div class="sidebar-actions">
<button class="refresh-button js-only" id="refresh-projects" type="button" aria-label="重新扫描项目">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 11a8.1 8.1 0 0 0-15.5-2M4 4v5h5"></path>
<path d="M4 13a8.1 8.1 0 0 0 15.5 2M20 20v-5h-5"></path>
</svg>
<span>重新扫描</span>
</button>
<p class="sidebar-hint">目录变化会自动同步,也可以手动重新扫描。</p>
</div>
<footer class="sidebar-footer">
<span>STATIC HOSTING</span>
<span class="footer-ready"><i></i>READY</span>
</footer>
</aside>
<main class="content">
<header class="content-header">
<div class="content-heading">
<p class="eyebrow">PROJECT LIBRARY</p>
<h2>本地应用</h2>
<p>一个入口,快速抵达每个正在开发或预览的页面。</p>
</div>
<div class="content-meta">
<span class="live-pill" id="live-status"><i></i><span id="live-status-label">服务在线</span></span>
<span class="count-pill"><strong data-project-count>{{ projects.len() }}</strong> 个项目</span>
</div>
</header>
<section class="site-grid" id="project-grid" aria-live="polite">
{% if projects.is_empty() %}
<div class="empty-state">
<span class="empty-visual" aria-hidden="true">
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
<path d="M8 13h8M8 16h5"></path>
</svg>
</span>
<h3>这里还没有项目</h3>
<p>在托管根目录下创建子目录,并放入 <code>index.html</code>,刷新后即可在这里看到它。</p>
</div>
{% else %}
{% for project in projects %}
<a class="site-item" href="{{ project.route_path }}" title="{{ project.name }}">
<div class="site-card-top">
<span class="site-avatar" aria-hidden="true">{{ project.initial() }}</span>
<span class="site-open" aria-hidden="true">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 17 17 7M8 7h9v9"></path>
</svg>
</span>
</div>
<div class="site-info">
<h3 class="site-name">{{ project.name }}</h3>
<span class="site-desc">本地静态应用</span>
</div>
<div class="site-card-footer">
<span class="site-path">{{ project.route_path }}</span>
<span class="site-ready">READY</span>
</div>
</a>
{% endfor %}
{% endif %}
</section>
<p class="refresh-status" id="refresh-status" role="status" aria-live="polite"></p>
</main>
</div>
</body>
</html>
-499
View File
@@ -1,499 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>服务与推荐列表完整布局</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: #f5f6f8;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
padding: 24px;
color: #333;
}
.container {
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}
/* 统一外框卡片(图二样式) */
.section-card {
background-color: #ffffff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid #f0f0f0;
}
/* 顶部标题栏 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title-wrapper {
display: flex;
align-items: center;
}
/* 蓝色指示条/图标 */
.title-bar {
width: 4px;
height: 16px;
background-color: #1677ff;
border-radius: 2px;
margin-right: 8px;
}
/* 标题容器,方便角标定位 */
.title-text-container {
position: relative;
display: inline-flex;
align-items: center;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #1f2329;
line-height: 1.2;
}
/* 右上角红色角标 */
.badge {
position: absolute;
top: -8px;
right: -18px;
background-color: #ff4d4f;
color: #ffffff;
font-size: 11px;
font-weight: bold;
height: 16px;
min-width: 16px;
padding: 0 4px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
box-shadow: 0 1px 3px rgba(255, 77, 79, 0.3);
}
/* 卡片右上角更多按钮 */
.more-icon {
color: #8c8c8c;
font-size: 18px;
cursor: pointer;
user-select: none;
letter-spacing: 2px;
}
.more-icon:hover {
color: #595959;
}
/* ------------------- 样式 1:常规应用卡片网格 ------------------- */
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.app-item {
display: flex;
align-items: center;
padding: 12px 14px;
background-color: #f9f9fb;
border-radius: 8px;
border: 1px solid #f0f0f0;
transition: all 0.2s ease;
cursor: pointer;
}
.app-item:hover {
background-color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
border-color: #e6e6e6;
transform: translateY(-1px);
}
.app-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background-color: #e8f3ff;
color: #1677ff;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
margin-right: 12px;
flex-shrink: 0;
}
.app-info {
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-name {
font-size: 14px;
font-weight: 600;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-desc {
font-size: 12px;
color: #8c8c8c;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ------------------- 样式 2:图四 推荐电影(竖版海报卡片) ------------------- */
.movie-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
}
@media (max-width: 900px) {
.movie-grid { grid-template-columns: repeat(3, 1fr); }
}
.movie-item {
position: relative;
border-radius: 8px;
overflow: hidden;
aspect-ratio: 2 / 3; /* 经典电影海报高宽比 */
background-color: #e0e0e0;
cursor: pointer;
transition: transform 0.2s ease;
}
.movie-item:hover {
transform: translateY(-2px);
}
.movie-cover {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 底部文字渐变蒙层 */
.movie-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20px 8px 8px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.4) 60%, transparent 100%);
color: #ffffff;
}
.movie-title {
font-size: 12px;
font-weight: 500;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ------------------- 样式 3:图五 现场报道/文章(横版网格卡片) ------------------- */
.article-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
}
@media (max-width: 900px) {
.article-grid { grid-template-columns: repeat(3, 1fr); }
}
.article-item {
position: relative;
border-radius: 6px;
overflow: hidden;
aspect-ratio: 4 / 3; /* 横版比例 */
background-color: #e0e0e0;
cursor: pointer;
transition: transform 0.2s ease;
}
.article-item:hover {
transform: translateY(-2px);
}
.article-cover {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 底部文字渐变蒙层 */
.article-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 16px 6px 6px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
color: #ffffff;
}
.article-title {
font-size: 11px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
opacity: 0.95;
}
</style>
</head>
<body>
<div class="container">
<!-- 1. 本地服务 -->
<div class="section-card">
<div class="section-header">
<div class="section-title-wrapper">
<div class="title-bar"></div>
<div class="title-text-container">
<span class="section-title">本地服务</span>
<span class="badge">1</span>
</div>
</div>
<div class="more-icon">•••</div>
</div>
<div class="app-grid">
<div class="app-item">
<div class="app-icon">G</div>
<div class="app-info">
<div class="app-name">git_helper</div>
<div class="app-desc">/git_helper/</div>
</div>
</div>
</div>
</div>
<!-- 2. 常用推荐 -->
<div class="section-card">
<div class="section-header">
<div class="section-title-wrapper">
<div class="title-bar"></div>
<div class="title-text-container">
<span class="section-title">常用推荐</span>
<span class="badge">4</span>
</div>
</div>
<div class="more-icon">•••</div>
</div>
<div class="app-grid">
<div class="app-item">
<div class="app-icon">G</div>
<div class="app-info">
<div class="app-name">GitHub</div>
<div class="app-desc">代码托管与开源社区</div>
</div>
</div>
<div class="app-item">
<div class="app-icon" style="background-color: #e6f7ff; color: #1890ff;">Z</div>
<div class="app-info">
<div class="app-name">知乎</div>
<div class="app-desc">深度内容问答社区</div>
</div>
</div>
<div class="app-item">
<div class="app-icon" style="background-color: #fff0f6; color: #eb2f96;">B</div>
<div class="app-info">
<div class="app-name">哔哩哔哩</div>
<div class="app-desc">弹幕视频分享网站</div>
</div>
</div>
<div class="app-item">
<div class="app-icon" style="background-color: #f6ffed; color: #52c41a;">V</div>
<div class="app-info">
<div class="app-name">V2EX</div>
<div class="app-desc">创意工作者讨论社区</div>
</div>
</div>
</div>
</div>
<!-- 3. 图四样式:推荐电影(竖版海报卡片) -->
<div class="section-card">
<div class="section-header">
<div class="section-title-wrapper">
<div class="title-bar" style="background-color: #722ed1;"></div>
<div class="title-text-container">
<span class="section-title">推荐电影</span>
<span class="badge">6</span>
</div>
</div>
<div class="more-icon">•••</div>
</div>
<div class="movie-grid">
<div class="movie-item">
<img class="movie-cover" src="https://picsum.photos/seed/m1/300/450" alt="海报">
<div class="movie-info">
<div class="movie-title">千与千寻 千と千尋の神隠し (2001)</div>
</div>
</div>
<div class="movie-item">
<img class="movie-cover" src="https://picsum.photos/seed/m2/300/450" alt="海报">
<div class="movie-info">
<div class="movie-title">美丽人生 La vita è bella (1997)</div>
</div>
</div>
<div class="movie-item">
<img class="movie-cover" src="https://picsum.photos/seed/m3/300/450" alt="海报">
<div class="movie-info">
<div class="movie-title">这个杀手不太冷 Léon (1994)</div>
</div>
</div>
<div class="movie-item">
<img class="movie-cover" src="https://picsum.photos/seed/m4/300/450" alt="海报">
<div class="movie-info">
<div class="movie-title">阿甘正传 Forrest Gump (1994)</div>
</div>
</div>
<div class="movie-item">
<img class="movie-cover" src="https://picsum.photos/seed/m5/300/450" alt="海报">
<div class="movie-info">
<div class="movie-title">霸王别姬 (1993)</div>
</div>
</div>
<div class="movie-item">
<img class="movie-cover" src="https://picsum.photos/seed/m6/300/450" alt="海报">
<div class="movie-info">
<div class="movie-title">肖申克的救赎 The Shawshank...</div>
</div>
</div>
</div>
</div>
<!-- 4. 图五样式:CES 现场报道(横版图片两行网格) -->
<div class="section-card">
<div class="section-header">
<div class="section-title-wrapper">
<div class="title-bar" style="background-color: #fa8c16;"></div>
<div class="title-text-container">
<span class="section-title">CES 现场报道</span>
<span class="badge">12</span>
</div>
</div>
<div class="more-icon">•••</div>
</div>
<div class="article-grid">
<!-- 第一行 -->
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a1/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">可塑性极强的玻璃,在设计师手里有了更多可能</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a2/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">废旧共享单车如何变成微型共享书屋?</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a3/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">荧光灯管与混凝土块的组合,可以玩出多少花样?</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a4/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">混凝土做的模块化收纳框,还能改成搁架用</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a5/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">这个用拉伸材料做的头盔,还能帮你呼叫救护车</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a6/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">你如何选择字体,简单易读还是好看有趣?</div>
</div>
</div>
<!-- 第二行 -->
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a7/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">家里空间小?你需要几把可以悬挂在墙上的椅子</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a8/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">宜家厨房家具又被改造了,色彩是亮点</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a9/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">这款诞生50年的扶手椅,推出了特别版配色</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a10/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">让家和办公室界限变得模糊的一组家具</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a11/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">这个酒店设计奖,挖掘过去一年体验最好的酒店</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a12/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">一款可移动办公桌,挂墙上当作白板就能写写画画</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
-920
View File
@@ -1,920 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>STASH - 完整联动与版心对齐布局</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth; /* 启用原生平滑滚动 */
}
body {
background-color: #f5f6f8;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: #333;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 统一版心最大宽度约束 */
.layout-container {
max-width: 1380px;
width: 100%;
margin: 0 auto;
padding: 0 20px;
}
/* ------------------ 1. 顶部 Header 组件 (宽度对齐下方) ------------------ */
.header-outer {
background-color: #ffffff;
border-bottom: 1px solid #e8e8e8;
position: sticky;
top: 0;
z-index: 100;
}
.header-inner {
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
width: 32px;
height: 32px;
background-color: #0f172a;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
}
.logo-text {
font-size: 18px;
font-weight: 800;
color: #0f172a;
letter-spacing: 0.5px;
}
.header-center-info {
display: flex;
align-items: center;
gap: 20px;
font-size: 13px;
color: #64748b;
}
.info-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
background-color: #e6f4ff;
color: #1677ff;
font-size: 12px;
margin-right: 6px;
}
.info-item {
display: flex;
align-items: center;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
color: #64748b;
}
.header-icon-btn {
cursor: pointer;
padding: 6px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.header-icon-btn:hover {
background-color: #f1f5f9;
color: #0f172a;
}
/* 搜索栏区域(宽度与下方卡片版心保持对齐) */
.search-container {
padding: 16px 0 0;
display: flex;
justify-content: center;
}
.search-box {
width: 100%;
max-width: 520px;
position: relative;
display: flex;
align-items: center;
}
.search-input {
width: 100%;
height: 40px;
padding: 0 16px 0 40px;
border-radius: 20px;
border: 1px solid #e2e8f0;
background-color: #ffffff;
font-size: 13px;
outline: none;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
}
.search-input:focus {
border-color: #1677ff;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.1);
}
.search-icon {
position: absolute;
left: 14px;
color: #94a3b8;
font-size: 14px;
pointer-events: none;
}
/* ------------------ 主体 Layout 布局 ------------------ */
.main-layout {
display: flex;
margin: 20px auto 40px;
gap: 20px;
}
/* ------------------ 2. 左侧侧边栏组件 ------------------ */
.sidebar {
width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
position: sticky;
top: 76px;
align-self: flex-start;
}
.sidebar-card {
background-color: #ffffff;
border-radius: 12px;
padding: 18px;
border: 1px solid #f0f0f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
/* 用户卡片 */
.user-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.avatar-wrapper {
position: relative;
margin-bottom: 10px;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background-color: #dbeafe;
color: #1d4ed8;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
}
.verified-badge {
position: absolute;
bottom: 0;
right: 2px;
width: 18px;
height: 18px;
background-color: #f59e0b;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
border: 2px solid #fff;
}
.user-name {
font-size: 15px;
font-weight: 700;
color: #1e293b;
}
.user-role {
font-size: 11px;
color: #64748b;
background-color: #f1f5f9;
padding: 2px 8px;
border-radius: 10px;
margin: 6px 0 10px;
border: 1px solid #e2e8f0;
}
.user-desc {
font-size: 12px;
color: #64748b;
line-height: 1.5;
margin-bottom: 8px;
}
.user-ip {
font-size: 12px;
color: #94a3b8;
}
/* 本地服务状态卡片 */
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.status-title {
font-size: 14px;
font-weight: 700;
color: #1e293b;
}
.status-pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 12px;
background-color: #f1f5f9;
color: #64748b;
display: flex;
align-items: center;
gap: 4px;
}
.status-pill::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #94a3b8;
}
.status-info-row {
display: flex;
justify-content: space-between;
font-size: 13px;
margin-bottom: 8px;
}
.status-label {
color: #64748b;
}
.status-value {
font-weight: 600;
color: #1e293b;
}
.status-actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.action-btn {
flex: 1;
padding: 6px 0;
border-radius: 16px;
border: 1px solid #e2e8f0;
background-color: #fff;
font-size: 12px;
color: #475569;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
transition: all 0.2s;
}
.action-btn:hover {
border-color: #cbd5e1;
background-color: #f8fafc;
}
/* 侧边分类导航菜单(对应右侧主标题) */
.menu-card {
padding: 10px;
}
.menu-group-title {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
padding: 6px 10px;
text-transform: uppercase;
}
.menu-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
font-size: 13px;
color: #334155;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
margin-bottom: 2px;
}
.menu-item:hover {
background-color: #f8fafc;
color: #1677ff;
}
.menu-item.active {
background-color: #e6f4ff;
color: #1677ff;
font-weight: 600;
}
/* ------------------ 3. 右侧主内容区域 ------------------ */
.content-area {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
}
/* 大框样式(图二规范) */
.section-card {
background-color: #ffffff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
border: 1px solid #f0f0f0;
scroll-margin-top: 80px; /* 留出顶部固定 Header 的高度避免遮挡 */
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title-wrapper {
display: flex;
align-items: center;
}
.title-bar {
width: 4px;
height: 16px;
background-color: #1677ff;
border-radius: 2px;
margin-right: 8px;
}
.title-text-container {
position: relative;
display: inline-flex;
align-items: center;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #1f2329;
line-height: 1.2;
}
/* 角标相对于标题右上角定位 */
.badge {
position: absolute;
top: -8px;
right: -18px;
background-color: #ff4d4f;
color: #ffffff;
font-size: 11px;
font-weight: bold;
height: 16px;
min-width: 16px;
padding: 0 4px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
box-shadow: 0 1px 3px rgba(255, 77, 79, 0.3);
}
.more-icon {
color: #8c8c8c;
font-size: 18px;
cursor: pointer;
letter-spacing: 2px;
padding: 2px 6px;
border-radius: 4px;
transition: background 0.2s;
}
.more-icon:hover {
color: #595959;
background-color: #f5f5f5;
}
/* 常规卡片网格 */
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 12px;
}
.app-item {
display: flex;
align-items: center;
padding: 12px 14px;
background-color: #f9f9fb;
border-radius: 8px;
border: 1px solid #f0f0f0;
transition: all 0.2s ease;
cursor: pointer;
}
.app-item:hover {
background-color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
border-color: #e6e6e6;
transform: translateY(-1px);
}
.app-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background-color: #e8f3ff;
color: #1677ff;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
margin-right: 12px;
flex-shrink: 0;
}
.app-info {
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-name {
font-size: 14px;
font-weight: 600;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-desc {
font-size: 12px;
color: #8c8c8c;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 推荐电影网格 */
.movie-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
}
@media (max-width: 1100px) {
.movie-grid { grid-template-columns: repeat(3, 1fr); }
}
.movie-item {
position: relative;
border-radius: 8px;
overflow: hidden;
aspect-ratio: 2 / 3;
background-color: #e0e0e0;
cursor: pointer;
transition: transform 0.2s ease;
}
.movie-item:hover {
transform: translateY(-2px);
}
.movie-cover {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.movie-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20px 8px 8px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.4) 60%, transparent 100%);
color: #ffffff;
}
.movie-title {
font-size: 12px;
font-weight: 500;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* CES 现场报道网格 */
.article-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
}
@media (max-width: 1100px) {
.article-grid { grid-template-columns: repeat(3, 1fr); }
}
.article-item {
position: relative;
border-radius: 6px;
overflow: hidden;
aspect-ratio: 4 / 3;
background-color: #e0e0e0;
cursor: pointer;
transition: transform 0.2s ease;
}
.article-item:hover {
transform: translateY(-2px);
}
.article-cover {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.article-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 16px 6px 6px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
color: #ffffff;
}
.article-title {
font-size: 11px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
</head>
<body>
<!-- 1. 顶部 Header (固定顶部且与下方容器宽度一致) -->
<header class="header-outer">
<div class="layout-container header-inner">
<div class="header-left">
<div class="logo-icon">S</div>
<span class="logo-text">STASH</span>
</div>
<div class="header-center-info">
<div class="info-item">
<span>共 1 个本地应用</span>
</div>
<div class="info-item">
<span class="info-tag">本地</span>
<span>git_helper</span>
</div>
<div class="info-item">
<span class="info-tag" style="background-color: #f0fdf4; color: #16a34a;">状态</span>
<span>等待服务连接</span>
</div>
</div>
<div class="header-right">
<div class="header-icon-btn" title="搜索">🔍</div>
<div class="header-icon-btn" title="个人账户">👤</div>
</div>
</div>
</header>
<!-- 搜索框区域 -->
<div class="layout-container search-container">
<div class="search-box">
<span class="search-icon">🔍</span>
<input type="text" class="search-input" placeholder="搜索本地项目...">
</div>
</div>
<!-- 主体布局 -->
<div class="layout-container main-layout">
<!-- 2. 左侧侧边栏组件 -->
<aside class="sidebar">
<!-- 用户信息卡片 -->
<div class="sidebar-card user-card">
<div class="avatar-wrapper">
<div class="avatar">👤</div>
<div class="verified-badge">✓</div>
</div>
<div class="user-name">本地管理员</div>
<div class="user-role">管理员</div>
<div class="user-desc">本地静态站点托管与预览,所有项目都运行在这台机器上。</div>
<div class="user-ip">IP地址: 局域网</div>
</div>
<!-- 本地服务状态卡片 -->
<div class="sidebar-card">
<div class="status-header">
<span class="status-title">本地服务</span>
<span class="status-pill">未连接</span>
</div>
<div class="status-info-row">
<span class="status-label">项目数</span>
<span class="status-value">1</span>
</div>
<div class="status-info-row">
<span class="status-label">访问地址</span>
<span class="status-value">127.0.0.1:8080</span>
</div>
<div class="status-actions">
<button class="action-btn">🔄 重新扫描</button>
<button class="action-btn">🔗 查看接口</button>
</div>
</div>
<!-- 分类导航菜单(与右侧主标题一致) -->
<div class="sidebar-card menu-card">
<div class="menu-group-title">快速导航</div>
<a href="#section-local" class="menu-item active" onclick="setActive(this)">
<span>本地服务</span>
<span style="font-size: 11px;">1</span>
</a>
<a href="#section-recommend" class="menu-item" onclick="setActive(this)">
<span>常用推荐</span>
<span style="font-size: 11px;">4</span>
</a>
<a href="#section-movie" class="menu-item" onclick="setActive(this)">
<span>推荐电影</span>
<span style="font-size: 11px;">6</span>
</a>
<a href="#section-ces" class="menu-item" onclick="setActive(this)">
<span>CES 现场报道</span>
<span style="font-size: 11px;">12</span>
</a>
</div>
</aside>
<!-- 3. 右侧主内容区域 -->
<main class="content-area">
<!-- 1. 本地服务 -->
<section id="section-local" class="section-card">
<div class="section-header">
<div class="section-title-wrapper">
<div class="title-bar"></div>
<div class="title-text-container">
<span class="section-title">本地服务</span>
<span class="badge">1</span>
</div>
</div>
<div class="more-icon" title="更多操作">•••</div>
</div>
<div class="app-grid">
<div class="app-item">
<div class="app-icon">G</div>
<div class="app-info">
<div class="app-name">git_helper</div>
<div class="app-desc">/git_helper/</div>
</div>
</div>
</div>
</section>
<!-- 2. 常用推荐 -->
<section id="section-recommend" class="section-card">
<div class="section-header">
<div class="section-title-wrapper">
<div class="title-bar"></div>
<div class="title-text-container">
<span class="section-title">常用推荐</span>
<span class="badge">4</span>
</div>
</div>
<div class="more-icon" title="更多操作">•••</div>
</div>
<div class="app-grid">
<div class="app-item">
<div class="app-icon">G</div>
<div class="app-info">
<div class="app-name">GitHub</div>
<div class="app-desc">代码托管与开源社区</div>
</div>
</div>
<div class="app-item">
<div class="app-icon" style="background-color: #e6f7ff; color: #1890ff;">Z</div>
<div class="app-info">
<div class="app-name">知乎</div>
<div class="app-desc">深度内容问答社区</div>
</div>
</div>
<div class="app-item">
<div class="app-icon" style="background-color: #fff0f6; color: #eb2f96;">B</div>
<div class="app-info">
<div class="app-name">哔哩哔哩</div>
<div class="app-desc">弹幕视频分享网站</div>
</div>
</div>
<div class="app-item">
<div class="app-icon" style="background-color: #f6ffed; color: #52c41a;">V</div>
<div class="app-info">
<div class="app-name">V2EX</div>
<div class="app-desc">创意工作者讨论社区</div>
</div>
</div>
</div>
</section>
<!-- 3. 推荐电影 -->
<section id="section-movie" class="section-card">
<div class="section-header">
<div class="section-title-wrapper">
<div class="title-bar" style="background-color: #722ed1;"></div>
<div class="title-text-container">
<span class="section-title">推荐电影</span>
<span class="badge">6</span>
</div>
</div>
<div class="more-icon" title="更多操作">•••</div>
</div>
<div class="movie-grid">
<div class="movie-item">
<img class="movie-cover" src="https://picsum.photos/seed/m1/300/450" alt="海报">
<div class="movie-info">
<div class="movie-title">千与千寻 千と千尋の神隠し (2001)</div>
</div>
</div>
<div class="movie-item">
<img class="movie-cover" src="https://picsum.photos/seed/m2/300/450" alt="海报">
<div class="movie-info">
<div class="movie-title">美丽人生 La vita è bella (1997)</div>
</div>
</div>
<div class="movie-item">
<img class="movie-cover" src="https://picsum.photos/seed/m3/300/450" alt="海报">
<div class="movie-info">
<div class="movie-title">这个杀手不太冷 Léon (1994)</div>
</div>
</div>
<div class="movie-item">
<img class="movie-cover" src="https://picsum.photos/seed/m4/300/450" alt="海报">
<div class="movie-info">
<div class="movie-title">阿甘正传 Forrest Gump (1994)</div>
</div>
</div>
<div class="movie-item">
<img class="movie-cover" src="https://picsum.photos/seed/m5/300/450" alt="海报">
<div class="movie-info">
<div class="movie-title">霸王别姬 (1993)</div>
</div>
</div>
<div class="movie-item">
<img class="movie-cover" src="https://picsum.photos/seed/m6/300/450" alt="海报">
<div class="movie-info">
<div class="movie-title">肖申克的救赎 The Shawshank...</div>
</div>
</div>
</div>
</section>
<!-- 4. CES 现场报道 -->
<section id="section-ces" class="section-card">
<div class="section-header">
<div class="section-title-wrapper">
<div class="title-bar" style="background-color: #fa8c16;"></div>
<div class="title-text-container">
<span class="section-title">CES 现场报道</span>
<span class="badge">12</span>
</div>
</div>
<div class="more-icon" title="更多操作">•••</div>
</div>
<div class="article-grid">
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a1/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">可塑性极强的玻璃,在设计师手里有了更多可能</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a2/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">废旧共享单车如何变成微型共享书屋?</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a3/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">荧光灯管与混凝土块的组合,可以玩出多少花样?</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a4/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">混凝土做的模块化收纳框,还能改成搁架用</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a5/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">这个用拉伸材料做的头盔,还能帮你呼叫救护车</div>
</div>
</div>
<div class="article-item">
<img class="article-cover" src="https://picsum.photos/seed/a6/300/225" alt="文章图片">
<div class="article-info">
<div class="article-title">你如何选择字体,简单易读还是好看有趣?</div>
</div>
</div>
</div>
</section>
</main>
</div>
<script>
// 切换左侧菜单激活高亮样式
function setActive(element) {
const items = document.querySelectorAll('.menu-item');
items.forEach(item => item.classList.remove('active'));
element.classList.add('active');
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-741
View File
@@ -1,741 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>导航主页</title>
<style>
:root {
--bg-color: #f7f9fb;
--card-bg: #ffffff;
--primary-color: #3b82f6;
--text-main: #1e293b;
--text-sub: #64748b;
--border-color: #e2e8f0;
--radius-main: 12px;
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.03), 0 1px 3px rgba(0, 0, 0, 0.02);
--shadow-hover: 0 10px 20px rgba(0, 0, 0, 0.06), 0 4px 6px rgba(0, 0, 0, 0.03);
--sidebar-width: 240px;
--top-nav-height: 60px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* 1. 锁死大窗口,禁止全局滚动 */
html, body {
width: 100vw;
height: 100vh;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
}
/* 通用卡片容器 */
.card-box {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-main);
box-shadow: var(--shadow-card);
}
/* 2. 顶部直角导航栏 */
.top-header-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: var(--top-nav-height);
background-color: var(--card-bg);
border-bottom: 1px solid var(--border-color);
border-radius: 0 !important;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 200;
}
.site-logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 800;
font-size: 18px;
color: #0f172a;
text-decoration: none;
width: 180px;
flex-shrink: 0;
}
.logo-icon {
width: 32px;
height: 32px;
background-color: #000;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
font-weight: bold;
}
.ticker-wrapper {
flex: 1;
max-width: 550px;
height: 36px;
overflow: hidden;
position: relative;
margin: 0 20px;
display: flex;
align-items: center;
mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);
-webkit-mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);
}
.ticker-content {
display: flex;
gap: 40px;
white-space: nowrap;
animation: ticker 18s linear infinite;
}
.ticker-wrapper:hover .ticker-content {
animation-play-state: paused;
}
.ticker-item {
font-size: 13.5px;
color: var(--text-sub);
text-decoration: none;
display: flex;
align-items: center;
gap: 6px;
}
.ticker-tag {
background-color: #eff6ff;
color: var(--primary-color);
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
}
@keyframes ticker {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.top-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.action-btn {
background: transparent;
border: none;
cursor: pointer;
color: #475569;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 50%;
transition: all 0.2s;
}
.action-btn:hover {
background-color: #f1f5f9;
color: var(--primary-color);
}
.action-btn svg {
width: 19px;
height: 19px;
stroke-width: 2;
stroke: currentColor;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
/* ---------------- 搜索框:全局固定且全屏水平居中 ---------------- */
.search-row {
position: fixed;
top: 76px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
width: 480px;
height: 44px;
pointer-events: auto;
}
.search-bar {
display: flex;
align-items: center;
gap: 10px;
height: 100%;
width: 100%;
padding: 0 12px;
}
.engine-btn {
background: transparent;
border: none;
color: var(--text-sub);
cursor: pointer;
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.engine-btn:hover {
background-color: #f1f5f9;
color: var(--primary-color);
}
.engine-btn svg {
width: 16px;
height: 16px;
stroke-width: 2;
stroke: currentColor;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.search-input {
width: 100%;
height: 36px;
font-size: 13.5px;
border: none;
background: transparent;
outline: none;
color: var(--text-main);
}
/* ---------------- 3. 双列核心网格 ---------------- */
.layout-grid {
margin-top: var(--top-nav-height);
height: calc(100vh - var(--top-nav-height));
padding: 36px 24px 0 20px;
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
gap: 20px;
align-items: start;
}
.left-column {
display: flex;
flex-direction: column;
gap: 16px;
flex-shrink: 0;
margin-top: 68px;
}
.right-column {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow: hidden;
padding-top: 52px;
}
.user-card {
height: 52px;
padding: 0 12px;
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 8px;
background-color: #eff6ff;
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 15px;
}
.user-name {
font-size: 13.5px;
font-weight: 600;
color: var(--text-main);
line-height: 1.2;
}
.user-desc {
font-size: 11.5px;
color: var(--text-sub);
margin-top: 2px;
}
.nav-card {
padding: 8px;
height: auto;
}
.nav-group {
list-style: none;
}
.nav-item-wrapper {
margin-bottom: 4px;
}
.nav-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 12px;
color: var(--text-sub);
text-decoration: none;
border-radius: 8px;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.nav-link:hover, .nav-link.active {
background-color: #eff6ff;
color: var(--primary-color);
}
.arrow-icon {
font-size: 13px;
transition: transform 0.3s;
color: #94a3b8;
font-family: monospace;
font-weight: bold;
}
.nav-item-wrapper.open > .nav-link .arrow-icon {
transform: rotate(90deg);
}
.sub-menu {
list-style: none;
padding-left: 12px;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s cubic-bezier(0, 1, 0, 1);
}
.nav-item-wrapper.open .sub-menu {
max-height: 500px;
transition: max-height 0.3s ease-in-out;
}
.sub-menu .nav-link {
padding: 7px 12.5px;
font-size: 12.5px;
}
.submit-card {
padding: 6px;
}
.submit-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 9px 12px;
color: var(--text-sub);
text-decoration: none;
border-radius: 8px;
font-size: 13.5px;
font-weight: 500;
}
.submit-btn:hover {
background-color: #eff6ff;
color: var(--primary-color);
}
/* 4. 右侧独立滚动区域 */
.main-content {
flex: 1;
overflow-y: auto;
padding-right: 4px;
padding-top: 16px;
padding-bottom: 40px;
-webkit-mask-image: linear-gradient(to bottom, transparent 0px, black 22px, black 100%);
mask-image: linear-gradient(to bottom, transparent 0px, black 22px, black 100%);
-ms-overflow-style: none;
scrollbar-width: none;
}
.main-content::-webkit-scrollbar {
display: none !important;
width: 0 !important;
}
.section-group {
margin-bottom: 24px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--text-main);
display: flex;
align-items: center;
line-height: 1;
margin-bottom: 16px;
padding-top: 2px;
}
.section-title::before {
content: "";
display: inline-block;
width: 4px;
height: 15px;
background-color: var(--primary-color);
margin-right: 8px;
border-radius: 2px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 12px 14px;
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
box-shadow: var(--shadow-card);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-hover);
}
.card-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background-color: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--primary-color);
margin-right: 12px;
flex-shrink: 0;
}
.card-title {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-desc {
font-size: 12px;
color: var(--text-sub);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.back-to-top {
position: fixed;
bottom: 30px;
right: 30px;
width: 40px;
height: 40px;
background-color: #e2e8f0;
border-radius: 50%;
color: #1e293b;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: var(--shadow-card);
transition: all 0.2s ease;
text-decoration: none;
z-index: 90;
}
.back-to-top:hover {
background-color: #cbd5e1;
transform: translateY(-2px);
}
.back-to-top svg {
width: 18px;
height: 18px;
stroke: currentColor;
stroke-width: 2.5;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
</style>
</head>
<body>
<!-- 顶栏 -->
<header class="top-header-bar">
<a href="#" class="site-logo">
<div class="logo-icon">S</div>
<span>SUXING</span>
</a>
<div class="ticker-wrapper">
<div class="ticker-content">
<a href="#" class="ticker-item"><span class="ticker-tag">公告</span> 欢迎使用最新版个人导航站!</a>
<a href="#" class="ticker-item"><span class="ticker-tag">更新</span> WordPress 主题升级至 v3.2 版本</a>
<a href="#" class="ticker-item"><span class="ticker-tag">推荐</span> 常用前端工具箱已更新资源</a>
<a href="#" class="ticker-item"><span class="ticker-tag">公告</span> 欢迎使用最新版个人导航站!</a>
<a href="#" class="ticker-item"><span class="ticker-tag">更新</span> WordPress 主题升级至 v3.2 版本</a>
</div>
</div>
<div class="top-actions">
<button class="action-btn" title="暗黑模式">
<svg viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
</button>
<button class="action-btn" title="快捷方式">
<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
</button>
<button class="action-btn" title="全局搜索">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</button>
</div>
</header>
<!-- 搜索框独立置于全屏水平居中位置 -->
<div class="search-row">
<div class="search-bar card-box">
<button class="engine-btn" title="切换搜索引擎">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</button>
<input type="text" class="search-input" placeholder="输入关键字搜索全网资源...">
</div>
</div>
<!-- 主布局网格 -->
<div class="layout-grid">
<!-- 左侧固定边栏 -->
<div class="left-column">
<div class="user-card card-box">
<div class="avatar">S</div>
<div>
<div class="user-name">Suxing Admin</div>
<div class="user-desc">个人导航站</div>
</div>
</div>
<div class="nav-card card-box">
<ul class="nav-group">
<li class="nav-item-wrapper">
<a href="#common" class="nav-link active">
<span>常用推荐</span>
</a>
</li>
<li class="nav-item-wrapper">
<div class="nav-link has-sub">
<span>素材资源</span>
<span class="arrow-icon">›</span>
</div>
<ul class="sub-menu">
<li><a href="#design" class="nav-link"><span>UI 设计资源</span></a></li>
<li><a href="#icon" class="nav-link"><span>矢量图标</span></a></li>
<li><a href="#photo" class="nav-link"><span>免费壁纸</span></a></li>
</ul>
</li>
<li class="nav-item-wrapper">
<div class="nav-link has-sub">
<span>开发工具</span>
<span class="arrow-icon">›</span>
</div>
<ul class="sub-menu">
<li><a href="#dev" class="nav-link"><span>前端调试工具</span></a></li>
<li><a href="#code" class="nav-link"><span>在线编辑器</span></a></li>
</ul>
</li>
</ul>
</div>
<div class="submit-card card-box">
<a href="#submit" class="submit-btn">提交网站</a>
</div>
</div>
<!-- 右侧区域 -->
<div class="right-column">
<!-- 右侧独立滚动内容 -->
<main class="main-content" id="scrollContainer">
<div id="common" class="section-group">
<div class="section-title">常用推荐</div>
<div class="grid">
<a href="#" class="card">
<div class="card-icon">G</div>
<div>
<div class="card-title">GitHub</div>
<div class="card-desc">代码托管与开源社区</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">Z</div>
<div>
<div class="card-title">知乎</div>
<div class="card-desc">深度内容问答社区</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">B</div>
<div>
<div class="card-title">哔哩哔哩</div>
<div class="card-desc">弹幕视频分享网站</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">V</div>
<div>
<div class="card-title">V2EX</div>
<div class="card-desc">创意工作者讨论社区</div>
</div>
</a>
</div>
</div>
<div id="dev" class="section-group">
<div class="section-title">前端调试工具 (开发工具)</div>
<div class="grid">
<a href="#" class="card">
<div class="card-icon">S</div>
<div>
<div class="card-title">Stack Overflow</div>
<div class="card-desc">程序员技术问答</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">J</div>
<div>
<div class="card-title">掘金</div>
<div class="card-desc">开发者技术社区</div>
</div>
</a>
</div>
</div>
<div id="code" class="section-group">
<div class="section-title">在线编辑器</div>
<div class="grid">
<a href="#" class="card">
<div class="card-icon">C</div>
<div>
<div class="card-title">CodePen</div>
<div class="card-desc">前端在线代码演练场</div>
</div>
</a>
</div>
</div>
<div id="design" class="section-group">
<div class="section-title">UI 设计资源</div>
<div class="grid">
<a href="#" class="card">
<div class="card-icon">D</div>
<div>
<div class="card-title">Dribbble</div>
<div class="card-desc">全球设计作品分享社区</div>
</div>
</a>
</div>
</div>
</main>
</div>
</div>
<!-- 回到顶部按钮 -->
<a href="javascript:void(0)" class="back-to-top" id="backToTopBtn" title="回到顶部">
<svg viewBox="0 0 24 24">
<line x1="5" y1="4" x2="19" y2="4"></line>
<path d="M12 20V8M6 14l6-6 6 6"></path>
</svg>
</a>
<script>
document.querySelectorAll('.has-sub').forEach(trigger => {
trigger.addEventListener('click', () => {
trigger.closest('.nav-item-wrapper').classList.toggle('open');
});
});
const scrollContainer = document.getElementById('scrollContainer');
const backToTopBtn = document.getElementById('backToTopBtn');
backToTopBtn.addEventListener('click', () => {
scrollContainer.scrollTo({
top: 0,
behavior: 'smooth'
});
});
document.querySelectorAll('a.nav-link[href^="#"]').forEach(link => {
link.addEventListener('click', function(e) {
const targetId = this.getAttribute('href');
if (targetId === '#') return;
const targetElement = document.querySelector(targetId);
if (targetElement) {
e.preventDefault();
const containerTop = scrollContainer.getBoundingClientRect().top;
const elementTop = targetElement.getBoundingClientRect().top;
const currentScroll = scrollContainer.scrollTop;
const offsetPosition = currentScroll + (elementTop - containerTop) - 24;
scrollContainer.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
document.querySelectorAll('.nav-link').forEach(el => el.classList.remove('active'));
this.classList.add('active');
}
});
});
</script>
</body>
</html>