81 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
CNWei 1effbea3dd docs: 按当前实现刷新 README / HANDOFF / ADMIN-PLAN
README:
- 快速开始补上 stash-ui 必须先构建(file: 依赖),目录结构补 stash-ui 与新的前端目录
- 登录与设置改写为新的后台结构(左侧导航 + 页头/侧边栏/内容卡片子项)、明暗主题与 /interface
- 前端路由补 /interface、/admin 与 /c/ 前缀;服务端回落说明改成 SPA_PREFIXES + SPA_PAGES
- 表结构:node 增补 allow_groups / enabled,section_block 章节改为 card / card_group,
  说明 user_version=22 与自动迁移
- HTTP API:内容目录改为 /api/catalog2,新增「其它接口」汇总表(与 /interface 页一致)
- 已知限制:删掉「没有迁移」「没有身份认证」「只有 /c/** 一个前缀」等过时结论

HANDOFF.md 顶部加历史快照说明与变更摘要;ADMIN-PLAN.md 的待办标注已完成项;
stash-web/README.md 从 Vite 模板换成真实说明
2026-11-01 22:42:05 +08:00
CNWei 259eb20411 feat: 「查看接口」改为站内路由,只换右侧内容
- ServicePanel 新增 onOpenApi,链接点击接管为 navigate('/interface');
  按住 Ctrl/Cmd/Shift/Alt 仍保留新标签打开
- 前台内容区已有 route.kind === 'api' 分支,直接渲染接口一览
- 顺手删掉两个没人引用的旧组件:sidebar/Sidebar.tsx、sidebar/CategoryNav.tsx
2026-11-01 22:22:57 +08:00
CNWei b1fd82a0b6 style(admin): 侧边栏去掉「侧栏卡片」小标题,说明贴近标题
- 删除 SidebarPanel 里的「侧栏卡片」标签
- SidebarPanel 不再挂 .admin-form(它带 20px margin-top),
  标题到说明的距离由 32px 变成 12px,与「内容卡片」一致
- 三个开关行间距不变(Space gap=md)
2026-11-01 22:14:08 +08:00
CNWei 6e773374bb refactor(admin): 折叠箭头与卡片列表改用库里的 Caret / SortableList
- 分组表格的折叠箭头换成 Caret,.group-caret 只保留按钮盒
- 首页卡片列表换成 SortableList,拖拽与排序计算交给组件;reorder 改为接收 key 顺序
- 删掉 .cards-side-item / .cards-side-title / .cards-side-count / .cards-drag-handle 等
  9 条样式,App.css 再减 42 行
2026-11-01 22:07:32 +08:00
CNWei 4ae69b29fe refactor(admin): 三张手写表格改用库里的 Table,文字按钮/开关行收进库
- 分组管理 / 数据管理 / 内容卡片 / 本地服务 四处手写 <table> 全部换成 Table size='sm'
- 行内操作(新建子分组 / 编辑 / 删除)改用 TextButton,删除统一 danger 色
- 侧边栏、页头共 7 处开关行改用 ToggleRow
- 空状态改用 Empty
- 删掉随之失效的 CSS:.group-table* / .entry-table* / .cards-table* / .group-action* /
  .sidebar-toggle-row / .admin-item-title 等,App.css 净减 144 行

视觉:表格外框、表头底色、行 hover 统一由 @stash/ui 提供,与接口页一致
2026-11-01 21:59:18 +08:00
CNWei a67ab7bfe0 fix: 分区角标改按左边缘定位,位数不同间距也一致
原来 right: -30px 按右边缘定位,badge 宽度随位数变化:
本地服务(1 位数)离标题 14px,推荐电影 / CES(2 位数)只有 8px。
改成 left: calc(100% + 8px),四张卡片实测间距全部为 8px。
2026-11-01 21:47:12 +08:00
CNWei 883504222b style: 滚动信息靠右,分区角标与标题拉开 8px
- .header-ticker 加 margin-left: auto,紧贴右侧操作图标;
  靠右改由它承担,.header-actions 去掉 margin-left: auto(无滚动信息时由 space-between 兜住)
- .count-badge 的 right 从 -18 调到 -30,标题与角标间距约 8px
2026-11-01 21:43:54 +08:00
CNWei 27a28ab583 feat: 新增接口一览页 /interface,滚动信息区域收窄
- 新页面 /interface:按用途分组的接口表(方法 / 路径 / 说明 / 权限),
  用库里的 Card + Table + Tag 排;服务端 SPA 路由加入该路径
- 侧边栏「查看接口」不再直接打开 /api/projects 的原始 JSON,改指到该页面
- 页头滚动信息宽度上限 720px(原来铺满,约减少 1/3)
2026-11-01 20:53:55 +08:00
CNWei 036551ee09 feat: 刷新按钮改为整页重载;滚动信息间距可配
- 前台刷新按钮改成 window.location.reload(),项目扫描 / 内容库 / 设置 / 主题全部重走
- settings.header 新增 gap(px,默认 26 ≈ 两个中文字宽,最大 120)
- 后台「页头 → 滚动信息」增加「条目间距」输入
2026-11-01 20:44:29 +08:00
CNWei 0375669a6e feat(admin): 页头拆成三个子页签,导航顺序与命名调整
- 首页管理下顺序改为 页头 / 侧边栏 / 内容卡片,默认展开
- 资料 -> 资料管理、系统 -> 系统管理
- 页头面板内用水平页签分为 站点信息 / 操作工具 / 滚动信息
- 滚动信息页列出可用通配符 {count} / {project} / {status} 及含义
2026-11-01 20:36:11 +08:00
CNWei 10b653e30f feat(admin): 后台改为左侧导航 + 右侧内容
- 原来的横向 Tabs 换成库里的 Menu,左侧固定导航、右侧面板
- 首页管理收成一组:侧边栏 / 内容卡片 / 页头 三个子项,页头从顶层移入
- 页头、资料仍需手动保存,保存/撤销按钮留在各自面板底部
- 窄屏(<=860px)导航折到内容上方
- 后台内容区宽度不变(1340),导航列 190px + 24px 间距
2026-11-01 20:27:36 +08:00
CNWei 8271adc307 style(admin): 拖动说明与下方卡片列表拉开间距
说明从列表底部挪到按钮下方后,原本的 margin-top 变成了「和按钮的间距」,
和列表贴在一起;改成 margin-bottom 16px,上下分别是 12 / 16。
2026-11-01 20:19:07 +08:00
CNWei 3f00a64b3b chore(admin): 副标题里的「首页卡片」改成「内容卡片」 2026-11-01 20:13:49 +08:00
CNWei a7391d7b1a fix(admin): 内容卡片说明放回标题下并改为当前行为,拖动说明移到新建按钮下方
- 卡片说明回到「内容卡片」标题下,改成:勾选分组 + 调整先后顺序与预览条数 + 停用由分组管理决定
- 「拖动卡片可调整首页顺序」移到新建卡片按钮下方(仍在左侧卡片列表里)
- 页头副标题里的「首页卡片」同步改成「内容卡片」
2026-11-01 20:13:20 +08:00
CNWei 30baa96237 style(admin): 首页管理 Tab 整理
- Tab 改名:首页 -> 首页管理
- 两个区块标题改用 Title(侧边栏 / 内容卡片,原「首页卡片」)
- 卡片表格状态列统一成胶囊:启用绿底绿字、已停用红底红字,形状一致
- 卡片删除按钮改成与数据管理一致的红色
- 顺序列 92 -> 112px
- 首页 Tab 内所有说明补上「说明:」前缀;新建卡片的说明挪到按钮下方
2026-11-01 20:08:09 +08:00
CNWei d0a79283ed fix(admin): 新建数据按钮恢复主按钮样式
上一版顺手改成了 secondary,用户要求只改文案、按钮风格保持原样。
2026-11-01 19:59:17 +08:00
CNWei 54d2025a6e style(admin): 内容页改为数据管理
- Tab 名称:内容 -> 数据管理
- 说明文案:数据只属于一个分组;前面加「说明:」
- 新建条目 -> 新建数据,与分组下拉按 8px 间距紧挨
- 操作列:编辑、删除配上图标
2026-11-01 19:55:54 +08:00
CNWei 784a7c54ac style(admin): 分组管理表格整理
- 折叠箭头改成和前台侧栏 Menu 一样的 CSS 角标(6px 折角,展开/收起旋转)
- 分组默认全部收起,首次拿到数据时统一折叠
- 操作列:加子分组 -> 新建子分组,并与编辑、删除一起配上图标
- 表格上方说明前加「说明:」
2026-11-01 19:36:42 +08:00
CNWei 923903b4b2 feat: 前台明暗切换改为本机偏好,不再要求登录
- 新增 src/theme.ts:localStorage 的 stash-theme 作为本机偏好,优先于站点默认
- 前台页头的月亮/太阳只写本机偏好,未登录也能用,刷新后保持
- 后台「页头 → 配色」与后台页头按钮改的是站点默认值;后台按钮同时清掉本机偏好,
  保证点完看到的就是新默认
- 两个壳都按「本机偏好 ?? 站点默认」决定 data-scheme
2026-11-01 19:00:05 +08:00
CNWei 84fc148a39 feat: 前台页头加明暗切换,刷新图标换成闪电
- settings.site.actions 新增 theme(默认开),后台「操作工具」可关
- 前台页头:月亮/太阳按钮,点击写回 site.theme,前后台同步;
  未登录时不显示(设置接口需要会话)
- 刷新图标 refresh_3_line -> lightning_line,刷新期间仍旋转
2026-11-01 16:26:57 +08:00
CNWei 5c170f8819 feat: 后台页头加明暗切换;内容卡改用库里的 EntryRow / EntryTile
- 后台页头新增月亮/太阳按钮:写回 site.theme 并即时切换,前后台一起变,
  忽略未登录与保存中
- AppCard -> EntryRow,CesCard -> EntryTile(PosterCard 本来就用 Cover)
- 删除 .app-card / .app-card-* / .ces-card / .ces-body* 手写样式,
  两套卡片的点击语义与悬停反馈统一由库提供

视觉变化:头像认证勾回到库默认尺寸(20px,之前应用里压到 10px)
2026-11-01 16:08:44 +08:00
CNWei 18e9543607 feat: 分组图标统一(iconfont 名字优先,字符兜底)+ 后台选图标
- 新增 components/common/GroupMark:值是图标名就渲染图标,否则按字符显示,
  兼容旧数据里的 emoji;后台的 GroupMark 改为复用同一判断
- 分组详情页 hero 之前把图标名当文本渲染(会直接显示 folder_2_line),已修
- 分组编辑弹窗加「选择图标」:搜索 + 网格,446 个图标按关键字过滤(默认展示前 120)
- 输入框仍可手填字符,旧数据不用迁移
2026-11-01 15:43:53 +08:00
CNWei 2f2ceb9ea0 feat: 接入深色配色(浅色 / 深色 / 跟随系统)
- settings.site 新增 theme,落在 <html data-scheme> 上
- 深色完全由组件库的令牌块接管,页面与组件都不用改
- 后台「页头 → 配色」可选,保存后前后台即时切换
- 顶部背景图是浅色渐变图,深色下隐藏(.site-background)
2026-11-01 15:41:22 +08:00
CNWei 8a782099f5 style: 统一前后台宽度与数值标尺,站点外壳改用 @stash/ui
对应风格审计的 1-4 条:

1. 宽度:后台内容区改为与页头内层同宽同左缘(1380 - 40 = 1340),
   修掉品牌 x=50、卡片 x=280 的错位
2. 阴影:--su-shadow-card / hover 的覆盖从 .app 挪到 :root,前后台同一套
3. 死代码:删除未被引用的 App.tsx(436 行)、AdminPage.tsx(54KB)与 .content-veil 样式
4. 标尺:37 处脱离 4/8/12/16/20/24/32/40 与字阶的间距/字号/圆角/行高改为令牌
   (含新增的 --su-font-size-2xl);6px 的认证角标按图标保留并注明原因

顺带把重复实现收进库:
- 页头品牌 -> Brand
- 页头滚动信息 -> Ticker(含实测宽度的无缝循环)
- 搜索框 -> SearchField(variant=card),删除 components/search/SearchBar.tsx
- 后台「首页 Logo」预览与后台页头同样复用 Brand
2026-11-01 15:28:22 +08:00
CNWei 59fd61d832 feat: 搜索带并入 Layout toolbar,后台重组首页配置
前台
- 搜索框从内容列移到 Layout 的 toolbar 插槽,去掉 fixed + TOP_OFFSET + top-blur 三处补偿
- 左右栏滚动容器不变,左栏吸顶改用 CSS sticky(修回顶时侧栏抖动)
- 只让分组导航滚动,资料卡与本地服务固定
- 页头操作支持配置:搜索改为刷新(重扫项目 + 重拉内容库)
- 站点级设置:页面标题 / favicon / 品牌 logo / 品牌文字
- 停用的分组在侧边栏、首页卡片、分组页、条目页统一不再出现

后台
- 「侧边栏」「首页卡片」合并为「首页」Tab
- 首页卡片表格新增状态列与顺序列(上下箭头即时保存)
- 分组显隐只由「分组管理 → 状态」决定,去掉侧栏独立勾选
- 页头 Tab 增加站点信息、首页 Logo(带实时预览)、操作工具开关
- 后台页头与前台同构并吸顶

后端
- settings 新增 site(标题 / favicon / 品牌 / 操作工具)与 sidebar.show_nav
- 写接口成功后广播内容库变更,SSE 改为「广播 + data_version 兜底」
- catalog SSE 事件名与前端监听对齐(catalog)
2026-11-01 15:15:20 +08:00
CNWei bb5a615ed2 fix: 搜索框保持实底,不受虚化影响
- 移除搜索框半透明与 backdrop-filter
- 虚化只作用于搜索框周围
- 验证:bg 纯白、blur none、opacity 1
2026-10-31 17:24:36 +08:00
CNWei 6c8eaa9f76 style: 搜索框 30% 透明,分组块底部留白
- 搜索框背景 50% -> 30%
- sidebar 增加 24px 底部内边距,导航块滚到底留 24px
- 验证:alpha=0.30;nav bottom 与视口底部间距 24px
2026-10-31 17:18:57 +08:00
CNWei 762de4a4e1 style: 修复侧栏分类圆角,顶部增加软虚化
- nav-card 补 12px 圆角与 overflow hidden
- 搜索框 50% 透明 + blur(14px)
- 新增 top-blur:blur(8px) + 上下 mask 渐隐,覆盖搜索与内容顶部,不出现硬线
- 验证:nav 圆角 12、搜索可点、虚化生效
2026-10-31 17:03:20 +08:00
CNWei 2a344ae534 style: 加高顶部留白
- TOP_OFFSET 88 -> 104,搜索框与卡片间距 28 -> 44
- contentOffset 与 page-scroll margin/height 共用常量
- 验证 content/sider top=164
2026-10-31 16:57:29 +08:00
CNWei ddef75f862 fix: 去掉顶部独立块,消除横向分割线
- 删除 top-dock 与其半透明填充/底边
- 搜索框保持 fixed 悬浮,页头实底
- 顶部与内容之间只保留页面背景自然渐变
- 验证:top-dock 数量为 0,背景连续
2026-10-31 16:54:44 +08:00
CNWei e13c2b2ce6 fix: 页头恢复实底,搜索改为独立半透明块
- headerVariant 恢复 solid,去掉 .app > header 透明度
- 新增 top-dock(top 60 / height 88,alpha 0.28 + blur + 底边)承载搜索
- 背景图渐隐恢复 80%
- 验证:header 纯白、dock 半透明、搜索可见、无遮罩
2026-10-31 16:41:50 +08:00
CNWei a2155a4c89 fix: 去掉顶部遮罩,页头与搜索框改为半透明
- 删除 scroll-fade 遮罩及创建逻辑
- 页头改为 blur 并降到 55% 透明度,搜索框降到 60%
- 背景图渐隐层从 80% 调到 65%,顶部区域保持背景原色
- 验证:fades=0,header alpha=0.55,search alpha=0.60
2026-10-31 16:34:54 +08:00
CNWei 7a61acfa02 fix: 顶部遮罩合并为横跨左右的一条
- 移除内容区 content-veil,只保留一条 fixed 遮罩
- 遮罩宽度从侧栏左边缘到内容区右边缘,左右同一水平高度
- 保留渐变与 blur(14px),验证 left=50 width=1340 top=148
2026-10-31 16:28:14 +08:00
CNWei 50aea29ea6 fix: 侧栏顶部虚化遮罩与半透明卡片
- 新增固定侧栏遮罩层,滚动后淡入,blur(14px) + 透明渐变,不随吸底 transform 移动
- 侧栏资料/服务/导航卡改为 64% 半透明 + 毛玻璃
- 修正只输出 -webkit-backdrop-filter 导致不生效的问题
- 验证:遮罩 top=148、blur 生效、卡片 alpha=0.64
2026-10-31 16:00:45 +08:00
CNWei 4304fd75b4 fix: 双栏非对称吸底滚动与对齐
- 去掉首页侧栏额外 padding,左右两栏初始顶部对齐
- 隐藏 #page-scroll 滚动条
- 左侧到底后用 translateY 补偿吸附在视口底部,右侧继续滚动;右侧先到底时两栏同步
- ResizeObserver 重算吸附阈值
- 验证:初始 top 148/148;滚动后左栏 top 144 固定、bottom 900;无滚动条
2026-10-31 15:51:57 +08:00
60 changed files with 4293 additions and 12091 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` | 字体加载失败时 |
| 数字(主题) | `Outfit-Medium` | 仅主题的 `font-price` / `font-number` 使用,Stash 未引入 |
| 等宽 | `ui-monospace, Consolas, monospace` | 路由地址等路径文本 |
@@ -100,48 +100,16 @@ CSS 变量 `--font-sans` / `--font-mono` 定义在 `index.css`。
## 3. 图标
### 3.1 图标字体
图标统一来自组件库 `@stash/ui`:
主题使用自建 iconfont(线性风格,Project id 4809463)。
Stash 自托管了一份 **只含所需字形** 的子集:
```tsx
import { Icon } from '@stash/ui'
- 字体文件:[`stash-web/src/assets/iconfont.woff2`](stash-web/src/assets/iconfont.woff2)
- 样式:[`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;
}
<Icon name="search_3_line" />
```
可用名称见 `@stash/ui` 导出的 `ICON_NAMES`,尺寸与颜色分别由 `size` / CSS `color` 控制。`stash-web` 不再维护本地 iconfont 或 `Icon.tsx`。
---
## 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` |
| 新增图标 | 把 codepoint 加进 [`stash-web/src/iconfont.css`](stash-web/src/iconfont.css),在 `Icon.tsx` 包一个组件 |
| 新增分类 | 内容库 `category` 表(首次启动前可改 [`src/db/seed.json`](src/db/seed.json)),首页与明细页自动适配 |
| 调整侧栏导航 | 内容库 `nav_entry` 表,标签 / 层级 / 跳转目标都可在库里改 |
| 新增卡片类型 | 在 `data/types.ts` 增加 kind,`HomeView` / `DetailView` 各加一个分支 |
| 新增图标 | 在 `@stash/ui` 的 iconfont 中补图标,再通过 `<Icon name>` 使用 |
| 新增分组 | 后台「分组管理」新建根分组 / 子分组,内容挂在分组上 |
| 调整侧栏导航 | 后台「侧边栏」勾选要显示的卡片与分组 |
| 新增卡片类型 | 在 `AppNext.tsx` 的 `renderEntries` / `renderCard` 增加分支 |
| 调整分区样式 | [`stash-web/src/App.css`](stash-web/src/App.css) 的「分区卡片」段落 |
| 换背景图 | 替换 `stash-web/src/assets/site-bg.jpg` |
+213 -183
View File
@@ -1,232 +1,262 @@
# 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`。
## 0. 一句话现状
## 1. 一句话现状
Stash 已从「只读本地导航站」升级为**可登录、可配置、可编辑内容的本地发布台**:
Stash 是「本地项目托管 + 可配置导航首页」:Rust 单 crate 内嵌 React 前端,单管理员登录,内容库为 SQLite,分组 / 条目 / 首页卡片 / 站点设置都在 `/admin` 管理。
- 登录(单用户,argon2id + Cookie 会话);
- `/admin` 设置页(页头滚动信息 / 首页卡片 / 归类 / 侧栏 / 资料 / 系统);
- 条目就地编辑(Drawer + 乐观锁);
- 分区新增/删除、**条目新增/删除(可选分区与分组)**、分组(子分类)新建/改名/删除、侧栏子分类配置、浏览/点赞/分享/跳转;
- 数据库自动迁移到 **v18**;
- 前端 100% 使用组件库 `@stash/ui`(v0.3.1,42 个组件)。
当前开发分支 `develop`,本地服务:
当前数据库**已设置管理员密码**(2026-10-30 由使用者设置);忘记密码按 §6 删除 `admin` 行即可回到首次设置。
- 前台 + 后台:<http://127.0.0.1:8080>
- 管理后台:<http://127.0.0.1:8080/admin>
- 接口一览:<http://127.0.0.1:8080/interface>
- Vite 开发服务器 `5173` 当前**未运行**;需要时 `cd stash-web && npm run dev`
## 1. 仓库与运行环境
## 2. 仓库与分支
| 项目 | 路径 | 分支 | 端口 | 说明 |
| --- | --- | --- | --- | --- |
| 新版(组件重构) | `D:\CNWei\CNW\Rust\stash` | `feat/use-stash-ui` | **8080** | 所有新功能都在这里 |
| 旧版(1:1 基线) | `D:\CNWei\CNW\Rust\stash-old`(worktree) | `develop` | **8081** | 只用于视觉对照,不要改 |
| 组件库 | `D:\CNWei\CNW\Rust\stash-ui` | `master` | — | tag `v0.1.0` / `v0.2.0` / `v0.3.0` / `v0.3.1` |
| 仓库 | 路径 | 分支 | 状态 |
| --- | --- | --- | --- |
| 主项目 | `D:\CNWei\CNW\Rust\stash` | `develop` | 工作区干净;功能最新 `1d1ece6`,本文件随后单独提交 |
| 组件库 | `D:\CNWei\CNW\Rust\stash-ui` | `master` | 工作区只有忽略的 `.idea/`;`994436a` 最新 |
后端:Rust 2024 + Axum 0.8 + rusqlite(bundled)。前端:React 19 + Vite + TS + CSS Modules,`@stash/ui` 通过 `file:` 依赖引入。
- 主项目 remote 名为 `master`(不是 `origin`),地址 `https://gitea.ngmao.com/CNWei/stash.git`。
- `develop` 比 `master/develop` **领先 20 个提交,尚未推送**;此前 HTTPS 凭据不可用,需要时先准备 Gitea 凭据。
- `stash-ui` 也有 3 个未推送提交:`b97c77e`(SearchField suffix)、`46829cd`(Input leading)、`994436a`(EngineIcon)。
- `@stash/ui` 以 `file:../../stash-ui` 引入,`stash-web` 使用其 `dist/`;改组件库后必须重新 `npm run build`。
### 启动 / 构建(顺序重要)
## 3. 启动 / 构建 / 测试
前端产物会被 `build.rs` 内嵌,顺序不能反:
```powershell
# 1) 前端(产物会被 build.rs 内嵌,必须先构建)
cd D:\CNWei\CNW\Rust\stash\stash-web
npm run build
npm run build # tsc -b + vite,产物 dist/
# 2) 先杀掉正在运行的 stash.exe,否则 cargo 会报「拒绝访问」
Get-Process stash -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*stash\target*' } | Stop-Process -Force
# 3) 后端
cd ..
cd D:\CNWei\CNW\Rust\stash
Get-Process stash -ErrorAction SilentlyContinue |
Where-Object { $_.Path -like '*stash\target*' } | Stop-Process -Force # 否则 exe 被占用
cargo build
Start-Process .\target\debug\stash.exe -RedirectStandardOutput "$env:TEMP\stash.log" -RedirectStandardError "$env:TEMP\stash-err.log" -WindowStyle Hidden
Start-Process .\target\debug\stash.exe `
-RedirectStandardOutput "$env:TEMP\stash.log" `
-RedirectStandardError "$env:TEMP\stash-err.log" -WindowStyle Hidden
```
旧版(8081,仅在需要对照时):
```powershell
D:\CNWei\CNW\Rust\stash-old\target\debug\stash.exe --port 8081
```
### 测试
Windows 下 `TEMP` 权限有问题,跑测试要覆盖临时目录:
测试(Windows 下覆盖 TEMP 权限):
```powershell
$env:TEMP = "D:\CNWei\CNW\Rust\stash\target\tmp"; $env:TMP = $env:TEMP
cargo test # 当前 48 passed
cargo test # 交接时 52 passed
```
## 2. 目录 / 文件地图
组件库:
```powershell
cd D:\CNWei\CNW\Rust\stash-ui
npm run build # dist/stash-ui.js + .css + types
```
## 4. 代码地图
```
src/
main.rs / lib.rs 启动、日志
config.rs 端口 / 数据目录(默认 target/debug/data)
server.rs 全部路由(API + 内嵌前端 + SPA 回落)
error.rs StashError(含 Conflict → 409)
main.rs / lib.rs 启动、参数、日志
config.rs 端口 / 数据目录 / 托管目录
server.rs 全部 API + 内嵌前端 + SPA 回落
listing.rs 托管目录扫描
watcher.rs 文件监听 + SSE 防抖
db/
mod.rs Db 门面(所有方法)+ 迁移测试
schema.rs 建表、种子、版本迁移(0/15→16→17)
auth.rs 管理员、密码哈希、会话(token 只存 sha256)
settings.rs settings JSON(header/sidebar/sections/profile)
entries.rs 条目读取/编辑、浏览数、点赞、审计
categories.rs 分区新增/删除
catalog.rs 查询组装(前端三种视图)
mod.rs Db 门面 + 迁移/种子测试
schema.rs 建表、种子、v15→…→v23 迁移
auth.rs admin / session / argon2id
settings.rs settings JSON + 校验
entries.rs 条目 CRUD、浏览、点赞、审计
groups.rs 分组树 CRUD / 移动 / 排序
cards.rs 卡片与勾选分组
catalog.rs 分组树 + 条目查询组装
stash-web/src/
App.tsx 主页壳、登录弹窗、编辑器、设置应用
main.tsx 入口分流(/admin 与 /login)
auth.ts / settings.ts / items.ts 前端 API 客户端
AppNext.tsx 前台壳:路由、卡片、侧栏、搜索框、主题
main.tsx /admin 与普通前台分流
settings.ts SiteSettings 类型 / 默认值 / API 客户端
manage.ts 分组、卡片、catalog2 客户端
items.ts 条目编辑客户端
api.ts 项目扫描 / SSE 客户端
components/
header/SiteHeader.tsx 品牌 + 滚动信息 + 操作图标
admin/AdminPage.tsx /admin 全部 Tab
auth/LoginDialog.tsx Modal 登录
auth/PasswordInput.tsx 带显隐切换的密码框
content/ 首页、分区、条目、文章、编辑器
sidebar/ 资料卡、服务面板、导航
docs/ADMIN-PLAN.md 后台方案 + P0–P4 实施记录 + 未做项
README.md 运行、路由、库结构、登录说明
admin/AdminNext.tsx 后台全部面板
header/SiteHeader.tsx 品牌 / 滚动信息 / 操作图标
content/ 卡片、网格、详情、编辑器
sidebar/ 资料卡、本地服务、导航
stash-ui/src/components/ SearchField / Input / EngineIcon 等
```
## 3. 功能清单(已完成)
## 5. 数据库(v23)
### 登录
- 单用户 `admin`,密码 argon2id;
- 会话:随机 32 字节 token,HttpOnly + SameSite=Strict + Path=/,7 天,库里存 sha256;
- 登录失败 5 次锁 60 秒;写接口校验 Origin;写接口需要会话。
路径:`target/debug/data/stash.db`(`--data` 可覆盖)。
### `/admin`(新原子模型)
| Tab | 能力 |
| --- | --- |
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/图标/排序/状态)、删除;格式已下沉到条目,不再在分组上设置 |
| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 格式 / 跳转链接 / 所属分组 / 操作;每条内容单独选格式(应用/海报/图文),同一分组可混合 |
| 侧边栏 | 顶部「侧栏卡片」控制本地管理员 / 本地服务显隐;下方「分组导航」逐项勾选;开关即保存 |
| 首页卡片 | 左侧列表含「本地服务」只读表(不可删除,但可拖动排序)+ 自建卡片;右侧分组表格;表格内开关/条数即时保存,标题用「保存」按钮 |
| 页头 | 启用开关、速度、条目、标签与色调 |
| 资料 | 头像 URL、名称、角色、简介、位置 |
| 系统 | 修改密码、下载备份 |
- `PRAGMA user_version = 23`;支持 0 建全量、15→16→…→23 迁移,高于程序版本拒绝启动。
- 当前表:`admin`、`audit`、`card`、`card_group`、`entry`、`entry_tag`、`node`、`session`、`setting`。
- 当前数据(交接时):15 个 node、44 条 entry、5 张 card;`PRAGMA foreign_key_check` 无异常。
- `section_block` 与 `node.view_mode / empty_text / show_in_sidebar / allow_groups` 已在 v23 删除。
- `entry.format` 是每条内容自己的 `apps / posters / articles`,同一分组可混合。
- `node.kind = 'section'` 是根分组,`group` 是子分组;`enabled` 控制整棵子树显示。
- 条目只存 `node_key`;格式、分组、卡片都通过业务 key 关联。
- 备份:`target/debug/data/stash.db.bak-pre-cleanup-v22` 是 v23 改造前快照;`/api/backup` 可下载当前快照。
- **不要直接 `cargo clean`/删除整个 `target`**:运行库和备份在 `target/debug/data/`。
保存:分组/内容/卡片逐项保存;侧边栏、页头、资料走 `PATCH /api/settings`(`version` 乐观锁)。
## 6. settings JSON
### 内容编辑
- 详情页 `[编辑]` 只在登录后显示;
- 打开右侧 Drawer:标题、简介、标签、一句话评价、正文 Markdown、封面、跳转地址;
- `PATCH /api/items/{key}`,`updatedAt` 乐观锁;
- 保存后 `catalog.reload()` 局部刷新,不整页刷新。
### 详情操作条
- 浏览:进入页面 `POST /api/items/{key}/view`,数字 +1;
- 点赞:可切换,`POST /like`,localStorage 记住状态;
- 分享:复制当前链接,Toast 反馈;
- 跳转:保存时规范化 `href`(`www.` 自动补 `https://`,拒绝 `javascript:` 等协议);为空或 `#` 时按钮置灰。
## 4. HTTP API
`setting` 表 `key='site'`,API `GET/PATCH /api/settings`,`version` 乐观锁。当前字段:
```jsonc
{
"header": { "enabled": true, "speed": 24, "gap": 26, "items": [...] },
"sidebar": { "show_profile": true, "show_service": false, "show_nav": true },
"background": { "light": "", "dark": "" }, // 空 = 内置背景
"font": { "source": "builtin", "current": "", "items": [] }, // builtin = 文楷;custom = 用 items 里 current 那条
"search": {
"default": "internal",
"engines": [
{ "id": "google", "name": "Google", "url": "https://www.google.com/search?q={query}", "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/projects GET /api/projects/events (SSE)
GET /api/catalog GET /api/catalog/events (SSE) 旧结构(兼容)
GET /api/catalog2 分组树 + 卡片(新前台/后台)
GET /api/cards 卡片列表
POST /api/nodes {key,title,parentKey?,format?} 需登录
PATCH /api/nodes/{key} {title,parentKey?,sort?,format?} 需登录
DELETE /api/nodes/{key} 需登录,级联
POST /api/cards {title} 需登录
PATCH /api/cards/{id} {title?,sort?} 需登录
DELETE /api/cards/{id} 需登录
PUT /api/cards/{id}/groups [{groupKey,previewLimit}] 需登录
GET /api/auth/session -> {authenticated, has_admin, username}
POST /api/auth/setup {password} 仅当无管理员
POST /api/auth/login {password}
POST /api/auth/logout
PATCH /api/auth/password {current, next}
GET /api/settings -> {version, settings}
PATCH /api/settings {version, settings} 需登录
GET /api/items/{key} -> 原始字段(编辑表单)
POST /api/items {key,nodeKey,item} 需登录
PATCH /api/items/{key} {updatedAt, item, nodeKey?} 需登录,nodeKey 可移动条目
DELETE /api/items/{key} 需登录
GET /api/projects GET /api/projects/events (SSE)
GET /api/catalog2 分组树 + 卡片
GET /api/catalog/events SSE:catalog 变化
GET /api/settings
PATCH /api/settings 需登录,{version,settings}
GET /api/fonts font/ 目录里的字体清单(公开读)
POST /api/nodes 需登录
PATCH /api/nodes/{key} 需登录
DELETE /api/nodes/{key} 需登录
POST /api/cards 需登录
PATCH /api/cards/{id} 需登录
DELETE /api/cards/{id} 需登录
PUT /api/cards/{id}/groups 需登录
GET /api/items/{key}
POST /api/items 需登录
PATCH /api/items/{key} 需登录
DELETE /api/items/{key} 需登录
POST /api/items/{key}/view
POST /api/items/{key}/like {liked}
POST /api/categories {id,title,kind} 需登录
PATCH /api/categories/{id} {title,icon,barColor,kind,previewLimit,emptyText,blockNodeKey,allowGroups} 需登录
POST /api/categories/{id}/groups {id,title} 需登录,仅应用分区
DELETE /api/categories/{id} 需登录,级联,local 不可删
PATCH /api/groups/{id} {title,showInSidebar} 需登录
DELETE /api/groups/{id} 需登录,级联组内条目
GET /api/backup SQLite 快照附件 需登录
POST /api/items/{key}/like
GET /api/backup 需登录
GET /api/auth/session
POST /api/auth/setup / login / logout
PATCH /api/auth/password
```
## 5. 数据库与迁移
已删除:`/api/catalog`、`/api/categories/*`、旧 `/api/groups/{id}`。
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。
- 当前 `PRAGMA user_version = 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)是唯一滚动容器,搜索框固定在其上方;左右两栏放在同一容器内,位移天然一致。
- 本地服务是运行时卡片,但 `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`,恢复需停机替换文件。
## 9. 组件库改动(stash-ui)
## 6. 认证运维
- `SearchField` 新增可选 `suffix` 与 `leading` 插槽;
- `Input` 新增 `leading` ReactNode,优先于 `icon`;
- 新增 `EngineIcon`(`internal/google/bing/baidu/sogou/360`),图标路径来自参考稿;`18px` 默认、`14px` 用于顶部小标签;
- 构建产物已更新,`stash-web` 通过 file 依赖读取;改库后必须重建 dist,否则主项目构建仍用旧类型/资源。
- 忘记密码:删除 `admin` 行(或整库)后重启 → 回到首次设置。
```powershell
node -e "const {DatabaseSync}=require('node:sqlite');const d=new DatabaseSync('target/debug/data/stash.db');d.exec('DELETE FROM admin; DELETE FROM session;')"
```
- 改密码:`/admin → 系统`,成功后该账号所有会话失效,需要重新登录。
- 首个管理员由 `/api/auth/setup` 创建,密码至少 8 位。
## 10. 已知限制 / 坑
## 7. 组件库 `@stash/ui`
- 42 个组件,文档站:`cd stash-ui && npm run dev`(也可 `npm run build:site` 后看 `dist-site`)。
- 关键命令:`npm run typecheck && npm run lint && npm run build && npm run build:site`。
- 组件契约:`docs/COMPONENTS.md`;设计规范:`DESIGN.md`;外部风格接入流程:`docs/STYLE-INTAKE.md`(闸门:截图 → 映射 → 决策 → 评审 → 再写代码)。
- 主题:`themes/example.css` 只改变量;页面禁止写组件内部选择器。
- **判断归属**:全局配置放 `/admin`;页面版式(间距、布局容器)写在页面 CSS;只有「第二个页面也复用」的才抽组件。
## 8. 验证与截图
- 后端逻辑:`cargo test`(48 passed,覆盖迁移/登录/设置/条目编辑/分区)。
- 前端构建:`stash-web npm run build`(tsc -b + vite)。
- 视觉/交互:Playwright 脚本在 `stash-ui/.ref/*.mjs`(不提交 git),截图在 `stash-ui/shots/`。
- `measure-final.mjs` **已过时**(旧 1:1 对照用),选择器 `.app header` 仍可用;
- `c3-check.mjs`、`d-check.mjs`、`p2-check.mjs` 是后期功能检查脚本。
- 常用冒烟:首页卡片、`/admin` 各 Tab、详情编辑、登录弹窗。
## 9. 已知限制 / 未做
| 项 | 状态 |
| 项 | 说明 |
| --- | --- |
| 备份恢复/导入 | 只提供下载快照;恢复需停机替换 `stash.db` |
| 回收站/软删除 | 未做,删除分组会级联删子分组与条目(有二次确认) |
| 图片上传 | 只支持 URL;上传需存储目录、大小/类型校验 |
| 拖拽排序 | 分组/卡片顺序暂用创建顺序;上移下移/拖拽未做 |
| 多用户/角色 | 未做,当前单管理员 |
| 分组移动 | 编辑弹窗里可改「上级分组」(防环);根分组不能移到子级 |
| 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) |
| 分组排序 | 可编辑「排序权重」数值;拖拽排序未做 |
| 旧接口 | `/api/catalog`、`/api/categories/*`、`/api/groups/*` 仍保留给旧组件/兼容,后续可删 |
| 登录限流 | 进程内计数,重启清空 |
| 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 |
| 搜索引擎 | 内置六个(含 DuckDuckGo,有品牌图标,不能删只能停用)+ 后台添加的自定义引擎(最多 10 个,通用地球图标) |
| 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 |
| 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 |
| 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 |
| 备份恢复 | 只有 `/api/backup` 下载;恢复需停机替换库文件 |
| 旧接口 | 已删;不要再按旧文档调用 `/api/catalog` 或 categories |
| 双份示例 | `public/git_helper/` 是 Rust 托管示例;`stash-web/public/git_helper/` 是 Vite dev 示例,**两份都保留** |
| exe 占用 | 重启/构建前先杀 stash.exe,否则 `cargo build` 拒绝访问 |
| 编码 | 源码 UTF-8;PowerShell 控制台可能显示乱码,以 read 工具为准,别用 Set-Content 改中文源码 |
## 10. 约定与坑
## 11. 最近提交(主项目,倒序)
- 回复用中文,表格化优先;视觉工作要截图验证。
- **任何共享组件改动**都要在 `docs/COMPONENTS.md` 补契约 + changelog,并说明兼容性。
- PowerShell 写文件注意 UTF-8;优先用编辑工具,别用 `Set-Content` 写带中文的源码。
- 改前端后必须 `npm run build` 再 `cargo build`,否则内嵌资源是旧的。
- 重启前先杀 `stash.exe`,否则 cargo 报「拒绝访问」。
- `/login` 是旧地址,`main.tsx` 会重定向到 `/`;登录已改为弹窗。
- 前端路由:`/`、`/g/<分组 key>`、`/g/<分组 key>/i/<条目>`;旧 `/c/...` 地址按第一个路径段当分组 key 兼容,`/admin` 独立壳。
```text
1d1ece6 fix(group): 分组面包屑保留详情页外框
9d1e5dc fix(group): 分组页面包屑与详情页统一
4862f8f fix(search): 外部引擎模式不再过滤本地内容
aa6f341 feat(search): 按参考稿重做搜索框并支持后台配置
5ae2088 style(search): 引擎图标恢复 18px
200908c style(search): 引擎图标统一使用组件库图标
56f362f feat(search): 点击放大镜切换搜索引擎
8c1c658 feat(search): 效果五快捷栏加入本地搜索
038b7ec feat(search): 搜索框支持切换搜索引擎
a5d7859 fix(theme): 用整页快照过渡消除明暗割裂
5439ebf feat(admin): 增加背景管理
b9005dd style(theme): 暗色使用 Alright 同款深色背景
```
## 11. 新会话建议的下一步
组件库最近:
1. 先跑 `cargo test` + 打开 8080 冒烟,确认基线;
2. 若要继续产品化:条目移动 UI、回收站、图片上传、拖拽排序;
3. 若要发布:确认 `stash-ui` 以 `file:` 引入的方式在发布构建里如何锁定版本(建议改为 git tag 依赖或构建前 `npm pack`)。
```text
994436a feat(icons): 增加搜索引擎图标
46829cd feat(input): 增加左侧 leading 插槽
b97c77e feat(search): SearchField 增加 suffix 插槽
```
## 12. 新会话建议顺序
1. 打开 <http://127.0.0.1:8080> 与 `/admin` 冒烟;确认 8080 在跑(当前 PID 会变,不必依赖 PID)。
2. 改前端:`cd stash-web && npm run build` → 杀 stash → `cargo build` → 启动。
3. 改组件库:先 `cd stash-ui && npm run build`,再构建 `stash-web`,最后 Rust。
4. 改设置结构:同时改 `src/db/settings.rs`、`stash-web/src/settings.ts`、后台面板和默认值;旧 JSON 由 serde 默认值兜底。
5. 改数据库结构:升 `SCHEMA_VERSION` 并补迁移与迁移测试;先备份 `target/debug/data/stash.db`。
6. 视觉改动用 Playwright 截图到 `stash-ui/shots/` 验证;脚本在 `stash-ui/.ref/`(不提交)。
7. 收尾跑 `cargo test`(52 条)和 `npm run build`,确认工作区干净。
+173 -116
View File
@@ -7,14 +7,28 @@ Stash 是一个用 Rust 编写的本地静态页面托管工具。编译后的 R
## 快速开始
组件库 [`stash-ui`](https://gitea.ngmao.com/CNWei/stash-ui) 以 `file:` 方式挂在 `stash-web` 上,
所以**两个仓库要放在同一级目录**:
```text
<任意目录>/
├── stash/ # 本仓库
└── stash-ui/ # 组件库,必须先构建出 dist
```
```bash
# 1. 先构建前端(dist 会被内嵌,缺了它 cargo build 会直接失败)
# 1. 先构建组件库(stash-web 依赖它的 dist)
cd stash-ui
npm install && npm run build
cd ../stash
# 2. 再构建前端(dist 会被内嵌,缺了它 cargo build 会直接失败)
cd stash-web
npm install
npm run build
cd ..
# 2. 编译并运行
# 3. 编译并运行
cargo run # 托管 ./public,监听 127.0.0.1:8080
# 自定义目录 / 端口 / 对外暴露
@@ -22,6 +36,9 @@ cargo run -- --root ./sites --port 3000
cargo run -- --root ./sites --host 0.0.0.0 --port 3000
```
改了组件库之后要重新 `npm run build`(库)→ `npm run build`(stash-web)→ `cargo build`,
顺序不能反:`cargo build` 内嵌的是 `stash-web/dist`。
启动后打开终端里打印的地址即可,例如 `http://127.0.0.1:8080/`。
默认托管目录 `./public` 不存在时会自动创建;首次启动还会与二进制同级创建 `data/`,
并在其中生成内容库 `stash.db`(已内置示例内容,可以直接用 SQLite 工具修改)。
@@ -34,11 +51,18 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
### 登录与设置(v0.1.1 起)
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组以列表查看/编辑条目,每条可单独设格式,主分组含子分组)、侧边栏(侧栏卡片显隐 + 分组导航勾选)、首页卡片(本地服务只读表 + 左侧卡片列表 + 分组表格,可拖动排序),以及页头滚动信息、资料与系统设置;
- 登录后点「站点设置」进入 `/admin`,左侧导航 + 右侧内容:
- **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除;
- **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除;
- **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示);
- **资料管理**与**系统管理**(改密码、下载整库备份;系统管理里还能设正文字体:内置文楷 / 字体链接二选一,链接模式下可存多套字体(`font/` 目录里的文件或外部链接)并随时启用其中一套);
- 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效;
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
- `/admin` 的「系统管理」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
- 读接口公开(本地工具可只读使用),写接口(设置、改密等)要求登录;
- 读接口公开(本地工具可只读使用),写接口(分组、条目、卡片、设置、改密)要求登录并校验同源 `Origin`;
- 明暗配色:站点默认值在「页头 → 配色」(跟随系统 / 浅色 / 深色),前台页头的月亮按钮写的是**本机偏好**(localStorage),不需要登录;
- 完整接口清单可以直接开站内页面 `/interface` 查看;
- 忘记密码:删除 `admin` 表里的那一行(或整个库)后重启,会回到「设置密码」;
- 方案与后续阶段见 [`docs/ADMIN-PLAN.md`](docs/ADMIN-PLAN.md)。
@@ -50,8 +74,10 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| --- | --- |
| `/api/*` | JSON / SSE 接口 |
| `/`、`/index.html` | 内嵌前端的 `index.html`(`no-cache`) |
| `/c/**` | 前端路由地址(分类明细 / 条目详情),没有实体文件时同样返回 `index.html` |
| `/c/**`、`/g/**` | 前端路由地址(分组页 / 条目详情),没有实体文件时同样返回 `index.html` |
| `/admin`、`/login`、`/interface` | 固定页面地址,同样返回 `index.html` |
| `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) |
| `/fonts/**` | 程序同级 `font/` 目录里的字体文件(后台字体设置用,`no-cache`) |
| `/favicon.svg` | 内嵌前端根目录文件 |
| 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` |
| 仍未命中 | 内置 404 页面(显示请求路径) |
@@ -66,8 +92,10 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| 地址 | 页面 |
| --- | --- |
| `/` | 首页卡片(按后台勾选的分组组合) |
| `/g/<分组>` | 分组页(自身内容 + 子分组) |
| `/g/<分组>`、`/c/<分组>` | 分组页(自身内容 + 子分组) |
| `/g/<分组>/i/<条目>` | 条目详情 |
| `/interface` | 接口一览(站内页面,侧边栏「查看接口」进入) |
| `/admin` | 站点设置(左侧导航 + 右侧内容) |
地址里的每一段都是**业务键**(也就是接口 JSON 里的 `id`):库里是 `node.key` / `entry.key`,
本地项目则是目录名(访问地址仍然是 `route_path`,两者不再混用)。
@@ -78,12 +106,13 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
仍然落回条目所属的分组,而不是去猜一个返回目标;
- 「返回」按钮在应用内有上一页时交给浏览器(`history.back()`),
直接打开地址(刷新 / 收藏,应用内没有上一页)时退到上面的推导结果;
- 服务端只把 `/c/**` 回落到入口页(`src/server.rs` 的 `SPA_PREFIXES`),
托管目录里写错的地址仍然是 404 页面——不做「所有未命中都返回 200」的万能回落,
否则分不清是路由拼错还是文件真的不存在;
- 服务端只把 `/c/`、`/g/` 两个前缀和 `/admin`、`/login`、`/interface` 三个固定地址回落到入口页
(`src/server.rs` 的 `SPA_PREFIXES` / `SPA_PAGES`),托管目录里写错的地址仍然是 404 页面——
不做「所有未命中都返回 200」的万能回落,否则分不清是路由拼错还是文件真的不存在;
- 地址转换规则在 [`stash-web/src/data/view.ts`](stash-web/src/data/view.ts),
与 history 打交道的部分在 [`stash-web/src/useViewRoute.ts`](stash-web/src/useViewRoute.ts)。
新增一类页面时两处都要改:前端加前缀,服务端把它加进 `SPA_PREFIXES`。
实际解析与跳转在 [`stash-web/src/AppNext.tsx`](stash-web/src/AppNext.tsx) 的 `useRoute` / `navigate`。
新增一类页面时两处都要改:前端加前缀,服务端把它加进 `SPA_PREFIXES` 或 `SPA_PAGES`;
- 站内跳转统一走 `navigate()`(`AppNext.tsx`),不整页刷新;按住 Ctrl/Cmd 点卡片或链接仍可新标签打开。
## 目录结构
@@ -102,20 +131,28 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
│ ├── watcher.rs # 文件监听、防抖、快照广播
│ └── db/ # SQLite 内容库
│ ├── mod.rs # 打开、PRAGMA、种子导入时机
│ ├── schema.rs # 建表语句与种子导入
│ ├── schema.rs # 建表语句、版本迁移、种子导入
│ ├── catalog.rs # 查询结果组装成前端 Category[]
│ ├── settings.rs # 站点设置的 JSON 结构与校验
│ └── seed.json # 内嵌种子数据(编译进二进制)
├── stash-web/ # React + Vite + TypeScript 控制台
│ ├── package.json # 含 volta 版本固定
│ ├── package.json # 含 volta 版本固定;@stash/ui 用 file:../../stash-ui 引入
│ ├── vite.config.ts # 开发期 /api 代理到 127.0.0.1:8080
│ └── src/
│ ├── AppNext.tsx # 前台外壳:路由、卡片、侧栏、搜索
│ ├── theme.ts # 明暗主题的本机偏好(localStorage)
│ ├── settings.ts # 站点设置的读写与默认值
│ └── components/
│ ├── admin/ # 后台(AdminNext:左侧导航 + 各面板)
│ ├── api/ # 接口一览页
│ ├── header/ # 页头(品牌、滚动信息、操作图标)
│ └── sidebar/ # 侧栏卡片与本地服务面板
├── data/ # 内容库目录(与二进制同级,运行时生成)
│ └── stash.db # SQLite 内容库
└── public/ # 默认托管目录(可放多个前端项目)
```
`templates/` 与 `assets/` 是上一版 Askama 控制台留下的文件,当前已无任何代码引用,
可以直接删除。
../stash-ui/ # 同级目录:@stash/ui 组件库(令牌 / 组件 / 展示站)
```
## 前端怎么被内嵌
@@ -140,17 +177,22 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
### 表结构
三张表 + 一张展示配置表:
七张表:
- **分区与分组是同一张自引用树**——它们都是「装条目的容器」,只是层级不同;
- **侧栏直接由这棵树派生**(分区下挂分组),不再有独立的导航表;
- **首页分区展示什么由 `section_block` 决定**,卡片不再混排所有分组。
- **分组(`node`)是唯一容器**——分区与分组是同一张自引用树,只是层级不同;
- **内容(`entry`)只挂在分组上**,不直接挂在分区;
- **首页卡片由 `card` + `card_group` 决定**:卡片只存标题与顺序,勾选哪些分组、每个分组预览几条写在 `card_group`;
- **侧栏直接由分组树派生**(`enabled` 与 `sort`),不再有独立的导航表;
- **站点设置整份存在 `setting`**(JSON + `version` 乐观锁),`admin` / `session` 存管理员与会话,`audit` 记操作。
文章正文是一整段 Markdown,直接存在 `entry.body` 里(一篇文章一行),不再拆成正文块表。
主键统一是 `id INTEGER PRIMARY KEY`(SQLite rowid,自动分配,不用 `AUTOINCREMENT`),
另有一列 `key` 作为**唯一业务编码**;外键一律写成 `<目标>_key`,指向对方的 `key`。
表结构有版本号:`PRAGMA user_version` 记录当前版本(`src/db/schema.rs` 的 `SCHEMA_VERSION`,
当前 22),启动时逐级迁移旧库,不需要删库重建。
#### node —— 分区与分组(一棵容器树)
| 字段 | 类型 | 说明 |
@@ -160,17 +202,15 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| `parent_key` | TEXT? | 父节点编码:分组指向所属分区,分区为 NULL |
| `title` | TEXT | 展示名:分区标题 / 分组小标题 |
| `kind` | TEXT | `section` 顶层分区 / `group` 分区下的分组 |
| `view_mode` | TEXT? | 仅分区:`apps` / `posters` / `articles`,决定渲染形态 |
| `bar_color` | TEXT? | 仅分区:标题左侧 4×16 色条颜色(如 `#3858f6`) |
| `icon` | TEXT? | 仅分区:明细页 hero 左侧的字符图标 |
| `preview_limit` | INTEGER? | 仅分区:首页预览条数;为空时按类型取默认 |
| `empty_text` | TEXT? | 仅分区:分区为空时的提示文案 |
| `show_in_sidebar` | INTEGER | 是否出现在侧栏,`0` / `1`;0 表示只当首页卡片/内容,不进导航 |
| `bar_color` | TEXT? | 标题左侧色条颜色(如 `#3858f6`) |
| `icon` | TEXT? | 图标:可以是字符(`🎬`),也可以是 iconfont 图标名(`film_line`) |
| `preview_limit` | INTEGER? | 分组在卡片里的默认预览条数;为空时按类型取默认 |
| `enabled` | INTEGER | 是否启用,`0` / `1`;停用后侧栏与卡片都不再出现该子树 |
| `sort` | INTEGER | 同级顺序,越小越靠前 |
两条约束写在表上:
- `CHECK`:`section` 必须 `parent_key IS NULL` 且有 `view_mode`;`group` 必须有父节点且无 `view_mode`;
- `CHECK`:`section` 必须 `parent_key IS NULL`;`group` 必须有父节点;
- `key` 唯一(UNIQUE),全局不重复。
#### entry —— 内容条目(网站 / 电影 / 文章共用)
@@ -180,6 +220,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
| `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 |
| `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 |
| `format` | TEXT | 展示格式:`apps` / `posters` / `articles`;逐条设置,同一分组可混合 |
| `name` | TEXT | 名称:网站名 / 电影名 / 文章标题 |
| `description` | TEXT? | 一句话简介,列表卡片的副标题 |
| `icon` | TEXT? | 字符图标,封面缺失时的占位 |
@@ -208,7 +249,7 @@ WHERE COALESCE(p.parent_key, p.key) = 'recommend';
好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况;
`node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。
三种内容共用一张表,只是**用到的字段不同**,形态由所属分区的 `view_mode` 决定:
三种内容共用一张表,只是**用到的字段不同**,形态由每条自己的 `entry.format` 决定:
| 类型 | 常用字段 |
| --- | --- |
@@ -241,21 +282,32 @@ WHERE COALESCE(p.parent_key, p.key) = 'recommend';
主键 `(entry_key, sort)`。海报的 `tags` 与文章的 `meta` 都存这里——它们都是「有序字符串列表」,
前端只是在不同版式里渲染。
#### section_block —— 首页卡片的展示配置
#### card / card_group —— 首页卡片
| 字段 | 类型 | 说明 / 前端用途 |
````text
card 一张卡片 = 标题 + 顺序(内容由 card_group 勾选,卡片本身不存条目)
card_group 卡片包含哪些分组 + 每个分组的预览条数与先后
````
| `card` 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
| `node_key` | TEXT | 要展示的节点编码,外键 → `node.key`;通常是分组,电影/文章填分区自己 |
| `title` | TEXT? | 块标题;为空时用节点 `title` |
| `preview_limit` | INTEGER? | 卡片显示几条;为空时用分类默认值 |
| `sort` | INTEGER | 卡片顺序 |
| `id` | INTEGER | 主键(AUTOINCREMENT) |
| `key` | TEXT | 业务键,唯一;`local-service` 是内置的本地服务卡片 |
| `title` | TEXT | 卡片标题 |
| `sort` | INTEGER | 卡片在首页的先后 |
**不存所属分区**:一张卡片属于哪个分区,由 `node_key` 推导——节点是分组就取 `parent_key`,
节点是分区就取它自己。所以这一列没必要重复存。
| `card_group` 字段 | 类型 | 说明 |
| --- | --- | --- |
| `card_id` | INTEGER | 外键 → `card.id`,删卡片级联 |
| `group_key` | TEXT | 外键 → `node.key`,删分组级联 |
| `sort` | INTEGER | 分组在这张卡片里的先后 |
| `preview_limit` | INTEGER? | 这个分组预览几条;为空用分组自己的默认值 |
一张卡片展示「你选定的那个分组」,而不是把分区下所有分组混在一起;
点卡片标题或「···」都打开这个块指向的节点。
主键 `(card_id, group_key)`:同一张卡片里一个分组只出现一次。
一张卡片可以混合展示多个分组(不同格式也能混),顺序与条数各自独立。
旧版的 `section_block` 表已在 v23 迁移中删除;旧库升级时会先把数据回填到
`card` / `card_group`,再删表,不会丢内容。
### 表关系
@@ -264,10 +316,9 @@ node (kind=section) 1 ──── n node (kind=group)
│ │
└────────── n entry ───────┘
│
├──── n entry_tag (标签 / 元信息)
└──── n section_block (首页卡片,指向节点)
└──── n entry_tag (标签 / 元信息)
section_block ──── 1 node(分区由 node 自己推导,不单独存)
card 1 ──── n card_group ──── 1 node(勾选的分组 + 每个分组的预览条数)
```
| 关系 | 外键 | 级联 |
@@ -275,83 +326,82 @@ section_block ──── 1 node(分区由 node 自己推导,不单独存
| 分区 → 分组 | `node.parent_key → node.key` | 删分区连带删分组 |
| 节点 → 条目 | `entry.node_key → node.key` | 删分区或分组连带删条目;改编码时条目自动跟随 |
| 条目 → 标签 | `entry_tag.entry_key → entry.key` | 删条目删标签 |
| 展示块 → 节点 | `section_block.node_key → node.key` | 删被展示的节点连带删该卡片 |
| 卡片 → 分组 | `card_group.card_id → card.id`、`card_group.group_key → node.key` | 删卡片或删分组都会清掉这一行 |
几条约束值得记住:
- **API 只暴露业务编码**:响应里的 `id` 都是 `key`,数字主键只在库内使用;
- `entry.node_key`:应用指向分组,海报与图文直接指向分区;
- **API 只暴露业务编码**:响应里的 `id` 都是 `key`,数字主键只在库内使用(卡片例外,用数字 id);
- `entry.node_key`:应用指向分组,海报与图文也指向分组(分组可以是根分组);
- 条目 `key` 是主键,全局唯一;
- **侧栏就是内容树**:分区展开是它的分组,名称取自 `node.title`,不再有别名或跨区编排;
- 首页卡片展示哪个分组由 `section_block.node_key` 决定,改一行即可换内容。
- **侧栏就是内容树**:分组按 `parent_key` 展开,名称取自 `node.title`,没有别名或跨区编排;
- **首页卡片 = 勾选的分组集合**:`card_group.sort` 决定分组在卡片里的先后,`preview_limit` 决定这个分组预览几条。
### 前端怎么用这些表
| 界面 | 用到的表 | 关键字段 |
| --- | --- | --- |
| 首页分区卡片 | `node`(section) + `section_block` + `entry` | 卡片标题用展示块标题(默认被展示节点的 `title`),色条用分区 `bar_color`;预览条目取该节点下的前 N 条 |
| 分类明细页 | `node`(section/group) + `entry` | `icon`(hero 图标)、分区 `title` 或分组 `title`、条目卡片字段 |
| 首页卡片 | `card` + `card_group` + `node` + `entry` | 卡片标题用 `card.title`;色条取该卡片第一个分组的 `bar_color`;每个分组按 `card_group.sort` 排序、按 `preview_limit` 取前 N 条 |
| 分组页 | `node` + `entry` | `icon`(hero 图标)、`title`、条目字段;根分组只列直接条目,子分组另起一块 |
| 应用详情 | `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`)`description` `cover` `body` + `meta` |
| 侧栏导航 | `node` | 走 `parent_key` 的分区 + 分组,`show_in_sidebar = 0` 的节点跳过;「本地服务」由前端排除 |
| 侧栏导航 | `node` | 走 `parent_key` 的分组树,`enabled = 0` 的节点跳过;「本地服务」由前端排除 |
| 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 |
### 侧栏导航
侧栏不再是单独一层数据,**直接由内容树派生**:
侧栏不再是单独一层数据,**直接由分组树派生**:
```text
常用推荐(section,可折叠)
├── 常用站点(group → 打开该分组)
常用推荐(根分组,可折叠)
├── 常用站点(子分组 → 打开该分组)
├── UI 设计资源
├── 矢量图标
├── 免费壁纸
└── 开发工具
推荐电影(section,无分组 → 直接打开)
CES 现场报道(section,无分组 → 直接打开)
推荐电影(根分组,无子分组 → 直接打开)
CES 现场报道(根分组,无子分组 → 直接打开)
```
规则很简单:
- 分区有分组时渲染成可折叠节点,子项就是分组;
- 分区没有分组时(电影 / 文章)整行是链接;
- 有子分组时渲染成可折叠节点,子项就是子分组;
- 没有子分组时整行是链接;
- 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层;
- `show_in_sidebar = 0` 的节点不出现(种子把「常用站点」设成了 0,因为它已经是首页卡片);
- 「本地服务」是前端拼出来的分类,侧栏按 id 直接排除。
- `enabled = 0` 的节点连同整棵子树都不出现;
- 顺序取 `node.sort`(在「分组管理」里改「排序权重」);
- 「本地服务」是前端拼出来的分类,侧栏按 key 直接排除。
### 首页展示块
### 首页卡片
首页卡片展示什么由 `section_block` 决定,不再把分区下所有分组混排:
一张卡片 = 一个标题 + 一组勾选的分组,分组各自渲染、可以混合格式:
```sql
-- 常用推荐卡片改成展示「UI 设计资源」分组,最多 6 条
UPDATE section_block
SET node_key = 'ui', preview_limit = 6
WHERE node_key = 'common';
-- 看看「常用推荐」这张卡片勾了哪些分组、各自几条
SELECT cg.group_key, n.title, cg.sort, cg.preview_limit
FROM card_group cg
JOIN node n ON n.key = cg.group_key
WHERE cg.card_id = (SELECT id FROM card WHERE key = 'card-common')
ORDER BY cg.sort;
```
每张卡片一行,`node_key` 指向要展示的节点;它属于哪个分区由节点自己决定。
点卡片标题或「···」都会打开这个节点;电影 / 文章这类没有分组的,`node_key` 填分区自己的编码。
**卡片标题跟着展示内容走**:配了展示块时用块标题(没配则用节点 `title`),
所以「常用推荐」这张卡片会显示成「常用站点」——它实际展示的就是这个分组。
需要单独起名时,写 `section_block.title` 即可。
需要调整时就改 `card_group`:`sort` 是分组在卡片里的先后,`preview_limit` 是预览条数(空表示用分组自己的默认值)。
这些在后台的「首页管理 → 内容卡片」里都能点点鼠标改完,改完即时保存。
### 自己改内容
1. 用任何 SQLite 工具(DB Browser for SQLite、`sqlite3` CLI、DBeaver…)打开 `data/stash.db`;
2. 内容改 `node`(分区与分组)与 `entry`(文章正文就是 `entry.body` 里的 Markdown),标签改 `entry_tag`,首页展示改 `section_block`;
2. 内容改 `node`(分组树)与 `entry`(文章正文就是 `entry.body` 里的 Markdown),标签改 `entry_tag`,首页卡片改 `card` / `card_group`;
3. 提交修改后,页面会在 **5 秒内**自动刷新——服务端轮询 `PRAGMA data_version` 检测外部写入;
4. 也可以点侧栏的「重新扫描」或直接刷新浏览器。
新增条目时 `id` 不用填,`key` 必须写(全局唯一);`node_key` 填直接所在的分组编码
(没有分组就填分区编码),所属分区由它推导。
新增条目时 `id` 不用填,`key` 必须写(全局唯一);`node_key` 填直接所在的分组编码。
先 `SELECT key, kind, parent_key FROM node` 看一眼现有编码即可。
### 重置
开发阶段表结构还会调整,**没有实现迁移**。要重置内容:关掉服务后删掉 `data/stash.db`,
表结构有版本号(`PRAGMA user_version`,当前 22),启动时会**自动逐级迁移**旧库,
正常升级不用删库。只有在想回到出厂内容时才需要重置:关掉服务后删掉 `data/stash.db`,
下次启动会重新建表并灌入内嵌种子。
```bash
@@ -359,19 +409,12 @@ rm data/stash.db # Linux / macOS
Remove-Item data\stash.db # Windows PowerShell
```
库结构版本记在 `PRAGMA user_version` 里(当前为 `15`)。新版本二进制遇到旧库时会**直接拒绝启动**
并提示删库,而不是拿旧表执行新查询:
```text
[FATAL] 启动失败: 数据库错误: 内容库结构版本为 14,当前程序需要 15。
开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。
```
`SCHEMA_VERSION` 定义在 [`src/db/schema.rs`](src/db/schema.rs),改动表结构时把它 +1。
`SCHEMA_VERSION` 定义在 [`src/db/schema.rs`](src/db/schema.rs),改动表结构时把它 +1 并补一段迁移。
## 前端开发
```bash
cd stash-ui && npm install && npm run dev # 组件库文档站 http://127.0.0.1:5199
cd stash-web
npm install
npm run dev # http://127.0.0.1:5173,/api 自动代理到后端 8080
@@ -379,8 +422,9 @@ npm run build # 产物输出到 stash-web/dist
npm run lint # oxlint
```
开发时前后端分开跑:一个终端 `cargo run`,另一个终端 `npm run dev`。
控制台的用户资料目前是占位数据(见 `stash-web/src/profile.ts`),后端尚未提供鉴权接口。
开发时前后端分开跑:一个终端 `cargo run`,另一个终端(stash-web 目录下)`npm run dev`。
改了 `@stash/ui` 里的组件要重新 `npm run build`(库)才会被 stash-web 用到;
`npm run dev` 只会热更 stash-web 自己的代码。
## 命令行参数
@@ -443,41 +487,38 @@ data: {"projects":[{"name":"docs","route_path":"/docs/"}]}
### 内容目录
```http
GET /api/catalog
GET /api/catalog2
```
返回控制台需要的全部分类(结构见 [`types.ts`](stash-web/src/data/types.ts)),
数据来自 `data/stash.db`。一次全量下发,前端搜索在客户端完成:
返回分组树 + 首页卡片 + 站点设置(结构见 [`types.ts`](stash-web/src/data/types.ts)),
数据来自 `data/stash.db`,一次全量下发,前端搜索在客户端完成:
```json
{
"categories": [
"groups": [
{
"id": "recommend",
"kind": "apps",
"key": "pin",
"title": "常用推荐",
"barColor": "#3858f6",
"icon": "🔖",
"block": { "nodeKey": "common", "previewLimit": 4 },
"groups": [
{
"id": "common",
"name": "常用站点",
"items": [
{ "id": "github", "name": "GitHub", "desc": "代码托管与开源社区", "views": 429 }
]
}
]
"enabled": true,
"sort": 1,
"entries": [{ "id": "1", "name": "1", "format": "apps" }],
"children": [{ "key": "common", "title": "常用站点", "entries": [] }]
}
],
"cards": [
{
"id": 1,
"key": "card-pin",
"title": "常用推荐",
"groups": [{ "groupKey": "pin", "previewLimit": 4 }]
}
]
}
```
「本地服务」分类**不在**这个接口里,它由 `/api/projects` 扫描托管目录后在前端拼接。
每个分类上的 `block` 就是首页卡片的展示配置:`nodeKey` 指向要展示的节点(通常是分组),
`previewLimit` 是卡片里显示几条。侧栏不再单独下发,前端直接用 `categories` 里的
分区与分组渲染。
「本地服务」卡片在 `cards` 里也有一行 `local-service`,但内容由 `/api/projects` 运行时生成。
侧栏不再单独下发,前端直接用 `groups` 派生。
### 订阅内容库变化
@@ -486,14 +527,28 @@ GET /api/catalog/events
Accept: text/event-stream
```
当内容库被**其它连接**(例如用户用 DB Browser 改库)提交后推送一次,事件本身不带数据:
当内容库变化后推送一次(自己写入和其它连接写入都会触发),事件本身不带数据:
```text
event: catalog
data: {"changed":true}
```
客户端收到后重新 `GET /api/catalog`。检测基于 SQLite 的 `PRAGMA data_version`,每 5 秒轮询一次。
客户端收到后重新 `GET /api/catalog2`。检测基于 `PRAGMA data_version` 轮询 + 写接口主动广播。
### 其它接口
完整清单(方法 / 路径 / 权限)可以直接开站内页面 [`/interface`](http://127.0.0.1:8080/interface) 查看,
摘要如下:
| 分组 | 接口 |
| --- | --- |
| 健康与项目 | `GET /api/health`、`GET /api/projects`、`GET /api/projects/events`(SSE) |
| 内容库 | `GET /api/catalog2`、`GET /api/catalog/events`(SSE)、`POST /api/nodes`、`PATCH/DELETE /api/nodes/{key}`、`GET /api/items/{key}`、`POST /api/items`、`PATCH/DELETE /api/items/{key}`、`POST /api/items/{key}/view`、`POST /api/items/{key}/like`、`POST /api/cards`、`PATCH/DELETE /api/cards/{id}`、`PUT /api/cards/{id}/groups` |
| 站点设置 | `GET/PATCH /api/settings`、`GET /api/fonts`(`font/` 目录里的字体清单)、`GET /api/backup` |
| 认证 | `GET /api/auth/session`、`POST /api/auth/setup`、`POST /api/auth/login`、`POST /api/auth/logout`、`PATCH /api/auth/password` |
写接口(POST / PATCH / PUT / DELETE)都要求登录会话,并校验请求的 `Origin` 与本站同源。
## 测试
@@ -512,15 +567,17 @@ $env:TEMP = "$PWD\target\test-tmp"; $env:TMP = $env:TEMP; cargo test
## 已知限制
- 前端路由只有 `/c/**` 一个前缀;新增一类页面时,`stash-web/src/data/view.ts` 与
`src/server.rs` 的 `SPA_PREFIXES` 必须一起改,否则刷新那条地址会拿到 404 页面。
- 前端路由前缀是 `/c/`、`/g/`,固定页面是 `/admin`、`/login`、`/interface`;新增一类页面时,
`stash-web/src/data/view.ts` 与 `src/server.rs` 的 `SPA_PREFIXES` / `SPA_PAGES` 必须一起改,
否则刷新那条地址会拿到 404 页面。
- 前进 / 后退能回到对应页面,但不恢复滚动位置:切换视图统一滚回内容区顶部。
- 内嵌资源占用了 `/assets/` 前缀,托管目录下如果存在同名目录会被遮蔽。
- `/` 固定由控制台占用,上一版「根目录有 `index.html` 就直接当单站点展示」的行为已取消;
托管目录里的项目统一走 `/<目录名>/`。
- 项目扫描只检查一级子目录,不递归查找更深层项目。
- 文件事件经过约 150ms 防抖,新项目出现在控制台可能有短暂延迟。
- 内容库没有迁移机制:表结构变化时需要删掉 `data/stash.db` 重建(当前属于开发阶段)。
- 内容库被外部工具修改后,页面最长有 5 秒延迟才自动刷新。
- 内容库被其它工具修改后,页面最长有 5 秒延迟才自动刷新;自己的写操作会立即广播。
- 内容库按单实例使用设计:多个进程同时写同一个库会在 `busy_timeout`(5 秒)后报错。
- 没有身份认证,默认只建议绑定 `127.0.0.1`;使用 `0.0.0.0` 暴露到局域网前请确认目录内容适合被其他设备访问。
- 单管理员账号:只有一套密码,没有多用户 / 多角色;默认只建议绑定 `127.0.0.1`,
用 `0.0.0.0` 暴露到局域网前请确认目录内容适合被其它设备访问。
- 主题偏好按浏览器存(localStorage),换设备不会跟随;站点默认值在后台改。
-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");
});
})();
+74 -5
View File
@@ -93,11 +93,11 @@ DELETE /api/items/:id
6. **CSP / 安全头**:内嵌页面与 iconfont 的 CSP 白名单。
### 数据与迁移
7. **迁移机制**:现在没有迁移,`user_version` 只是标记;加表必须写迁移 + 升级测试。
8. **默认值与校验**:设置 JSON 的 schema 校验、缺字段回退默认、坏 JSON 不能白屏。
9. **备份/恢复**:编辑前自动备份(`stash.db.bak`)、一键导出/导入。
10. **审计**:谁改了什么(单用户也需要排查)。
11. **乐观锁/冲突**:多标签页同时编辑。
7. ~~**迁移机制**:现在没有迁移,`user_version` 只是标记~~ → **已完成**:`SCHEMA_VERSION = 22`,启动时按版本逐级迁移(`src/db/schema.rs`),加表要连迁移一起写。
8. **默认值与校验**:设置 JSON 的 schema 校验、缺字段回退默认、坏 JSON 不能白屏。(已做:`src/db/settings.rs` 校验 + 前端默认值合并)
9. **备份/恢复**:编辑前自动备份(`stash.db.bak`)、一键导出/导入。(已做:`GET /api/backup` 导出快照)
10. **审计**:谁改了什么(单用户也需要排查)。(已做:`audit` 表)
11. **乐观锁/冲突**:多标签页同时编辑。(已做:`settings.version` 与 `entry.updated_at`)
12. **草稿/发布**:编辑后立即生效,还是保存草稿再发布?(内容库现在没有状态字段)
13. **撤销**:误删条目能否恢复(软删除 + 回收站)。
@@ -147,6 +147,75 @@ DELETE /api/items/:id
## 11. 实施记录
### P29(2026-10-31,搜索框不受虚化/透明度影响)
- 移除 `.search-row > *:first-child` 的半透明与 `backdrop-filter` 覆盖,搜索框回到组件默认实底白色;
- 顶部软虚化只作用于搜索框周围区域,不再透过输入框;
- 验证:搜索框背景 `rgb(255,255,255)`、`backdrop-filter: none`、opacity=1。
### P28(2026-10-31,搜索框 30% 透明与分组块底部留白)
- 搜索框背景透明度从 50% 调到 30%(`rgba(255,255,255,0.3)`),保留 blur;
- 侧栏 `.sidebar-home` 增加 24px 底部内边距,分组导航块滚到底时距离视口底部保留 24px;
- 验证:搜索框 alpha=0.30;页面滚到底时 navBottom=856、viewportBottom=880,间距 24px。
### P27(2026-10-31,分类块圆角与顶部软虚化)
- 侧栏导航 `.nav-card` 是普通 div(不是 Card),之前没有圆角;补 `border-radius: 12px` + `overflow: hidden`,和资料/服务卡一致;
- 搜索框透明度提到 50%,模糊提到 14px;
- 新增 `.top-blur`:固定在 header 下方(top 60、height 120),`blur(8px)` + 18% 页面色渐变,并用上下 `mask-image` 渐隐,避免再次出现分割线;
- 验证:nav 圆角 12px、top-blur 存在且 blur 生效、搜索框仍可点击、滚动时顶部软虚化无硬边。
### P26(2026-10-31,顶部留白加高)
- 首页顶部偏移 `TOP_OFFSET` 从 88 调到 104,搜索框与内容卡片之间的空白从 28px 增加到 44px;
- Layout `contentOffset` 与 `#page-scroll` 的 `margin-top`/`height` 使用同一个常量,左右两栏仍从 164 对齐开始;
- 验证:content/sider top=164,search top=76 / bottom=120。
### P25(2026-10-31,去掉顶部块的接缝)
- 删除 `.top-dock` 整块:不再有 60–148 的半透明填充与底边,避免出现一条横向分割线;
- 搜索框继续 fixed 悬浮,页头保持实底;顶部与内容之间只剩页面背景的自然渐变;
- 背景图渐隐保持 80%,与内容起点无硬边;
- 验证:`.top-dock` 数量 0,左侧与右侧顶部背景连续。
### P24(2026-10-31,独立的半透明顶部块)
- 页头恢复实底(`headerVariant="solid"`),去掉 `.app > header` 的透明度覆盖;
- 页头下方新增独立 `.top-dock`:`position: fixed; top:60px; height:88px`,背景 28% 页面色 + `blur(10px)` + 半透明底边,作为承载搜索框的单独块,而不是让整条看起来像背景填充;
- 搜索框保持 60% 半透明;
- 背景图渐隐层恢复 80%(撤掉上一轮为露出背景做的 65%);
- 验证:dock top=60、height=88、alpha=0.28;header 纯白;搜索框可见;无 mask 元素。
### P23(2026-10-31,去掉遮罩、顶部透明)
- 删除 `scroll-fade` 整条遮罩及创建逻辑,页面不再有顶部渐隐/模糊层;
- 页头改为 `headerVariant="blur"`,并用 `.app > header` 把底色降到 55%;搜索框底色降到 60% 并保留轻微 blur;
- 背景图渐隐层高度从 80% 调到 65%,让页头到内容之间那一条保持背景原色,不再发白;
- 验证:`.scroll-fade` 数量为 0;header 背景 alpha=0.55;搜索框 alpha=0.60。
### P22(2026-10-31,遮罩合并为整条)
- 去掉内容区自带的 `.content-veil`,只保留一条固定遮罩;
- 遮罩宽度从侧栏左边缘一直覆盖到内容区右边缘(实测 left=50、width=1340),左右同一水平高度(top=148),不再像两个块拼接;
- 渐变、模糊参数不变,滚动后整体淡入。
### P21(2026-10-31,侧栏虚化遮罩与透明度)
- 侧栏顶部增加独立 fixed 遮罩层:位置跟随侧栏左右/宽度,固定在滚动区顶部(y=148),滚动后淡入;
- 遮罩用 `backdrop-filter: blur(14px)` + 45%→20%→透明的页面色渐变,既虚化又不挡内容;不能挂在侧栏内部(会被吸底 `translateY` 带走);
- 左侧资料卡 / 本地服务 / 导航卡改成 64% 不透明 + `backdrop-filter` 毛玻璃,露出页面背景;
- 修复 Tailwind/构建器只保留 `-webkit-backdrop-filter` 导致 Chromium 不生效的问题:只写标准属性;
- 验证:滚动后遮罩 top=148、opacity=1、backdrop-filter=blur(14px) saturate(1.1),卡片背景 alpha=0.64。
### P20(2026-10-31,双栏非对称吸底滚动)
- 两栏顶部对齐:去掉首页侧栏额外的 `padding-top`,两栏都从 `#page-scroll` 顶部(y=148)开始;
- 隐藏滚动条:`#page-scroll` 使用 `scrollbar-width: none` / `::-webkit-scrollbar { display:none }`;
- 非对称吸底:仍共用一个滚动容器;滚动时用 `translateY(max(0, scrollTop - (侧栏高度 - 视口高度)))` 补偿左栏,左侧内容到底后吸附在视口底部,右侧继续滚动;
- 右侧先到底、左侧更长时不补偿,两栏同步滚到整体底部;`ResizeObserver` 在内容变化时重算;
- 验证:初始左右 top 均为 148;滚动 120/300/600 时左侧 top 稳定在 144、bottom 贴住 900,右侧正常滚动;页面无滚动条。
### P19(2026-10-31,左右两栏共用滚动容器)
- 不再用 `sider.scrollTop = content.scrollTop` 模拟同步:把 Layout 的 body(`#page-scroll`)设为滚动容器,`margin-top: 88px`、高度 `calc(100% - 88px)`,左右两栏都在这个容器里;
+26
View File
@@ -4,6 +4,8 @@ use crate::error::StashError;
/// 数据目录名:与二进制同级,存放可被用户直接修改的内容库。
pub const DATA_DIR_NAME: &str = "data";
/// 字体目录名:与二进制同级,后台「系统管理 → 字体」只从这个目录列字体。
pub const FONT_DIR_NAME: &str = "font";
/// 内容库文件名:固定为这个名字,一个实例一个库。
pub const DB_FILE_NAME: &str = "stash.db";
@@ -13,6 +15,8 @@ pub struct Config {
pub root: PathBuf,
/// 数据目录:存放内容库与将来的备份 / 导出文件
pub data_dir: PathBuf,
/// 字体目录:后台字体下拉只列这里的文件,通过 `/fonts/<文件名>` 对外提供
pub font_dir: PathBuf,
/// 内容库路径,固定为 `data/stash.db`
pub db_path: PathBuf,
/// 监听端口
@@ -34,6 +38,7 @@ impl Default for Config {
root: PathBuf::from("../public"),
db_path: resolve_db_path(&data_dir),
data_dir,
font_dir: PathBuf::from(FONT_DIR_NAME),
port: 8080,
host: "127.0.0.1".to_string(),
directory_listing: true,
@@ -65,6 +70,18 @@ pub fn resolve_db_path(data_dir: &Path) -> PathBuf {
data_dir.join(DB_FILE_NAME)
}
/// 字体目录的默认位置:**二进制所在目录**下的 `font/`,理由同 `data/`。
pub fn default_font_dir() -> PathBuf {
default_font_dir_from(std::env::current_exe().ok().as_deref())
}
fn default_font_dir_from(exe: Option<&Path>) -> PathBuf {
match exe.and_then(Path::parent) {
Some(dir) => dir.join(FONT_DIR_NAME),
None => PathBuf::from(FONT_DIR_NAME),
}
}
/// 校验内容库没有落在静态托管目录里。
///
/// `ServeDir` 会把 `--root` 下的所有文件当静态资源对外提供,
@@ -151,6 +168,15 @@ mod tests {
assert_eq!(default_data_dir_from(None), PathBuf::from(DATA_DIR_NAME));
}
#[test]
fn font_dir_follows_the_binary_directory() {
assert_eq!(
default_font_dir_from(Some(Path::new(r"D:\apps\stash\stash.exe"))),
PathBuf::from(r"D:\apps\stash\font")
);
assert_eq!(default_font_dir_from(None), PathBuf::from(FONT_DIR_NAME));
}
#[test]
fn db_path_is_fixed_inside_the_data_dir() {
assert_eq!(
+12 -343
View File
@@ -11,23 +11,10 @@ use rusqlite::Connection;
use serde_json::{Map, Value, json};
use crate::{
db::{Catalog, map_error},
db::map_error,
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` 表的列一一对应
struct EntryRow {
key: String,
@@ -47,19 +34,11 @@ struct EntryRow {
likes: i64,
}
/// 读出整个内容目录。
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
Ok(Catalog {
categories: load_sections(conn)?,
})
}
/// 分组树节点(新视图用)
struct TreeNode {
key: String,
parent: Option<String>,
title: String,
view_mode: Option<String>,
icon: Option<String>,
bar_color: Option<String>,
preview_limit: Option<i64>,
@@ -67,11 +46,11 @@ struct TreeNode {
enabled: bool,
}
/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。
/// 新的原子视图:完整分组树(多层、带条目),供新首页/侧栏/后台使用。
pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
let mut stmt = conn
.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",
)
.map_err(map_error)?;
@@ -82,12 +61,11 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
key: row.get(0)?,
parent: row.get(1)?,
title: row.get(2)?,
view_mode: row.get(3)?,
icon: row.get(4)?,
bar_color: row.get(5)?,
preview_limit: row.get(6)?,
sort: row.get(7)?,
enabled: row.get(8)?,
icon: row.get(3)?,
bar_color: row.get(4)?,
preview_limit: row.get(5)?,
sort: row.get(6)?,
enabled: row.get(7)?,
})
})
.map_err(map_error)?
@@ -96,48 +74,22 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
drop(stmt);
let index: HashMap<String, usize> = nodes
.iter()
.enumerate()
.map(|(idx, node)| (node.key.clone(), idx))
.collect();
// 子节点索引(按 sort 已排好序)
let mut children: HashMap<Option<String>, Vec<usize>> = HashMap::new();
for (idx, node) in nodes.iter().enumerate() {
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)?;
fn render(
conn: &Connection,
nodes: &[TreeNode],
children: &HashMap<Option<String>, Vec<usize>>,
formats: &[String],
tags: &HashMap<String, Vec<String>>,
idx: usize,
) -> Result<Value, StashError> {
let node = &nodes[idx];
let format = &formats[idx];
let entries = load_node_entries(conn, &node.key)?;
let items = entries
.iter()
@@ -148,7 +100,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
.get(&Some(node.key.clone()))
.map(|list| {
list.iter()
.map(|child| render(conn, nodes, children, formats, tags, *child))
.map(|child| render(conn, nodes, children, tags, *child))
.collect::<Result<Vec<_>, _>>()
})
.transpose()?
@@ -158,7 +110,6 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
"key": node.key,
"parentKey": node.parent,
"title": node.title,
"format": format,
"icon": node.icon.clone().unwrap_or_default(),
"barColor": node.bar_color.clone().unwrap_or_default(),
"previewLimit": node.preview_limit,
@@ -173,7 +124,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
.get(&None)
.map(|list| {
list.iter()
.map(|idx| render(conn, &nodes, &children, &formats, &tags, *idx))
.map(|idx| render(conn, &nodes, &children, &tags, *idx))
.collect::<Result<Vec<_>, _>>()
})
.transpose()?
@@ -203,164 +154,6 @@ fn load_all_tags(conn: &Connection) -> Result<HashMap<String, Vec<String>>, Stas
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 {
let mut value = match entry.format.as_str() {
@@ -374,55 +167,6 @@ fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> 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 {
let mut object = Map::new();
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` 升序。
///
/// 条目只存 `node_key`,所属分区由父节点推导:
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
/// 取某个分区下的全部条目(含分组内的),海报 / 图文平铺用。
fn load_section_entries(
conn: &Connection,
section_key: &str,
) -> Result<Vec<EntryRow>, StashError> {
/// 取某个节点直接挂着的条目,按 `sort` 升序。
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> {
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(
"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,
@@ -589,32 +287,3 @@ fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>,
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'`,带
//! `view_mode` 格式),下面的节点都叫分组(`kind='group'`),可以继续嵌套。
//! 条目挂在任意节点上,格式由最近的根分组决定。
//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`),
//! 下面的节点都叫分组(`kind='group'`),可以继续嵌套。
//! 条目的展示格式由 `entry.format` 决定,分组不再保存格式。
use rusqlite::{Connection, OptionalExtension, params};
use serde::Deserialize;
use crate::{db::entries, db::map_error, error::StashError};
const FORMATS: [&str; 3] = ["apps", "posters", "articles"];
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewNode {
@@ -20,9 +18,6 @@ pub struct NewNode {
/// 为空表示新建一个根分组
#[serde(default)]
pub parent_key: Option<String>,
/// 根分组的格式:apps / posters / articles(子分组继承,不需要传)
#[serde(default)]
pub format: Option<String>,
/// 图标:可以是 iconfont 名或字符;空字符串表示不显示
#[serde(default)]
pub icon: Option<String>,
@@ -41,8 +36,6 @@ pub struct UpdateNode {
/// 传入即移动到新的父节点;不支持移回根(根分组请直接新建)
pub parent_key: Option<String>,
pub sort: Option<i64>,
/// 只对根分组生效
pub format: Option<String>,
/// 图标:空字符串表示清除图标
pub icon: Option<String>,
pub enabled: Option<bool>,
@@ -78,7 +71,7 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
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) => {
let parent_exists: i64 = conn
.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 {
return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
}
("group", None)
}
None => {
let format = new.format.unwrap_or_else(|| "apps".into());
if !FORMATS.contains(&format.as_str()) {
return Err(StashError::InvalidDirectory("格式不合法".into()));
}
("section", Some(format))
"group"
}
None => "section",
};
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)?;
tx.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, icon, enabled, sort, show_in_sidebar, allow_groups)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, 1)",
"INSERT INTO node (key, parent_key, title, kind, icon, enabled, sort)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
key,
new.parent_key,
title,
kind,
view_mode,
icon,
enabled,
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> {
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(
"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],
|row| {
Ok((
@@ -157,15 +143,13 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
))
},
)
.optional()
.map_err(map_error)?;
let Some((current_title, current_parent, current_format, current_sort, current_icon, current_enabled)) =
current
let Some((current_title, current_parent, current_sort, current_icon, current_enabled)) = current
else {
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(),
};
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 {
Some(icon) => {
let icon = icon.trim().to_string();
@@ -268,10 +232,10 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
};
tx.execute(
"UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4,
icon = ?5, enabled = ?6
WHERE key = ?7",
params![title, parent_key, format, final_sort, icon, enabled, key],
"UPDATE node SET title = ?1, parent_key = ?2, sort = ?3,
icon = ?4, enabled = ?5
WHERE key = ?6",
params![title, parent_key, final_sort, icon, enabled, key],
)
.map_err(map_error)?;
entries::audit(&tx, "node.update", key, &title)?;
+94 -128
View File
@@ -10,7 +10,6 @@
pub mod auth;
pub mod cards;
pub mod categories;
mod catalog;
pub mod entries;
pub mod groups;
@@ -34,15 +33,6 @@ pub use settings::{Settings, SettingsDocument};
/// 条目编辑(P2)。
pub use entries::{EntryPatch, EntryRecord};
/// 一次查询得到的完整内容目录。
///
/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。
#[derive(Debug)]
pub struct Catalog {
/// 分类树,结构对应前端 `types.ts` 的 `Category[]`
pub categories: Vec<Value>,
}
/// 内容库句柄。
///
/// 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`:**其它连接**提交后该值会变,本连接自己的写入不变。
///
/// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。
@@ -217,41 +201,6 @@ impl Db {
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()?;
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`。
@@ -363,8 +307,8 @@ mod tests {
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
.expect("应能统计条目");
let blocks: i64 = conn
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
.expect("应能统计展示块");
.query_row("SELECT COUNT(*) FROM card", [], |row| row.get(0))
.expect("应能统计首页卡片");
let tags: i64 = conn
.query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0))
.expect("应能统计标签");
@@ -383,27 +327,26 @@ mod tests {
assert_eq!(tags, 48, "标签与元信息共用 entry_tag");
// 正文是一篇一段 Markdown,只有 12 篇文章有
assert_eq!(articles_with_body, 12, "只有图文条目带正文");
assert_eq!(blocks, 3, "每个分区一个首页展示块");
assert_eq!(blocks, 3, "每个分区一张首页卡片");
}
/// 分区与分组是同一张表:分组必须挂在分区下,且不能有 view_mode。
/// 分区与分组是同一张表:分组必须挂在分区下。
#[test]
fn groups_are_nodes_under_their_section() {
let conn = seeded();
let (parent_key, view_mode): (String, Option<String>) = conn
let parent_key: String = conn
.query_row(
"SELECT p.key, g.view_mode
"SELECT p.key
FROM node g
JOIN node p ON p.key = g.parent_key
WHERE g.kind = 'group' AND g.key = 'ui'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
|row| row.get(0),
)
.expect("应能查到分组及其父节点");
assert_eq!(parent_key, "recommend");
assert!(view_mode.is_none(), "分组不应带 view_mode");
// 分区一定是顶层节点
let root_sections: i64 = conn
@@ -419,92 +362,102 @@ mod tests {
#[test]
fn catalog_matches_the_frontend_model() {
let conn = seeded();
let catalog = catalog::load(&conn).expect("应能组装内容目录");
let categories = &catalog.categories;
let tree = catalog::load_tree(&conn).expect("应能组装分组树");
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];
assert_eq!(apps["id"], "recommend");
assert_eq!(apps["kind"], "apps");
assert_eq!(apps["barColor"], "#3858f6");
assert_eq!(apps["showInSidebar"], true);
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];
let common = groups
.iter()
.find(|node| node["key"] == "common")
.expect("应有常用站点");
let first_app = &common["entries"][0];
assert_eq!(first_app["name"], "GitHub");
assert_eq!(first_app["format"], "apps");
assert_eq!(first_app["hot"], true);
// 计数从 JSON 提到了独立列,这里确认读取时又合并回来了
assert_eq!(first_app["views"], 429);
let posters = &categories[1];
assert_eq!(posters["kind"], "posters");
// 海报与图文用 title 而不是 name
assert_eq!(
posters["items"][0].get("name"),
None,
let posters = roots
.iter()
.find(|node| node["key"] == "movie")
.expect("应有推荐电影");
let first_poster = &posters["entries"][0];
assert_eq!(first_poster["format"], "posters");
assert!(
first_poster.get("name").is_none(),
"海报不应同时下发 name 字段"
);
assert!(posters["items"][0]["title"].is_string());
// tags 来自 entry_tag 子表
assert!(first_poster["title"].is_string());
assert_eq!(
posters["items"][0]["tags"]
first_poster["tags"]
.as_array()
.expect("海报应有标签")
.len(),
2
);
let articles = &categories[2];
assert_eq!(articles["kind"], "articles");
// 正文是一整段 Markdown,而不是块数组
let body = articles["items"][0]["body"]
.as_str()
.expect("图文应有正文");
let articles = roots
.iter()
.find(|node| node["key"] == "ces")
.expect("应有 CES 现场报道");
let first_article = &articles["entries"][0];
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("\n\n"), "段落之间用空行分隔");
assert!(
articles["items"][0].get("blocks").is_none(),
first_article.get("blocks").is_none(),
"不应再下发 blocks 数组"
);
// meta 与海报的 tags 共用 entry_tag 表
assert_eq!(
articles["items"][0]["meta"]
first_article["meta"]
.as_array()
.expect("图文应有元信息")
.len(),
2
);
// ---- 首页展示块 ----
// 常用推荐卡片展示「常用站点」分组,最多 4 条
assert_eq!(apps["block"]["nodeKey"], "common");
assert_eq!(apps["block"]["previewLimit"], 4);
// 电影/文章没有分组,块直接指向分区自己
assert_eq!(posters["block"]["nodeKey"], "movie");
assert_eq!(articles["block"]["nodeKey"], "ces");
// 卡片只记录勾选的分组;常用推荐卡片展示「常用站点」,最多 4 条
let (group_key, preview_limit): (String, Option<i64>) = conn
.query_row(
"SELECT cg.group_key, cg.preview_limit
FROM card c JOIN card_group cg ON cg.card_id = c.id
WHERE c.key = 'card-recommend'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("应能查到常用推荐卡片");
assert_eq!(group_key, "common");
assert_eq!(preview_limit, Some(4));
}
/// 删除分区时,它的展示块一起消失(外键级联),不会留下指向空分区的卡片。
/// 删除分组时,卡片里勾选它的记录一起消失(外键级联)。
#[test]
fn section_blocks_cascade_with_their_section() {
fn card_groups_cascade_with_their_group() {
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
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
.expect("应能统计展示块");
.query_row(
"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() {
let conn = seeded();
for table in ["node", "entry", "section_block"] {
for table in ["node", "entry", "card"] {
let pk: String = conn
.query_row(
&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'", [])
.expect("应能改分区业务键");
let catalog = catalog::load(&conn).expect("应能组装内容目录");
let apps = catalog
.categories
let tree = catalog::load_tree(&conn).expect("应能组装分组树");
let roots = tree.as_array().expect("根节点应为数组");
let apps = roots
.iter()
.find(|category| category["id"] == "recommend-v2")
.find(|node| node["key"] == "recommend-v2")
.expect("应能找到改名后的分区");
let total: usize = apps["groups"]
.as_array()
.expect("应有分组")
.iter()
.map(|group| group["items"].as_array().expect("应有条目").len())
.sum();
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()
.map_or(0, |items| items.iter().map(count).sum());
own + children
}
assert_eq!(total, 18, "改名后条目仍应归在这个分区下");
assert_eq!(count(apps), 18, "改名后条目仍应归在这个分区下");
}
/// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。
@@ -654,7 +608,19 @@ mod tests {
let fresh = Connection::open_in_memory().expect("内存库应能打开");
fresh
.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 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;",
+67 -64
View File
@@ -27,9 +27,8 @@ const SEED_JSON: &str = include_str!("seed.json");
/// 种子会自动重新灌一份干净数据。
const SCHEMA_SQL: &str = r#"
-- 内容节点:分区与分组共用一张自引用树
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_code 必须为 NULL
-- kind = 'group' → 分区下的分组,parent_code 指向所属分区
-- view_mode 只对分区有意义,决定首页与明细页怎么渲染
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_key 必须为 NULL
-- kind = 'group' → 分区下的分组,parent_key 指向分区或上层分组
CREATE TABLE IF NOT EXISTS node (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT)
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui)
@@ -38,22 +37,15 @@ CREATE TABLE IF NOT EXISTS node (
title TEXT NOT NULL, -- 展示名:分区标题 / 分组小标题
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
-- 容器层级
view_mode TEXT CHECK (view_mode IN ('apps', 'posters', 'articles')),
-- 分区渲染形态;分组为 NULL
bar_color TEXT, -- 分区标题左侧色条颜色(分组为 NULL)
icon TEXT, -- 分区 hero 字符图标(分组为 NULL)
bar_color TEXT, -- 标题左侧色条颜色
icon TEXT, -- 字符图标
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)),
-- 是否启用(禁用后前台侧栏/卡片不展示)
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
CHECK (
(kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL)
OR (kind = 'group' AND parent_key IS NOT NULL AND view_mode IS NULL)
(kind = 'section' AND parent_key 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)
);
-- 首页卡片:一行一个卡片,说明展示哪个节点
-- 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:登录与设置 ==========
-- 管理员:单用户场景只允许一行
@@ -226,6 +206,41 @@ const V22_SQL: &str = r#"
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 回填。
const V21_SQL: &str = r#"
ALTER TABLE entry ADD COLUMN format TEXT NOT NULL DEFAULT 'apps';
@@ -302,7 +317,7 @@ pub struct SeedReport {
///
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
pub const SCHEMA_VERSION: i64 = 22;
pub const SCHEMA_VERSION: i64 = 23;
fn current_version(conn: &Connection) -> Result<i64, StashError> {
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;
}
if version == 22 {
conn.execute_batch(V23_SQL).map_err(map_error)?;
version = 23;
}
conn.pragma_update(None, "user_version", version)
.map_err(map_error)?;
@@ -416,18 +436,14 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
}
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)
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
"INSERT INTO node (key, parent_key, title, kind, bar_color, icon, preview_limit, sort)
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6)",
params![
section.key,
section.title,
view,
section.bar_color,
section.icon,
section.preview_limit,
section.empty_text,
i64::from(section.show_in_sidebar),
i64::from(view == "apps"),
section.sort,
],
)
@@ -437,15 +453,9 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
if view == "apps" {
for group in &section.groups {
tx.execute(
"INSERT INTO node (key, parent_key, title, kind, show_in_sidebar, sort)
VALUES (?1, ?2, ?3, 'group', ?4, ?5)",
params![
group.key,
section.key,
group.title,
i64::from(group.show_in_sidebar),
group.sort
],
"INSERT INTO node (key, parent_key, title, kind, sort)
VALUES (?1, ?2, ?3, 'group', ?4)",
params![group.key, section.key, group.title, group.sort],
)
.map_err(map_error)?;
report.groups += 1;
@@ -461,17 +471,26 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
}
}
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身)
// 首页卡片:仓库里直接生成卡片与勾选的分组
tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort)
VALUES (?1, ?2, ?3, ?4)",
"INSERT INTO card (key, title, sort) VALUES (?1, ?2, ?3)",
params![
section.block.node_key,
section.block.title,
section.block.preview_limit,
format!("card-{}", section.key),
section
.block
.title
.clone()
.unwrap_or_else(|| section.title.clone()),
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| {
StashError::Database(format!(
"分区 {} 的展示节点 {} 无效: {error}",
@@ -481,9 +500,6 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
report.blocks += 1;
}
// 老结构(分区 → 展示块)同步生成卡片,保证新首页立刻有内容
tx.execute_batch(BACKFILL_CARDS_SQL).map_err(map_error)?;
tx.commit().map_err(map_error)?;
Ok(report)
@@ -565,11 +581,6 @@ struct SeedSection {
sort: i64,
#[serde(default)]
preview_limit: Option<i64>,
#[serde(default)]
empty_text: Option<String>,
/// 是否出现在侧栏;默认 true
#[serde(default = "default_true")]
show_in_sidebar: bool,
/// 仅 apps 分区使用
#[serde(default)]
groups: Vec<SeedGroup>,
@@ -601,17 +612,9 @@ struct SeedGroup {
key: String,
title: String,
sort: i64,
/// 是否出现在侧栏;默认 true
#[serde(default = "default_true")]
show_in_sidebar: bool,
entries: Vec<SeedEntry>,
}
/// serde 默认值:侧栏默认显示
fn default_true() -> bool {
true
}
/// 一条内容:字段与 `entry` 表的列一一对应。
///
/// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta);
+418 -59
View File
@@ -13,6 +13,10 @@ pub const SETTINGS_KEY: &str = "site";
pub const MAX_HEADER_ITEMS: usize = 8;
pub const MIN_SPEED: u32 = 8;
pub const MAX_SPEED: u32 = 120;
pub const MAX_FONT_LINKS: usize = 20;
/// 内置搜索引擎:有品牌图标,后台只能改名称 / 地址 / 启停,不能删除。
pub const BUILTIN_ENGINE_IDS: [&str; 6] =
["google", "bing", "baidu", "sogou", "360", "duckduckgo"];
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
@@ -50,6 +54,8 @@ pub struct HeaderSettings {
pub enabled: bool,
/// 滚动一轮的秒数
pub speed: u32,
/// 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽
pub gap: u32,
pub items: Vec<HeaderItem>,
}
@@ -58,6 +64,7 @@ impl Default for HeaderSettings {
Self {
enabled: true,
speed: 24,
gap: 26,
items: vec![
HeaderItem {
text: "共 {count} 个本地应用".into(),
@@ -88,13 +95,7 @@ impl Default for SidebarSettings {
Self {
show_profile: true,
show_service: 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(),
show_nav: true,
}
}
}
@@ -108,33 +109,125 @@ pub struct SidebarSettings {
/// 是否显示「本地服务」面板
#[serde(default = "default_true")]
pub show_service: 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>>,
/// 是否显示「分组导航」卡片(首页 / 分组树)
#[serde(default = "default_true")]
pub show_nav: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct SectionsSettings {
/// 隐藏的首页卡片(分类 id)
pub hidden: Vec<String>,
/// 卡片顺序(分类 id)
pub order: Vec<String>,
/// 标题覆盖:id → 标题
pub titles: std::collections::HashMap<String, String>,
/// 预览条数覆盖:id → 条数
pub limits: std::collections::HashMap<String, u32>,
pub struct BackgroundSettings {
/// 亮色背景图地址;空表示用内置图
pub light: String,
/// 暗色背景图地址;空表示用内置图
pub dark: String,
}
/// 正文字体来源:内置霞鹜文楷,或用户存的字体链接。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum FontSource {
#[default]
Builtin,
Custom,
}
/// 一条存下来的字体配置:名字只用于后台下拉,url 才是真正加载的东西。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct FontLink {
/// 唯一编码,由前端生成(删除 / 重排都不影响当前选择)
pub key: String,
/// 后台下拉里显示的名字
pub name: String,
/// 字体文件或 CSS 链接:`/fonts/xxx` 或 `http(s)://…`
pub url: String,
/// 字体族名:链接到 CSS 时填 CSS 里声明的名字;字体文件留空则用 `Stash Font`
pub family: String,
}
/// 正文字体设置:先选来源(内置 / 字体链接),选「字体链接」时用 `current` 指向启用的那条。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct FontSettings {
/// 字体来源:内置文楷,或下面保存的字体链接
pub source: FontSource,
/// 当前启用的 `FontLink.key`;为空时用第一条
pub current: String,
/// 存下来的字体:常用字体放这里,切换时不用重新填
pub items: Vec<FontLink>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct SearchEngineSettings {
pub id: String,
pub name: String,
/// 搜索地址模板,用 `{query}` 表示关键词
pub url: String,
/// 自定义图标地址(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)]
@@ -160,13 +253,98 @@ impl Default for ProfileSettings {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct BrandSettings {
/// 品牌文字(页头左侧,可留空只显示 logo)
pub name: String,
/// 没有 logo 图片时,圆形标记里显示的首字
pub mark: String,
/// logo 图片地址;空字符串表示用首字
pub logo: String,
}
impl Default for BrandSettings {
fn default() -> Self {
Self {
name: "STASH".into(),
mark: "S".into(),
logo: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ActionSettings {
/// 明暗切换图标
#[serde(default = "default_true")]
pub theme: bool,
/// 刷新图标(重扫本地项目并重拉内容库)
pub refresh: bool,
/// 站点设置图标(仅登录后出现)
pub settings: bool,
/// 登录 / 退出登录
pub session: bool,
}
impl Default for ActionSettings {
fn default() -> Self {
Self {
theme: true,
refresh: true,
settings: true,
session: true,
}
}
}
/// 配色:浅色 / 深色 / 跟随系统。
/// 落到 `<html data-scheme>` 上,深色由组件库的令牌块接管,页面不用改组件。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThemeSetting {
Light,
Dark,
#[default]
Auto,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SiteMetaSettings {
/// 浏览器标签标题
pub title: String,
/// favicon 地址;空字符串表示用内置图标
pub favicon: String,
/// 配色
pub theme: ThemeSetting,
pub brand: BrandSettings,
pub actions: ActionSettings,
}
impl Default for SiteMetaSettings {
fn default() -> Self {
Self {
title: "Stash · 本地应用工作区".into(),
favicon: String::new(),
theme: ThemeSetting::Auto,
brand: BrandSettings::default(),
actions: ActionSettings::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct Settings {
pub header: HeaderSettings,
pub sidebar: SidebarSettings,
pub sections: SectionsSettings,
pub background: BackgroundSettings,
pub font: FontSettings,
pub search: SearchSettings,
pub profile: ProfileSettings,
pub site: SiteMetaSettings,
}
/// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。
@@ -176,6 +354,16 @@ pub struct SettingsDocument {
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> {
if settings.header.items.is_empty() {
@@ -191,6 +379,9 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
"滚动速度需在 {MIN_SPEED}–{MAX_SPEED} 秒之间"
)));
}
if settings.header.gap > 120 {
return Err(StashError::InvalidDirectory("信息间距最多 120".into()));
}
for item in &settings.header.items {
let text = item.text.trim();
if text.is_empty() {
@@ -205,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 {
return Err(StashError::InvalidDirectory("名称最多 40 个字".into()));
}
@@ -244,6 +408,132 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
if settings.profile.location.chars().count() > 40 {
return Err(StashError::InvalidDirectory("位置最多 40 个字".into()));
}
if settings.background.light.chars().count() > 500 {
return Err(StashError::InvalidDirectory(
"亮色背景地址最多 500 个字符".into(),
));
}
if settings.background.dark.chars().count() > 500 {
return Err(StashError::InvalidDirectory(
"暗色背景地址最多 500 个字符".into(),
));
}
if settings.font.items.len() > MAX_FONT_LINKS {
return Err(StashError::InvalidDirectory(format!(
"字体最多保存 {MAX_FONT_LINKS} 条"
)));
}
let mut font_keys = std::collections::HashSet::new();
for link in &settings.font.items {
let key = link.key.trim();
if key.is_empty() || key.chars().count() > 60 {
return Err(StashError::InvalidDirectory("字体编码需为 1–60 个字符".into()));
}
if !font_keys.insert(key) {
return Err(StashError::InvalidDirectory("字体编码不能重复".into()));
}
let name = link.name.trim();
if name.is_empty() || name.chars().count() > 60 {
return Err(StashError::InvalidDirectory("字体名称需为 1–60 个字".into()));
}
let url = link.url.trim();
if url.chars().count() > 500 {
return Err(StashError::InvalidDirectory("字体链接最多 500 个字符".into()));
}
if url.is_empty()
|| !(url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("/fonts/"))
{
return Err(StashError::InvalidDirectory(
"字体链接需以 http(s):// 或 /fonts/ 开头".into(),
));
}
if link.family.chars().count() > 100 {
return Err(StashError::InvalidDirectory("字体族名最多 100 个字符".into()));
}
}
if settings.font.source == FontSource::Custom {
let current = settings.font.current.trim();
if !current.is_empty() && !font_keys.contains(current) {
return Err(StashError::InvalidDirectory("当前启用的字体不存在".into()));
}
}
if settings.search.engines.len() > 10 {
return Err(StashError::InvalidDirectory("搜索引擎最多 10 个".into()));
}
let mut ids = std::collections::HashSet::new();
for engine in &settings.search.engines {
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 {
return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into()));
}
if settings.site.favicon.chars().count() > 500 {
return Err(StashError::InvalidDirectory("favicon 地址最多 500 个字符".into()));
}
if settings.site.brand.name.chars().count() > 20 {
return Err(StashError::InvalidDirectory("品牌文字最多 20 个字".into()));
}
if settings.site.brand.mark.chars().count() > 4 {
return Err(StashError::InvalidDirectory("logo 标记最多 4 个字".into()));
}
if settings.site.brand.logo.chars().count() > 500 {
return Err(StashError::InvalidDirectory("logo 地址最多 500 个字符".into()));
}
Ok(())
}
@@ -349,7 +639,6 @@ mod tests {
let settings = Settings::default();
assert!(settings.header.enabled);
assert_eq!(settings.header.items.len(), 3);
assert!(settings.sections.hidden.is_empty());
assert!(validate(&settings).is_ok());
}
@@ -377,6 +666,56 @@ mod tests {
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]
fn invalid_settings_are_rejected() {
let mut conn = db();
@@ -387,6 +726,26 @@ mod tests {
let mut empty = Settings::default();
empty.header.items.clear();
assert!(save(&mut conn, empty, 0).is_err());
let mut bad_font = Settings::default();
bad_font.font.items.push(FontLink {
key: "font-1".into(),
name: "测试".into(),
url: "javascript:alert(1)".into(),
family: String::new(),
});
assert!(save(&mut conn, bad_font, 0).is_err());
let mut ok_font = Settings::default();
ok_font.font.items.push(FontLink {
key: "font-1".into(),
name: "测试".into(),
url: "/fonts/x.woff2".into(),
family: String::new(),
});
ok_font.font.source = FontSource::Custom;
ok_font.font.current = "font-1".into();
assert!(save(&mut conn, ok_font, 0).is_ok());
}
#[test]
-11
View File
@@ -7,17 +7,6 @@ pub struct AppProject {
pub route_path: String,
}
impl AppProject {
pub fn initial(&self) -> String {
self.name
.chars()
.next()
.unwrap_or('A')
.to_uppercase()
.to_string()
}
}
pub fn scan_projects(root: &Path) -> Vec<AppProject> {
let mut projects = Vec::new();
+9 -1
View File
@@ -3,7 +3,7 @@ use clap::Parser;
use colored::*;
use stash::{
Config,
config::{default_data_dir, display_path, resolve_db_path, validate_db_location},
config::{default_data_dir, default_font_dir, display_path, resolve_db_path, validate_db_location},
start,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -94,10 +94,17 @@ async fn main() -> anyhow::Result<()> {
let data_dir = data_dir.canonicalize().unwrap_or(data_dir);
let db_path = resolve_db_path(&data_dir);
// 字体目录:跟 data/ 一样放在二进制旁边,后台只从这里列字体
let font_dir = default_font_dir();
std::fs::create_dir_all(&font_dir)?;
let font_dir = font_dir.canonicalize().unwrap_or(font_dir);
let font_dir_display = display_path(&font_dir);
// 4. 构造配置并启动
let config = Config {
root: abs_root.clone(),
data_dir: data_dir.clone(),
font_dir,
db_path: db_path.clone(),
port: cli.port,
host: cli.host,
@@ -121,6 +128,7 @@ async fn main() -> anyhow::Result<()> {
println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline());
println!(" ➜ 托管路径: {}", display_path(&abs_root).white());
println!(" ➜ 内容库: {}", display_path(&db_path).white());
println!(" ➜ 字体目录: {}", font_dir_display.white());
println!(" ➜ 前端资源: {}", "已内嵌 (stash-web/dist)".white());
println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed());
println!("{}", "--------------------------------------------------".bright_blue());
+162 -216
View File
@@ -1,7 +1,8 @@
use axum::{
Json, Router,
extract::{OriginalUri, Path, State},
extract::{OriginalUri, Path, Request, State},
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
middleware::{self, Next},
response::sse::{Event, KeepAlive, Sse},
response::{Html, IntoResponse, Response},
routing::{get, patch, post, put},
@@ -36,6 +37,9 @@ pub struct AppState {
pub project_updates: broadcast::Sender<Vec<AppProject>>,
/// 内容库:分类 / 分组 / 条目的唯一数据源
pub db: Arc<Db>,
/// 内容库写入后的广播:SSE 用它对所有打开的页面推「该重拉」。
/// 不能只靠 `PRAGMA data_version`——服务自己的连接写完不会让它变化。
pub catalog_updates: broadcast::Sender<()>,
/// 登录失败计数(单用户本地场景,一个进程一份就够)
pub login_guard: Arc<std::sync::Mutex<LoginGuard>>,
}
@@ -160,7 +164,7 @@ fn not_found_page(path: &str) -> Response {
const SPA_PREFIXES: &[&str] = &["/c/", "/g/"];
/// 精确匹配的前端页面(没有实体文件,返回入口页)
const SPA_PAGES: &[&str] = &["/login", "/admin"];
const SPA_PAGES: &[&str] = &["/login", "/admin", "/interface"];
/// 是否为前端路由路径(没有实体文件,应该返回入口页)。
fn is_spa_path(path: &str) -> bool {
@@ -202,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:内容库被外部修改时通知前端重新拉取。
///
/// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件:
@@ -234,30 +213,29 @@ async fn catalog_api(State(state): State<AppState>) -> Response {
/// 而 data_version 恰好在「其它连接提交了事务」时才变化,语义正好对上。
async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse {
let db = state.db.clone();
let mut updates = state.catalog_updates.subscribe();
let stream = async_stream::stream! {
let mut last = db.data_version().unwrap_or(0);
let mut ticker = tokio::time::interval(Duration::from_secs(5));
loop {
ticker.tick().await;
let current = match db.data_version() {
Ok(version) => version,
Err(error) => {
warn!("读取内容库版本失败: {error}");
continue;
}
// 两条来源:本进程的写接口主动广播(快、准),
// 以及 data_version 轮询兜底(外部工具直接改库时用)。
let changed = tokio::select! {
_ = ticker.tick() => match db.data_version() {
Ok(current) if current != last => {
last = current;
true
}
_ => false,
},
received = updates.recv() => received.is_ok(),
};
// 只在其它连接提交之后才推送。
// 当前服务对内容库是只读的;将来若加入写接口,必须在那里主动广播,
// 因为 data_version 不会因本连接自己的提交而变化。
tracing::debug!(last, current, "内容库版本检查");
if current == last {
if !changed {
continue;
}
last = current;
// 只告诉前端「变了」,具体内容由它重新 GET /api/catalog
yield Ok::<Event, Infallible>(
@@ -273,6 +251,36 @@ async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse {
)
}
/// 任何写请求成功后,通知所有打开的页面「内容库变了」。
///
/// 放在中间件里而不是逐个 handler 调:以后新增写接口不用再记得挂号。
/// 登录相关接口不涉及内容库,跳过以免无谓刷新。
async fn notify_catalog_writes(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_owned();
let response = next.run(request).await;
let mutating = matches!(
method,
Method::POST | Method::PATCH | Method::PUT | Method::DELETE
);
if mutating
&& path.starts_with("/api/")
&& !path.starts_with("/api/auth/")
&& response.status().is_success()
{
// 没人订阅(没有开着的页面)时 send 会失败,忽略即可
let _ = state.catalog_updates.send(());
}
response
}
/// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。
async fn project_events(State(state): State<AppState>) -> impl IntoResponse {
let root = state.config.root.clone();
@@ -581,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} —— 编辑表单的初始值(公开,便于未登录时预览)
@@ -931,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 + 内嵌前端 + 托管目录。
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
/// 起监听任务,没法在单元测试里用。
@@ -1115,7 +1031,6 @@ fn build_router(state: AppState) -> Router {
.route("/api/health", get(health_check))
.route("/api/projects", get(list_projects_api))
.route("/api/projects/events", get(project_events))
.route("/api/catalog", get(catalog_api))
.route("/api/catalog2", get(catalog2_api))
.route("/api/nodes", post(create_node))
.route("/api/nodes/{key}", patch(update_node).delete(delete_node))
@@ -1129,24 +1044,24 @@ fn build_router(state: AppState) -> Router {
.route("/api/auth/logout", post(auth_logout))
.route("/api/auth/password", patch(auth_password))
.route("/api/settings", get(get_settings).patch(patch_settings))
.route("/api/fonts", get(list_fonts))
.route("/fonts/{*path}", get(font_asset))
.route("/api/items/{key}", get(get_item).patch(patch_item).delete(delete_item))
.route("/api/items", post(create_item))
.route("/api/items/{key}/view", post(post_view))
.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("/", get(web_index))
.route("/index.html", get(web_index))
.route("/favicon.svg", get(web_root_file))
.route("/assets/{*path}", get(web_asset))
.fallback_service(serve_dir)
.with_state(state)
.with_state(state.clone())
// 写接口成功后广播「内容库变了」,让所有打开的页面自己重拉
.layer(middleware::from_fn_with_state(
state,
notify_catalog_writes,
))
.layer(TraceLayer::new_for_http());
if enable_cors {
@@ -1170,6 +1085,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
let (project_updates, _) = broadcast::channel(32);
watcher::start(config.root.clone(), project_updates.clone())?;
let (catalog_updates, _) = broadcast::channel(16);
// 内容库在启动时打开:建库 / 建表 / 空库时灌种子都在这里完成
let db = Arc::new(Db::open(&config)?);
@@ -1178,6 +1094,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
config: Arc::new(config.clone()),
project_updates,
db,
catalog_updates,
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
};
@@ -1240,15 +1157,44 @@ mod tests {
config.enable_cors = false;
let (project_updates, _) = broadcast::channel(4);
let (catalog_updates, _) = broadcast::channel(4);
build_router(AppState {
config: Arc::new(config),
project_updates,
db: Arc::new(Db::in_memory()),
catalog_updates,
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
})
}
#[test]
fn scan_fonts_lists_only_font_files() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/font-scan");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("应能建目录");
std::fs::write(dir.join("b.ttf"), b"x").expect("应能写入");
std::fs::write(dir.join("a.WOFF2"), b"x").expect("应能写入");
std::fs::write(dir.join("note.txt"), b"x").expect("应能写入");
let fonts = scan_fonts(&dir).expect("应能列目录");
let names: Vec<_> = fonts
.iter()
.map(|font| font["name"].as_str().unwrap_or_default())
.collect();
assert_eq!(names, vec!["a.WOFF2", "b.ttf"]);
assert_eq!(fonts[0]["url"], "/fonts/a.WOFF2");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn scan_fonts_treats_missing_dir_as_empty() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/no-such-font-dir");
let _ = std::fs::remove_dir_all(&dir);
assert!(scan_fonts(&dir).expect("目录不存在不应报错").is_empty());
}
/// 发一个 GET,返回(状态码,Content-Type,响应体)。
async fn get(path: &str) -> (StatusCode, String, String) {
let response = router()
+25 -28
View File
@@ -1,32 +1,29 @@
# React + TypeScript + Vite
# stash-web
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Stash 的前端控制台:React 19 + Vite + TypeScript。组件与样式全部来自
[`@stash/ui`](../stash-ui)(`file:` 依赖,需要先在同级目录构建出它的 `dist`)。
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```bash
npm install
npm run dev # http://127.0.0.1:5173,/api 代理到后端 8080
npm run build # 产物到 dist,随后会被 cargo build 内嵌进二进制
npm run lint # oxlint
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
## 目录
| 路径 | 内容 |
| --- | --- |
| `src/AppNext.tsx` | 前台外壳:路由、卡片、侧栏、搜索、主题应用 |
| `src/components/admin/` | 后台(`AdminNext.tsx`:左侧导航 + 分组 / 数据 / 首页 / 资料 / 系统面板) |
| `src/components/api/` | `/interface` 接口一览页 |
| `src/components/header/`、`sidebar/` | 页头(品牌、滚动信息、操作图标)与侧栏卡片 |
| `src/components/content/` | 卡片、网格、详情视图 |
| `src/data/view.ts`、`src/useViewRoute.ts` | 地址 ↔ 视图的路由规则(与服务端 `SPA_PREFIXES` / `SPA_PAGES` 对应) |
| `src/theme.ts` | 明暗主题:本机偏好(localStorage)优先于站点默认 |
## 约定
- 页面里不写死颜色 / 字号 / 间距,一律用 `@stash/ui` 的 `--su-*` 令牌;
- 站内跳转走 `navigate()`,不要用 `<a href>` 触发整页刷新;需要「新标签打开」时保留 `href` 并放行修饰键点击;
- 组件库里没有的东西优先补进 `stash-ui`,而不是在这里再手写一遍(后台已经是 100% 由库组件组装)。
+11 -3
View File
@@ -4,14 +4,22 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<!-- 与 Alright 主题一致的正文字体:霞鹜文楷 -->
<meta name="color-scheme" content="light dark" />
<script>
// 首帧先按本机偏好上色,避免先浅色再切深色的闪动;站点默认值随后由设置接管。
try {
var saved = localStorage.getItem('stash-theme')
var dark = saved ? saved === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.dataset.scheme = dark ? 'dark' : 'light'
} catch (e) {}
</script>
<!-- 内置正文字体:霞鹜文楷;后台「系统管理 → 字体」可以换成 font 目录里的字体或外部链接 -->
<link
rel="stylesheet"
href="https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/style.min.css"
crossorigin="anonymous"
/>
<title>Stash · 本地应用工作区</title>
<title>Stash</title>
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -24,7 +24,7 @@
},
"../../stash-ui": {
"name": "@stash/ui",
"version": "0.1.0",
"version": "0.3.1",
"devDependencies": {
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
+720 -555
View File
File diff suppressed because it is too large Load Diff
-436
View File
@@ -1,436 +0,0 @@
import { Empty, Layout, Notice, Toaster } from '@stash/ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/common/BackToTop'
import { ArticleDetailView } from './components/content/ArticleDetailView'
import { DetailView } from './components/content/DetailView'
import { HomeView } from './components/content/HomeView'
import { ItemDetailView } from './components/content/ItemDetailView'
import { SiteHeader } from './components/header/SiteHeader'
import { SearchBar } from './components/search/SearchBar'
import { Sidebar } from './components/sidebar/Sidebar'
import { withLocalCategory } from './data/local'
import {
appDetailModel,
posterDetailModel,
posterToRelatedItem,
toRelatedItem,
} from './data/detail'
import { filterCategories } from './data/filter'
import type { NavTarget } from './data/nav'
import {
type Category,
findAppItem,
findArticleItem,
findPosterItem,
relatedAppItems,
relatedPosterItems,
} from './data/types'
import { useProjects } from './useProjects'
import { useCatalog } from './useCatalog'
import { useViewRoute } from './useViewRoute'
import { fetchSession, logout as logoutRequest, type SessionState } from './auth'
import {
applySidebarGroups,
applySidebarSettings,
defaultSettings,
fetchSettings,
type SettingsDocument,
} from './settings'
import { LoginDialog } from './components/auth/LoginDialog'
import { ItemEditor } from './components/content/ItemEditor'
import { parseViewPath, viewPath, type View } from './data/view'
import './App.css'
export default function App() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
// 内容目录来自后端 SQLite 内容库;本地服务分类仍由托管项目动态生成
const catalog = useCatalog()
const [keyword, setKeyword] = useState('')
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
const { view, go } = useViewRoute()
const [session, setSession] = useState<SessionState | null>(null)
const [settings, setSettings] = useState<SettingsDocument>({
version: 0,
settings: defaultSettings(),
})
const [loginOpen, setLoginOpen] = useState(false)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
// 登录态:只用来切换页头按钮与后续的编辑入口
useEffect(() => {
let alive = true
fetchSession().then((next) => {
if (alive) setSession(next)
})
fetchSettings().then((next) => {
if (alive) setSettings(next)
})
return () => {
alive = false
}
}, [])
const categories = useMemo(
() => withLocalCategory(projects, catalog.categories),
[projects, catalog.categories],
)
const visibleCategories = useMemo(
() => filterCategories(categories, keyword),
[categories, keyword],
)
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区;
// 再按 /admin 里的顺序、显隐与分组设置重排一遍
const sidebarCategories = useMemo(
() =>
applySidebarGroups(
applySidebarSettings(
categories.filter(
(category) => category.id !== 'local' && category.showInSidebar !== false,
),
settings.settings.sidebar,
),
settings.settings.sidebar,
),
[categories, settings.settings.sidebar],
)
const detailView = view.type === 'detail' ? view : undefined
const itemView = view.type === 'item' ? view : undefined
const detailCategory =
detailView !== undefined
? categories.find((category) => category.id === detailView.categoryId)
: undefined
const itemCategory =
itemView !== undefined
? categories.find((category) => category.id === itemView.categoryId)
: undefined
// 应用与电影共用一个详情模型,这里按分类类型做映射
const detailModel = useMemo(() => {
if (itemView === undefined || itemCategory === undefined) return undefined
if (itemCategory.kind === 'apps') {
const item = findAppItem(itemCategory, itemView.itemId)
return item !== undefined ? appDetailModel(item) : undefined
}
if (itemCategory.kind === 'posters') {
const poster = findPosterItem(itemCategory, itemView.itemId)
return poster !== undefined ? posterDetailModel(poster) : undefined
}
return undefined
}, [itemView, itemCategory])
const relatedItems = useMemo(() => {
if (itemView === undefined || itemCategory === undefined) return []
if (itemCategory.kind === 'apps') {
return relatedAppItems(itemCategory, itemView.itemId, 4).map(toRelatedItem)
}
if (itemCategory.kind === 'posters') {
return relatedPosterItems(itemCategory, itemView.itemId, 4).map(posterToRelatedItem)
}
return []
}, [itemView, itemCategory])
// 切换视图时把内容区(内部滚动)和窗口都回到顶部
useEffect(() => {
const scroller = document.getElementById('content-scroll')
scroller?.scrollTo({ top: 0 })
window.scrollTo({ top: 0 })
}, [view])
/**
* 滚动状态同时驱动「顶部渐隐」和「回到顶部」。
* 桌面端滚的是内容容器(scroll 事件不冒泡),所以用捕获阶段监听。
*/
useEffect(() => {
const sync = () => {
const inner = document.getElementById('content-scroll')?.scrollTop ?? 0
const next = Math.max(inner, window.scrollY) > 4
setScrolled((current) => (current === next ? current : next))
}
sync()
window.addEventListener('scroll', sync, { passive: true })
document.addEventListener('scroll', sync, { passive: true, capture: true })
return () => {
window.removeEventListener('scroll', sync)
document.removeEventListener('scroll', sync, { capture: true })
}
}, [])
/**
* 「打开这个分区」应该落到哪个视图。
*
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
*/
const detailViewFor = (categoryId: string): View => {
const category = categories.find((item) => item.id === categoryId)
if (category?.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) {
return { type: 'detail', categoryId, groupId: group.id }
}
}
return { type: 'detail', categoryId }
}
const openDetail = (categoryId: string) => go(detailViewFor(categoryId))
/** 条目所在的分组:应用 / 海报 / 图文都可能有分组 */
const groupOfItem = (category: Category, itemId: string): string | undefined => {
return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id
}
const openItem = (categoryId: string, itemId: string) => {
const category = categories.find((item) => item.id === categoryId)
go({
type: 'item',
categoryId,
itemId,
// 地址里带上条目自己的分组,刷新后「返回」才有确定的落点
groupId: category === undefined ? undefined : groupOfItem(category, itemId),
})
}
/**
* 侧栏导航:带分组时直接打开那个分组;
* 点分区本身时,交给 openDetail 按展示块落到对应节点。
*/
const navigate = (target: NavTarget) => {
if (target.groupId === undefined) {
openDetail(target.categoryId)
return
}
go({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
}
const goHome = () => go({ type: 'home' })
/** 面包屑点击:把 href 解析回视图再跳转(保持 SPA 不刷新) */
const navigateHref = (href: string) => go(parseViewPath(href))
const searching = keyword.trim() !== ''
const viewCategoryId = detailView?.categoryId ?? itemView?.categoryId
/**
* 首屏数据还没回来时,地址指向的分类 / 条目只是「暂时查不到」,
* 不能直接报「不存在」——刷新详情页时最容易被这个误导。
*/
const dataPending =
viewCategoryId !== undefined &&
(catalog.status === 'loading' || (viewCategoryId === 'local' && status === 'loading'))
/**
* 条目详情按分类类型分流:
* 图文走长文版式,应用 / 电影走通用的条目版式。
*/
function renderItemView() {
if (itemView === undefined) return null
if (itemCategory === undefined) {
return <Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
}
if (itemCategory.kind === 'articles') {
const article = findArticleItem(itemCategory, itemView.itemId)
if (article === undefined) {
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
}
return (
<ArticleDetailView
article={article}
categoryId={itemCategory.id}
categoryTitle={itemCategory.title}
onNavigate={navigateHref}
onEdit={() => setEditingKey(itemView.itemId)}
canEdit={session?.authenticated === true}
/>
)
}
if (detailModel === undefined) {
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
}
return (
<ItemDetailView
itemKey={itemView.itemId}
crumbs={[
{ label: '首页', href: '/' },
{ label: itemCategory.title, href: viewPath({ type: 'detail', categoryId: itemCategory.id }) },
{ label: detailModel.title },
]}
model={detailModel}
related={relatedItems}
onNavigate={navigateHref}
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
onEdit={() => setEditingKey(itemView.itemId)}
canEdit={session?.authenticated === true}
/>
)
}
const handleLogout = () => {
void logoutRequest().then(() => {
setSession({ authenticated: false, has_admin: true })
})
}
return (
<>
<Toaster />
{/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */}
<LoginDialog
open={loginOpen}
hasAdmin={session?.has_admin ?? false}
onClose={() => setLoginOpen(false)}
onSuccess={(next) => {
setSession(next)
setLoginOpen(false)
}}
/>
<ItemEditor
open={editingKey !== null}
itemKey={editingKey}
onClose={() => setEditingKey(null)}
onSaved={() => catalog.reload()}
/>
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
<div className="site-background" aria-hidden="true" />
<Layout
contained
maxWidth={1380}
gutter={20}
siderWidth={240}
gap={20}
headerHeight={60}
headerVariant="solid"
siderBordered={false}
contentPadding="none"
contentOffset={88}
height="screen"
scrollable
hideScrollbar
contentId="content-scroll"
className="app"
header={
<SiteHeader
projectCount={projects.length}
sampleProject={projects[0]?.name}
live={live}
headerItems={settings.settings.header.items}
headerEnabled={settings.settings.header.enabled}
headerSpeed={settings.settings.header.speed}
authenticated={session?.authenticated === true}
username={session?.username ?? undefined}
onFocusSearch={() => searchRef.current?.focus()}
onLogin={() => setLoginOpen(true)}
onLogout={handleLogout}
onSettings={() => window.location.assign('/admin')}
/>
}
sider={
<Sidebar
projects={projects}
categories={sidebarCategories}
profile={settings.settings.profile}
live={live}
refreshing={refreshing}
onRefresh={refresh}
activeCategoryId={viewCategoryId}
activeGroupId={detailView?.groupId}
homeActive={view.type === 'home'}
onNavigate={navigate}
onHome={goHome}
/>
}
>
<div className="content">
{/* 滚动后淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
<h1 className="sr-only">Stash 本地应用工作区</h1>
{status === 'error' && (
<Notice
tone="error"
title="连不上 Stash 服务"
action={{ label: '重试', onClick: refresh }}
>
{error}
</Notice>
)}
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
{catalog.status === 'error' && (
<Notice
tone="error"
title="内容库读取失败"
action={{ label: '重试', onClick: catalog.reload }}
>
{catalog.error}
</Notice>
)}
{detailView === undefined && itemView === undefined && (
<>
{catalog.status === 'loading' && <Empty text="正在读取内容库…" />}
<HomeView
categories={visibleCategories}
sections={settings.settings.sections}
onOpenDetail={openDetail}
onOpenItem={openItem}
/>
{searching && visibleCategories.length === 0 && (
<Notice title={`没有匹配「${keyword.trim()}」的内容`}>
换个关键字试试,或清空搜索查看全部。
</Notice>
)}
</>
)}
{detailView !== undefined && detailCategory !== undefined && (
<DetailView
category={detailCategory}
groupId={detailView.groupId}
onNavigate={navigateHref}
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
/>
)}
{detailView !== undefined && detailCategory === undefined && (
<Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
)}
{itemView !== undefined && renderItemView()}
</div>
</Layout>
<BackToTop visible={scrolled} />
</>
)
}
+518 -117
View File
@@ -1,8 +1,13 @@
import {
Breadcrumb,
Card,
Drawer,
Empty,
EngineIcon,
Layout,
Menu,
Notice,
SearchField,
Space,
Toaster,
type MenuItem,
@@ -17,17 +22,27 @@ import { ItemEditor } from './components/content/ItemEditor'
import { PosterGrid } from './components/content/PosterGrid'
import { SectionCard } from './components/content/SectionCard'
import { SiteHeader } from './components/header/SiteHeader'
import { SearchBar } from './components/search/SearchBar'
import { ApiDocs } from './components/api/ApiDocs'
import { GroupMark } from './components/common/GroupMark'
import { LoginDialog } from './components/auth/LoginDialog'
import { ProfileCard } from './components/sidebar/ProfileCard'
import { ServicePanel } from './components/sidebar/ServicePanel'
import { useProjects } from './useProjects'
import { fetchSession, logout, type SessionState } from './auth'
import {
DEFAULT_SEARCH_ENGINES,
fetchSettings,
readCachedBackground,
writeCachedBackground,
type SettingsDocument,
type SiteSettings,
} from './settings'
import {
isDarkScheme,
readThemeOverride,
writeThemeOverride,
type ThemeChoice,
} from './theme'
import {
appDetailModel,
posterDetailModel,
@@ -52,9 +67,32 @@ type Route =
| { kind: 'home' }
| { kind: 'group'; key: string }
| { kind: 'item'; key: string; itemId: string }
| { 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 {
const segments = pathname.split('/').filter(Boolean)
if (segments[0] === 'interface') return { kind: 'api' }
if (
(segments[0] === 'g' || segments[0] === 'c') &&
segments[1] !== undefined
@@ -132,14 +170,24 @@ function findEntry(
}
function groupEntryCount(group: GroupNode): number {
return group.entries.length + group.children.reduce((total, child) => total + groupEntryCount(child), 0)
const children = group.children.filter((child) => child.enabled !== false)
return group.entries.length + children.reduce((total, child) => total + groupEntryCount(child), 0)
}
/** 分组自身与所有祖先都启用时才可见:停用父级会连带停用整棵子树 */
function isGroupVisible(groups: GroupNode[], key: string): boolean {
const path = findPath(groups, key)
if (path === undefined) return false
return path.every((node) => node.enabled !== false)
}
/** 分组子树里的全部条目(格式沿根分组继承,所以可以放在同一个网格里) */
function collectEntries(group: GroupNode): Record<string, unknown>[] {
return [
...(group.entries as unknown as Record<string, unknown>[]),
...group.children.flatMap((child) => collectEntries(child)),
...group.children
.filter((child) => child.enabled !== false)
.flatMap((child) => collectEntries(child)),
]
}
@@ -161,6 +209,24 @@ function matches(entry: Record<string, unknown>, keyword: string): boolean {
return text.includes(keyword)
}
/** 本地搜索回车时打开第一个匹配项;没有条目命中但分组名命中时打开分组 */
function findLocalTarget(
groups: GroupNode[],
keyword: string,
): { group: GroupNode; entry?: Record<string, unknown> } | undefined {
for (const group of groups) {
if (group.enabled === false) continue
if (group.title.toLowerCase().includes(keyword)) return { group }
const entry = (group.entries as unknown as Record<string, unknown>[]).find((item) =>
matches(item, keyword),
)
if (entry !== undefined) return { group, entry }
const child = findLocalTarget(group.children, keyword)
if (child !== undefined) return child
}
return undefined
}
export default function AppNext() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
const { route, navigate } = useRoute()
@@ -169,10 +235,17 @@ export default function AppNext() {
const [keyword, setKeyword] = useState('')
const [session, setSession] = useState<SessionState | null>(null)
const [settings, setSettings] = useState<SettingsDocument | null>(null)
/** 上次缓存的背景:设置回来之前先用它上屏,避免刷新时先闪内置背景 */
const [cachedBackground] = useState(readCachedBackground)
const [loginOpen, setLoginOpen] = useState(false)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [scrolled, setScrolled] = useState(false)
/** 小屏的分组导航抽屉 */
const [navOpen, setNavOpen] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const [searchEngineId, setSearchEngineId] = useState<string | null>(readSearchEngine)
const [engineOpen, setEngineOpen] = useState(false)
const engineRef = useRef<HTMLDivElement>(null)
const load = () => {
void fetchManage().then((data) => {
@@ -187,10 +260,22 @@ export default function AppNext() {
void fetchSettings().then(setSettings)
const source = new EventSource('/api/catalog/events')
source.onmessage = () => load()
// 服务端推的是具名事件 `catalog`(不是默认的 message),两种都接;
// 收到后连站点设置一起重拉,改完设置不用手动刷新页面。
const onCatalogChange = () => {
load()
void fetchSettings().then(setSettings)
}
source.onmessage = onCatalogChange
source.addEventListener('catalog', onCatalogChange)
return () => source.close()
}, [])
// 背景地址跟着设置存一份到本机:下次刷新首帧直接用它,不用等接口回来再换
useEffect(() => {
if (settings !== null) writeCachedBackground(settings.settings.background)
}, [settings])
useEffect(() => {
const content = document.getElementById('content-scroll')
const main = content?.parentElement
@@ -203,56 +288,58 @@ export default function AppNext() {
return
}
// 左右两栏放进同一个滚动容器:滚动区域从搜索框下方开始,两栏位移完全一致
const sider = body.querySelector('aside')
// 左右两栏放进同一个滚动容器:滚动区域从搜索条下方开始,两栏位移完全一致。
// 左栏的吸顶交给 CSS position: sticky(App.css 里 #page-scroll > aside):
// 之前用 scroll 事件改 transform,主线程永远落后平滑滚动一帧,看起来就在抖。
// 注意别再给 aside 设内联 overflow,否则会盖掉 CSS 里的 overflow-y: auto,
// 侧栏比视口高时就没法在栏内滚动了。
body.id = 'page-scroll'
body.style.overflowY = 'auto'
body.style.overflowX = 'hidden'
body.style.marginTop = '88px'
body.style.height = 'calc(100% - 88px)'
content.style.overflow = 'visible'
content.style.marginTop = '0'
main.style.height = 'auto'
if (sider instanceof HTMLElement) {
sider.style.overflow = 'visible'
sider.style.height = 'auto'
}
const onScroll = () => setScrolled(body.scrollTop > 4)
// 滚动容器只覆盖 1380 的两栏,窗口两侧的空白、页头与搜索条都在它外面,
// 滚轮落在这些地方时浏览器找不到可滚动元素。统一转发给内容区。
const forwardWheel = (event: WheelEvent) => {
if (event.target instanceof Node && body.contains(event.target)) return
// deltaMode 1 = 按行(Firefox 鼠标滚轮),换算成像素
body.scrollTop += event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY
event.preventDefault()
}
window.addEventListener('wheel', forwardWheel, { passive: false })
body.addEventListener('scroll', onScroll, { passive: true })
onScroll()
return () => {
body.removeEventListener('scroll', onScroll)
window.removeEventListener('wheel', forwardWheel)
body.id = ''
body.style.overflowY = ''
body.style.overflowX = ''
body.style.marginTop = ''
body.style.height = ''
content.style.overflow = ''
content.style.marginTop = ''
main.style.height = ''
if (sider instanceof HTMLElement) {
sider.style.overflow = ''
sider.style.height = ''
}
}
}, [])
const site: SiteSettings = settings?.settings ?? {
header: { enabled: true, speed: 24, items: [] },
header: { enabled: true, speed: 24, gap: 26, items: [] },
sidebar: {
show_profile: true,
show_service: true,
hidden: [],
order: [],
hidden_groups: [],
shown_groups: [],
group_order: {},
tree_hidden: [],
tree_order: {},
show_nav: true,
},
background: cachedBackground ?? { light: '', dark: '' },
font: { source: 'builtin', current: '', items: [] },
search: {
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
},
sections: { hidden: [], order: [], titles: {}, limits: {} },
profile: {
name: '本地管理员',
role: '管理员',
@@ -260,55 +347,247 @@ export default function AppNext() {
location: '',
avatar: '',
},
site: {
title: 'Stash · 本地应用工作区',
favicon: '',
theme: 'auto',
brand: { name: 'STASH', mark: 'S', logo: '' },
actions: { theme: true, refresh: true, settings: true, session: true },
},
}
const keywordLower = keyword.trim().toLowerCase()
const sidebarItems: MenuItem[] = useMemo(() => {
const hidden = new Set(site.sidebar.tree_hidden)
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 : ''
const sortNodes = (nodes: GroupNode[], parentKey: string): GroupNode[] => {
const ranks = new Map(
(site.sidebar.tree_order[parentKey] ?? []).map((key, index) => [key, index] as const),
)
return [...nodes].sort((a, b) => {
const left = ranks.get(a.key) ?? Number.MAX_SAFE_INTEGER
const right = ranks.get(b.key) ?? Number.MAX_SAFE_INTEGER
if (left === right) return a.sort - b.sort
return left - right
})
// 配色:本机偏好优先于站点默认,写在 <html data-scheme> 上,
// 深色由组件库的令牌块接管,页面与组件都不用改
const [themeOverride, setThemeOverride] = useState<ThemeChoice | null>(() =>
readThemeOverride(),
)
const effectiveTheme = themeOverride ?? site.site.theme
/** 当前实际是深色吗(auto 时按系统偏好算) */
const isDark = isDarkScheme(effectiveTheme)
const scheme = isDark ? 'dark' : 'light'
useEffect(() => {
const root = document.documentElement
if (root.dataset.scheme === scheme) return
const apply = () => {
root.dataset.scheme = scheme
}
const doc = document as Document & {
startViewTransition?: (callback: () => void) => unknown
}
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!reduceMotion && typeof doc.startViewTransition === 'function') {
try {
doc.startViewTransition(apply)
return
} catch {
// 浏览器正在跑另一段过渡时退回 CSS 过渡
}
}
const build = (nodes: GroupNode[], parentKey: string): MenuItem[] =>
sortNodes(nodes, parentKey).flatMap((node) => {
if (node.enabled === false) return []
if (hidden.has(node.key)) return build(node.children, node.key)
const children = build(node.children, node.key)
return [
{
key: `g:${node.key}`,
label: node.title,
children: children.length > 0 ? children : undefined,
},
]
})
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])
return [{ key: 'home', label: '首页' }, ...build(manage.groups, '')]
}, [manage.groups, site.sidebar.tree_hidden, site.sidebar.tree_order])
// 正文字体:内置文楷,或启用「字体链接」里存好的某一条。
// 自定义时注入 @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 next: ThemeChoice = isDark ? 'light' : 'dark'
setThemeOverride(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(空值回落到内置)
useEffect(() => {
const title = site.site.title.trim()
if (title !== '') document.title = title
const href = site.site.favicon.trim() === '' ? '/favicon.svg' : site.site.favicon.trim()
let link = document.head.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (link === null) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = href
link.type = href.endsWith('.svg') ? 'image/svg+xml' : ''
}, [site.site.title, site.site.favicon])
const sidebarItems: MenuItem[] = useMemo(() => {
// 侧边栏的显隐只认「分组管理 → 状态」:停用的分组整棵子树都不进导航,
// 顺序按分组自己的排序权重,不再有侧栏专属的隐藏/排序状态。
const build = (nodes: GroupNode[]): MenuItem[] =>
[...nodes]
.sort((a, b) => a.sort - b.sort)
.flatMap((node) => {
if (node.enabled === false) return []
const children = build(node.children)
return [
{
key: `g:${node.key}`,
label: node.title,
children: children.length > 0 ? children : undefined,
},
]
})
return [{ key: 'home', label: '首页' }, ...build(manage.groups)]
}, [manage.groups])
const activeKey =
route.kind === 'home'
route.kind === 'home' || route.kind === 'api'
? 'home'
: route.kind === 'group'
? `g:${route.key}`
: `g:${route.key}`
/** 侧栏与手机抽屉共用:选完分组就关掉抽屉 */
const selectNav = (key: string) => {
setNavOpen(false)
if (key === 'home') navigate('/')
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
}
useEffect(() => {
if (!navOpen) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setNavOpen(false)
}
// 抽屉打开时锁住背后的滚动
const scroll = document.getElementById('page-scroll')
const previous = scroll?.style.overflowY ?? ''
if (scroll !== null) scroll.style.overflowY = 'hidden'
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
if (scroll !== null) scroll.style.overflowY = previous
}
}, [navOpen])
function renderEntries(
group: GroupNode,
entries: Record<string, unknown>[],
options: { detail?: boolean; limit?: number } = {},
) {
const filtered = entries.filter((entry) => matches(entry, keywordLower))
const filtered = entries.filter((entry) => matches(entry, localKeyword))
const limit = options.limit ?? filtered.length
const items = filtered.slice(0, limit)
const open = (itemId: string) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(itemId)}`)
@@ -335,11 +614,16 @@ export default function AppNext() {
}
function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) {
const all = collectEntries(group).filter((entry) => matches(entry, keywordLower))
// 根分组(分区)只展示直接挂在它下面的条目,子分组各自成块,避免同一批内容出现两次
const rootGroup = group.parentKey === undefined || group.parentKey === null
const source = rootGroup
? (group.entries as unknown as Record<string, unknown>[])
: collectEntries(group)
const all = source.filter((entry) => matches(entry, localKeyword))
if (all.length === 0) return null
return (
<div key={group.key} className="card-group-block">
{showTitle && <h3 className="card-group-title">{group.title}</h3>}
{showTitle && !rootGroup && <h3 className="card-group-title">{group.title}</h3>}
{renderEntries(group, all, { limit })}
</div>
)
@@ -374,7 +658,7 @@ export default function AppNext() {
.map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) }))
.filter(
(item): item is { ref: typeof item.ref; group: GroupNode } =>
item.group !== undefined && item.group.enabled !== false,
item.group !== undefined && isGroupVisible(manage.groups, item.ref.groupKey),
)
const total = resolved.reduce((sum, item) => sum + groupEntryCount(item.group), 0)
@@ -404,7 +688,7 @@ export default function AppNext() {
{cards.map((card) =>
card.key === 'local-service' ? renderLocalService(card) : renderCard(card),
)}
{keywordLower !== '' && cards.length > 0 && (
{localKeyword !== '' && cards.length > 0 && (
<Notice title={`搜索结果:${keyword.trim()}`}>在卡片与分组内按标题过滤。</Notice>
)}
</div>
@@ -413,49 +697,51 @@ export default function AppNext() {
function renderGroupPage(key: string) {
const group = findGroup(manage.groups, key)
if (group === undefined) {
if (group === undefined || !isGroupVisible(manage.groups, key)) {
return <Empty text={loading ? '正在读取内容库…' : '分组不存在'} />
}
const trail = findPath(manage.groups, key) ?? [group]
// 根分组页就是「这个分区的全部内容」:所有启用的子分组整段铺开,不再需要「查看全部」
const rootGroup = group.parentKey === undefined || group.parentKey === null
return (
<div className="detail-view">
<nav className="detail-breadcrumb group-crumbs">
<button type="button" className="link-button" onClick={() => navigate('/')}>
首页
</button>
{trail.map((node) => (
<span key={node.key}>
<span className="group-crumb-sep">/</span>
<button
type="button"
className="link-button"
onClick={() => navigate(`/g/${encodeURIComponent(node.key)}`)}
>
{node.title}
</button>
</span>
))}
</nav>
<Card padding="none" className="detail-nav">
<Breadcrumb
className="detail-breadcrumb"
items={[
{ label: '首页', href: '/' },
...trail.map((node) => ({
label: node.title,
href: `/g/${encodeURIComponent(node.key)}`,
})),
]}
onNavigate={navigate}
/>
</Card>
<div className="detail-hero">
<span className="detail-hero-icon" aria-hidden="true">
{group.icon || '•'}
<GroupMark icon={group.icon} fallback="•" />
</span>
<h2 className="detail-hero-title">{group.title}</h2>
<span className="detail-hero-count">[{groupEntryCount(group)}]</span>
</div>
{group.entries.length > 0 ? (
renderEntries(group, group.entries)
<Card padding="md" className="group-own-block">
{renderEntries(group, group.entries)}
</Card>
) : group.children.some((child) => groupEntryCount(child) > 0) ? null : (
<Empty text="这个分组还没有内容" />
)}
<Space direction="column" gap="lg" align="stretch">
{group.children.map((child) => (
<section key={child.key} className="group-child-block">
{group.children
.filter((child) => child.enabled !== false)
.map((child) => (
<Card key={child.key} padding="md" className="group-child-block">
<div className="group-title">
<span
className="group-bar"
@@ -463,20 +749,22 @@ export default function AppNext() {
aria-hidden="true"
/>
<h3>{child.title}</h3>
<button
type="button"
className="link-button"
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
>
查看全部
</button>
{!rootGroup && (
<button
type="button"
className="link-button"
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
>
查看全部
</button>
)}
</div>
{child.entries.length > 0 ? (
renderEntries(child, child.entries, { limit: 12 })
renderEntries(child, child.entries, rootGroup ? {} : { limit: 12 })
) : (
<Empty text="暂无内容" />
)}
</section>
</Card>
))}
</Space>
</div>
@@ -485,7 +773,7 @@ export default function AppNext() {
function renderItemPage(key: string, itemId: string) {
const found = findEntry(manage.groups, key, itemId)
if (found === undefined) {
if (found === undefined || !isGroupVisible(manage.groups, found.group.key)) {
return <Empty text={loading ? '正在读取内容库…' : '条目不存在'} />
}
@@ -569,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
contained
@@ -581,7 +886,6 @@ export default function AppNext() {
headerVariant="solid"
siderBordered={false}
contentPadding="none"
contentOffset={88}
height="screen"
scrollable
hideScrollbar
@@ -595,44 +899,130 @@ export default function AppNext() {
headerItems={site.header.items}
headerEnabled={site.header.enabled}
headerSpeed={site.header.speed}
headerGap={site.header.gap}
site={site.site}
ready={settings !== null}
authenticated={session?.authenticated === true}
username={session?.username ?? undefined}
onFocusSearch={() => searchRef.current?.focus()}
refreshing={refreshing}
dark={isDark}
onToggleTheme={() => void toggleTheme()}
onRefresh={() => {
// 刷新 = 整页重载:项目扫描、内容库、设置、主题都重新走一遍
window.location.reload()
}}
onLogin={() => setLoginOpen(true)}
onLogout={() => {
void logout().then(() => setSession({ authenticated: false, has_admin: true }))
}}
onSettings={() => window.location.assign('/admin')}
onOpenNav={() => setNavOpen(true)}
/>
}
toolbar={
<div className="search-toolbar">
<div className="search-shell" ref={engineRef}>
<button
type="button"
className="engine-badge"
aria-haspopup="menu"
aria-expanded={engineOpen}
aria-label={`搜索引擎:${searchEngine.name},点击切换`}
title={
searchEngine.id === 'internal'
? '当前:站内搜索;回车打开第一个匹配项'
: `当前:${searchEngine.name};回车用该引擎搜索`
}
onClick={() => setEngineOpen((open) => !open)}
>
<EngineIcon name={searchEngine.id} src={searchEngine.icon} size={14} />
<span>{searchEngine.name}</span>
</button>
<SearchField
value={keyword}
onChange={setKeyword}
inputRef={searchRef}
maxWidth="100%"
placeholder={
searchEngine.id === 'internal'
? '搜索站内项目…'
: `使用 ${searchEngine.name} 搜索...`
}
label={
searchEngine.id === 'internal'
? '搜索站内项目,回车打开第一个匹配项'
: `使用 ${searchEngine.name} 搜索,回车打开新标签页`
}
variant="card"
hotkey="k"
/>
{engineOpen && (
<div className="engine-dropdown" role="menu" aria-label="选择搜索引擎">
<button
type="button"
role="menuitemradio"
aria-checked={searchEngine.id === 'internal'}
className={`engine-option${searchEngine.id === 'internal' ? ' is-active' : ''}`}
onClick={() => chooseEngine('internal')}
>
<EngineIcon name="internal" size={18} />
<span>站内搜索</span>
</button>
{searchEngines.length > 1 && (
<>
<div className="engine-divider" />
{searchEngines.slice(1).map((engine) => (
<button
key={engine.id}
type="button"
role="menuitemradio"
aria-checked={engine.id === searchEngine.id}
className={`engine-option${engine.id === searchEngine.id ? ' is-active' : ''}`}
onClick={() => chooseEngine(engine.id)}
>
<EngineIcon name={engine.id} src={engine.icon} size={18} />
<span>{engine.name}</span>
</button>
))}
</>
)}
</div>
)}
</div>
</div>
}
sider={
<div className="sidebar">
{site.sidebar.show_profile !== false && <ProfileCard profile={site.profile} />}
{site.sidebar.show_service !== false && (
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={refresh}
/>
)}
<div className="nav-card">
<Menu
items={sidebarItems}
activeKey={activeKey}
itemHeight={39}
onSelect={(key) => {
if (key === 'home') navigate('/')
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
}}
/>
<div className="sidebar-wrap">
<div className="sidebar sidebar-home">
{settings !== null && site.sidebar.show_profile !== false && (
<ProfileCard profile={site.profile} />
)}
{settings !== null && site.sidebar.show_service !== false && (
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={refresh}
onOpenApi={() => navigate('/interface')}
/>
)}
{settings !== null && !loading && site.sidebar.show_nav !== false && (
<div className="nav-card">
<Menu
items={sidebarItems}
activeKey={activeKey}
itemHeight={39}
onSelect={selectNav}
/>
</div>
)}
</div>
</div>
}
>
<div className="content">
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
<h1 className="sr-only">Stash 本地应用工作区</h1>
{status === 'error' && (
@@ -646,12 +1036,23 @@ export default function AppNext() {
)}
{route.kind === 'home' && renderHome()}
{route.kind === 'api' && <ApiDocs />}
{route.kind === 'group' && renderGroupPage(route.key)}
{route.kind === 'item' && renderItemPage(route.key, route.itemId)}
</div>
</Layout>
<BackToTop visible={scrolled} targetId="page-scroll" />
<Drawer
open={navOpen}
onClose={() => setNavOpen(false)}
title="分组导航"
placement="left"
width={300}
>
<Menu items={sidebarItems} activeKey={activeKey} itemHeight={44} onSelect={selectNav} />
</Drawer>
</>
)
}
+19 -63
View File
@@ -1,5 +1,3 @@
import type { Category } from './data/types'
export type Project = {
name: string
route_path: string
@@ -26,12 +24,30 @@ export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
return Array.isArray(payload.projects) ? payload.projects : []
}
export type ProjectEventsHandlers = {
type ProjectEventsHandlers = {
onProjects: (projects: Project[]) => void
/** SSE 连接建立 / 断开时回调,浏览器的 EventSource 会自行重连。 */
onConnectionChange?: (connected: boolean) => void
}
export type FontFile = {
name: string
/** `/fonts/<文件名>`,直接当作字体文件地址用 */
url: string
}
/** 读取服务端 `font/` 目录里的字体文件(后台字体下拉用)。 */
export async function fetchFonts(): Promise<FontFile[]> {
try {
const response = await fetch('/api/fonts', { headers: { Accept: 'application/json' } })
if (!response.ok) return []
const payload = (await response.json()) as { fonts?: FontFile[] }
return Array.isArray(payload.fonts) ? payload.fonts : []
} catch {
return []
}
}
/**
* 订阅服务端的项目快照推送。
* 浏览器不支持 SSE 时静默降级为空清理函数,页面仍可用手动刷新。
@@ -68,63 +84,3 @@ export function subscribeProjects(handlers: ProjectEventsHandlers): () => void {
source.close()
}
}
// ---------------------------------------------------------------------------
// 内容目录:数据来自后端的 SQLite 内容库(data/stash.db),不再是前端静态数组
// ---------------------------------------------------------------------------
const CATALOG_ENDPOINT = '/api/catalog'
const CATALOG_EVENTS_ENDPOINT = '/api/catalog/events'
type CatalogPayload = {
categories?: Category[]
}
/** 内容目录的完整返回;侧栏由这份分类树派生 */
export type Catalog = {
categories: Category[]
}
/**
* 拉取完整内容目录。
*
* 数据量只有几十 KB,一次全量取回即可;搜索仍然在前端按关键字过滤。
*/
export async function fetchCatalog(signal?: AbortSignal): Promise<Catalog> {
const response = await fetch(CATALOG_ENDPOINT, {
signal,
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(`请求 ${CATALOG_ENDPOINT} 失败:HTTP ${response.status}`)
}
const payload = (await response.json()) as CatalogPayload
return {
categories: Array.isArray(payload.categories) ? payload.categories : [],
}
}
/**
* 订阅内容库变更。
*
* 服务端用 SQLite 的 data_version 检测「外部工具改了库」,事件里不带数据,
* 只表示「该重新拉一次了」,避免两套数据结构需要保持同步。
*/
export function subscribeCatalog(onChange: () => void): () => void {
if (typeof EventSource === 'undefined') {
return () => {}
}
const source = new EventSource(CATALOG_EVENTS_ENDPOINT)
const handleChange = () => onChange()
source.addEventListener('catalog', handleChange)
return () => {
source.removeEventListener('catalog', handleChange)
source.close()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
import { Card, Space, Table, Tag, Text, Title } from '@stash/ui'
/**
* 接口一览:把 `/api/*` 按用途分组列出来。
*
* 页面只做展示,不请求后端——清单跟 `src/server.rs` 的路由表人工对齐,
* 改接口时顺手改这里(文件顶部有分组与权限说明)。
*/
type Endpoint = {
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
path: string
summary: string
/** 需要登录会话 */
auth?: boolean
}
const GROUPS: { title: string; desc: string; items: Endpoint[] }[] = [
{
title: '健康与项目',
desc: '托管目录的扫描结果与实时推送。',
items: [
{ method: 'GET', path: '/api/health', summary: '存活检查,返回服务名与状态' },
{ method: 'GET', path: '/api/projects', summary: '扫描托管目录(每次请求都重新扫描)' },
{ method: 'GET', path: '/api/projects/events', summary: 'SSE:目录变化时推送最新项目列表' },
],
},
{
title: '内容库',
desc: '分组树 / 条目 / 卡片。写操作都需要登录会话与同源 Origin。',
items: [
{ method: 'GET', path: '/api/catalog2', summary: '分组树 + 卡片,前台与后台共用' },
{ method: 'GET', path: '/api/catalog/events', summary: 'SSE:内容库变更时推送「该重拉」' },
{ method: 'POST', path: '/api/nodes', summary: '新建根分组 / 子分组', auth: true },
{ method: 'PATCH', path: '/api/nodes/{key}', summary: '改标题、所属、图标、排序、启停', auth: true },
{ method: 'DELETE', path: '/api/nodes/{key}', summary: '删除分组(级联子分组与条目)', auth: true },
{ method: 'GET', path: '/api/items/{key}', summary: '条目详情' },
{ method: 'POST', path: '/api/items', summary: '新建条目', auth: true },
{ method: 'PATCH', path: '/api/items/{key}', summary: '就地编辑(带 updatedAt 乐观锁)', auth: true },
{ method: 'DELETE', path: '/api/items/{key}', summary: '删除条目', auth: true },
{ method: 'POST', path: '/api/items/{key}/view', summary: '浏览数 +1' },
{ method: 'POST', path: '/api/items/{key}/like', summary: '点赞 / 取消点赞' },
{ method: 'POST', path: '/api/cards', summary: '新建首页卡片', auth: true },
{ method: 'PATCH', path: '/api/cards/{id}', summary: '改卡片标题 / 顺序', auth: true },
{ method: 'DELETE', path: '/api/cards/{id}', summary: '删除卡片', auth: true },
{ method: 'PUT', path: '/api/cards/{id}/groups', summary: '整组替换卡片包含的分组与顺序', auth: true },
],
},
{
title: '站点设置',
desc: '页头、侧边栏、卡片、资料、站点信息等整份 JSON。',
items: [
{ method: 'GET', path: '/api/settings', summary: '读取设置(含 version 乐观锁)' },
{ method: 'PATCH', path: '/api/settings', summary: '整份保存设置', auth: true },
{ method: 'GET', path: '/api/backup', summary: '下载 SQLite 快照', auth: true },
],
},
{
title: '认证',
desc: '单管理员账号;会话 token 只存 sha256,Cookie 为 HttpOnly + SameSite=Strict。',
items: [
{ method: 'GET', path: '/api/auth/session', summary: '当前会话状态(是否登录、是否已设密码)' },
{ method: 'POST', path: '/api/auth/setup', summary: '首次设置管理员密码(仅空库可用)' },
{ method: 'POST', path: '/api/auth/login', summary: '登录;连续失败 5 次锁 60 秒' },
{ method: 'POST', path: '/api/auth/logout', summary: '退出并清掉会话' },
{ method: 'PATCH', path: '/api/auth/password', summary: '改密码(需当前密码)', auth: true },
],
},
]
const METHOD_TONE = {
GET: 'neutral',
POST: 'primary',
PATCH: 'warning',
PUT: 'warning',
DELETE: 'danger',
} as const
export function ApiDocs() {
return (
<div className="api-docs">
<Space direction="column" gap="sm" align="stretch">
<Title level={2}>接口一览</Title>
<Text size="sm" tone="muted">
说明:这是 Stash 自带的 HTTP 接口清单,全部挂在同一个端口上。读接口公开,写接口需要登录会话并校验同源
Origin;带 <Tag tone="primary">需登录</Tag> 标记的需要登录。
</Text>
</Space>
{GROUPS.map((group) => (
<Card key={group.title} padding="lg">
<Space direction="column" gap="sm" align="stretch">
<Space gap="sm" align="center">
<Title level={4} as="h2">
{group.title}
</Title>
<Text size="sm" tone="muted">
{group.items.length} 个
</Text>
</Space>
<Text size="sm" tone="muted">
{group.desc}
</Text>
<Table
rowKey="id"
columns={[
{ key: 'method', title: '方法', width: 90 },
{ key: 'path', title: '路径', width: 280 },
{ key: 'summary', title: '说明' },
{ key: 'auth', title: '权限', width: 100 },
]}
rows={group.items.map((item) => ({
id: `${item.method} ${item.path}`,
method: <Tag tone={METHOD_TONE[item.method]}>{item.method}</Tag>,
path: <code className="api-path">{item.path}</code>,
summary: item.summary,
auth: item.auth === true ? <Tag tone="primary">需登录</Tag> : <Text tone="muted">公开</Text>,
}))}
/>
</Space>
</Card>
))}
</div>
)
}
@@ -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" />
}
@@ -0,0 +1,14 @@
import { Icon, ICON_NAMES, type IconName } from '@stash/ui'
/**
* 分组图标:能对上 iconfont 名字就渲染图标,否则按字符渲染。
*
* 旧数据里存的是 emoji / 任意字符,新数据存图标名(如 `folder_2_line`);
* 两种都要能显示,所以这里做一次判定,前后台共用同一套规则。
*/
export function GroupMark({ icon, fallback = '' }: { icon: string; fallback?: string }) {
const value = icon.trim()
if (value === '') return fallback === '' ? null : <>{fallback}</>
if (ICON_NAMES.includes(value as IconName)) return <Icon name={value as IconName} />
return <>{value}</>
}
-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} />
}
+11 -33
View File
@@ -1,9 +1,9 @@
import { Avatar, ItemBadge, Space, Text } from '@stash/ui'
import { EntryRow, ItemBadge } from '@stash/ui'
import type { AppItem } from '../../data/types'
/**
* 应用卡片:头像 + 名称(热门带 HOT 角标)+ 单行描述。
* 点击进入条目详情;按住修饰键时保持浏览器默认行为(新标签打开原始链接)。
* 版式与点击语义都在库里的 EntryRow,这里只做数据映射。
*/
type AppCardProps = {
item: AppItem
@@ -12,37 +12,15 @@ type AppCardProps = {
export function AppCard({ item, onOpen }: AppCardProps) {
return (
<a
className="app-card"
href={item.href}
<EntryRow
title={item.name}
onClick={(event) => {
if (onOpen === undefined) return
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
onOpen(item.id)
}}
>
<Avatar
size="lg"
tone="primary"
className="app-card-avatar"
src={item.cover}
fallback={item.icon}
verified={item.hot === true}
/>
<div className="app-card-info">
<Space gap="xs">
<Text strong truncate className="app-card-name">
{item.name}
</Text>
{item.hot === true && <ItemBadge tone="hot">HOT</ItemBadge>}
</Space>
<Text size="xs" tone="muted" truncate>
{item.desc}
</Text>
</div>
</a>
description={item.desc}
href={item.href}
avatar={item.cover}
fallback={item.icon}
verified={item.hot === true}
badge={item.hot === true ? <ItemBadge tone="hot">HOT</ItemBadge> : undefined}
onOpen={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
)
}
@@ -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>
)
}
+8 -34
View File
@@ -1,9 +1,9 @@
import { Card, Cover, Space, Text } from '@stash/ui'
import { EntryTile } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
/**
* 明细页图文卡:上图下文的白卡。
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
* 版式与点击语义都在库里的 EntryTile,这里只做数据映射。
*/
type CesCardProps = {
item: ArticleItem
@@ -11,38 +11,12 @@ type CesCardProps = {
}
export function CesCard({ item, onOpen }: CesCardProps) {
const body = (
<Space direction="column" gap="xs" align="stretch">
<Text strong truncate>
{item.title}
</Text>
<Text size="xs" tone="muted" truncate>
{item.desc}
</Text>
</Space>
)
return (
<Card padding="none" hoverable className="ces-card">
<Cover
src={item.cover}
alt={item.title}
ratio={16 / 10}
radius="none"
onClick={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
{onOpen === undefined ? (
<div className="ces-body">{body}</div>
) : (
<button
type="button"
className="ces-body ces-body--button"
title={item.title}
onClick={() => onOpen(item.id)}
>
{body}
</button>
)}
</Card>
<EntryTile
title={item.title}
description={item.desc}
cover={item.cover}
onOpen={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
)
}
@@ -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)}
/>
)
}
+96 -89
View File
@@ -1,9 +1,15 @@
import { Icon, IconButton, Tag } from '@stash/ui'
import { fillHeaderText, type HeaderItem, type SettingsTone } from '../../settings'
import { Brand, Icon, IconButton, Tag, Ticker } from '@stash/ui'
import {
fillHeaderText,
type HeaderItem,
type SettingsTone,
type SiteMetaSettings,
} from '../../settings'
/**
* 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
* 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。
* 品牌与操作图标是否出现,都来自「页头」Tab 的站点设置。
* 外壳(品牌、跑马灯)都用组件库,这里只拼内容。
*/
type SiteHeaderProps = {
projectCount: number
@@ -15,13 +21,26 @@ type SiteHeaderProps = {
headerEnabled: boolean
/** 滚动一轮的秒数 */
headerSpeed: number
/** 信息条目之间的间距(px) */
headerGap?: number
/** 品牌与操作工具配置 */
site: SiteMetaSettings
/** 站点设置是否已加载;未加载时不渲染依赖配置的图标,避免先显示再消失 */
ready?: boolean
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */
authenticated: boolean
username?: string
onFocusSearch: () => void
/** 刷新中:刷新按钮转圈 */
refreshing?: boolean
/** 当前是否为深色,决定明暗按钮的图标与文案 */
dark?: boolean
onRefresh: () => void
onToggleTheme: () => void
onLogin: () => void
onLogout: () => void
onSettings: () => void
/** 小屏打开分组导航抽屉;不传则不显示菜单按钮 */
onOpenNav?: () => void
}
const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = {
@@ -31,40 +50,6 @@ const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = {
success: 'success',
}
type InfoGroupProps = {
items: HeaderItem[]
projectCount: number
sampleProject?: string
live: boolean
'aria-hidden'?: boolean
}
/** 运行信息:同一份内容渲染三遍,轨道平移 1/3 形成无缝滚动 */
function InfoGroup({
items,
projectCount,
sampleProject,
live,
'aria-hidden': ariaHidden,
}: InfoGroupProps) {
const context = { count: projectCount, project: sampleProject, live }
return (
<span className="header-ticker-group" aria-hidden={ariaHidden}>
{items.map((item, index) => (
<span className="header-ticker-item" key={`${index}-${item.text}`}>
{item.tag !== undefined && item.tag !== null && item.tag !== '' && (
<Tag tone={tagTone[item.tone]} shape="rounded">
{item.tag}
</Tag>
)}
{fillHeaderText(item.text, context)}
</span>
))}
</span>
)
}
export function SiteHeader({
projectCount,
sampleProject,
@@ -72,70 +57,92 @@ export function SiteHeader({
headerItems,
headerEnabled,
headerSpeed,
headerGap,
site,
ready = true,
authenticated,
username,
onFocusSearch,
refreshing = false,
dark = false,
onRefresh,
onToggleTheme,
onLogin,
onLogout,
onSettings,
onOpenNav,
}: SiteHeaderProps) {
const context = { count: projectCount, project: sampleProject, live }
return (
<div className="site-header-inner">
<a className="site-brand" href="/" aria-label="Stash 首页">
<span className="site-brand-mark" aria-hidden="true">
S
</span>
<span className="site-brand-name">STASH</span>
</a>
<Brand
href="/"
name={site.brand.name}
mark={site.brand.mark}
logo={site.brand.logo}
/>
{headerEnabled && (
<div className="header-ticker" role="status" aria-label="运行状态">
<div
className="header-ticker-track"
style={{ animationDuration: `${headerSpeed}s` }}
>
<InfoGroup
items={headerItems}
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
/>
<InfoGroup
items={headerItems}
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
// 后两份只用于视觉无缝,读屏忽略
aria-hidden
/>
<InfoGroup
items={headerItems}
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
aria-hidden
/>
</div>
</div>
)}
<div className="header-ticker-slot">
{ready && headerEnabled && (
<Ticker speed={headerSpeed} gap={headerGap} label="运行状态" className="header-ticker">
{headerItems.map((item, index) => (
<span className="header-ticker-item" key={`${index}-${item.text}`}>
{item.tag !== undefined && item.tag !== null && item.tag !== '' && (
<Tag tone={tagTone[item.tone]} shape="rounded">
{item.tag}
</Tag>
)}
{fillHeaderText(item.text, context)}
</span>
))}
</Ticker>
)}
</div>
<div className="header-actions">
<IconButton
label="聚焦搜索框(Ctrl+K)"
icon={<Icon name="search_3_line" />}
onClick={onFocusSearch}
/>
{authenticated && (
<IconButton label="站点设置" icon={<Icon name="settings_1_line" />} onClick={onSettings} />
)}
{authenticated ? (
{onOpenNav !== undefined && (
<IconButton
label={username === undefined ? '退出登录' : `退出登录(${username})`}
icon={<Icon name="exit_line" />}
onClick={onLogout}
className="header-nav-toggle"
label="分组导航"
icon={<Icon name="menu_line" />}
onClick={onOpenNav}
/>
) : (
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
)}
{ready && (
<>
{site.actions.theme && (
<IconButton
label={dark ? '切换到浅色' : '切换到深色'}
icon={<Icon name={dark ? 'moon_line' : 'sun_line'} />}
onClick={onToggleTheme}
/>
)}
{site.actions.refresh && (
<IconButton
label="刷新(重扫项目并重拉内容)"
icon={<Icon name="lightning_line" />}
spinning={refreshing}
onClick={onRefresh}
/>
)}
{site.actions.settings && authenticated && (
<IconButton
label="站点设置"
icon={<Icon name="settings_1_line" />}
onClick={onSettings}
/>
)}
{site.actions.session &&
(authenticated ? (
<IconButton
label={username === undefined ? '退出登录' : `退出登录(${username})`}
icon={<Icon name="exit_line" />}
onClick={onLogout}
/>
) : (
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
))}
</>
)}
</div>
</div>
@@ -1,47 +0,0 @@
import { Input } from '@stash/ui'
import { useEffect, type RefObject } from 'react'
/**
* 全局搜索框:吸在内容区顶部。
* 支持 Ctrl/Cmd+K 聚焦、Esc 清空,输入内容由父组件接管过滤逻辑。
*/
type SearchBarProps = {
value: string
onChange: (value: string) => void
inputRef: RefObject<HTMLInputElement | null>
}
export function SearchBar({ value, onChange, inputRef }: SearchBarProps) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
inputRef.current?.focus()
inputRef.current?.select()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [inputRef])
return (
<div className="search-row">
<Input
inputRef={inputRef}
size="lg"
icon="search_3_line"
value={value}
placeholder="搜索本地项目…"
aria-label="搜索本地项目"
clearable
onClear={() => onChange('')}
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') onChange('')
}}
/>
</div>
)
}
@@ -1,86 +0,0 @@
import { Card, Menu, type MenuItem } from '@stash/ui'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
/**
* 分类导航树:由内容树派生。
*
* - 有分组的分类(apps)是折叠节点,子项是分组(分组可用 show_in_sidebar 单独隐藏);
* - 没有分组、或分组全被隐藏的分类,就是一个普通菜单项。
*
* 折叠状态由组件库的 Menu 内部维护;这里只负责把内容树翻译成 items。
*/
type CategoryNavProps = {
/** 内容目录;顺序即侧栏顺序 */
categories: Category[]
/** 当前明细视图的分类 / 分组,用于高亮 */
activeCategoryId?: string
activeGroupId?: string
/** 是否在首页(高亮「首页」项) */
homeActive?: boolean
onNavigate: (target: NavTarget) => void
/** 点击「首页」 */
onHome: () => void
}
const HOME_KEY = 'home'
const categoryKey = (id: string) => `cat:${id}`
const groupKey = (categoryId: string, groupId: string) => `group:${categoryId}:${groupId}`
export function CategoryNav({
categories,
activeCategoryId,
activeGroupId,
homeActive = false,
onNavigate,
onHome,
}: CategoryNavProps) {
const items: MenuItem[] = [
{ key: HOME_KEY, label: '首页' },
...categories.map((category) => {
// 「允许子分组」的分区才有 groups;空分组不出现在侧栏(后台仍保留)
const groups = category.groups.filter((group) => group.items.length > 0)
return {
key: categoryKey(category.id),
label: category.title,
children:
groups.length > 0
? groups.map((group) => ({
key: groupKey(category.id, group.id),
label: group.name,
}))
: undefined,
}
}),
]
const activeKey = homeActive
? HOME_KEY
: activeCategoryId === undefined
? undefined
: activeGroupId !== undefined
? groupKey(activeCategoryId, activeGroupId)
: categoryKey(activeCategoryId)
const handleSelect = (key: string) => {
if (key === HOME_KEY) {
onHome()
return
}
if (key.startsWith('group:')) {
const [, categoryId, groupId] = key.split(':')
onNavigate({ categoryId, groupId })
return
}
onNavigate({ categoryId: key.slice('cat:'.length) })
}
return (
<Card padding="none" className="nav-card">
<Menu items={items} activeKey={activeKey} onSelect={handleSelect} itemHeight={39} />
</Card>
)
}
@@ -10,9 +10,17 @@ type ServicePanelProps = {
live: boolean
refreshing: boolean
onRefresh: () => void
/** 打开接口一览:走站内路由,不整页刷新 */
onOpenApi: () => void
}
export function ServicePanel({ projects, live, refreshing, onRefresh }: ServicePanelProps) {
export function ServicePanel({
projects,
live,
refreshing,
onRefresh,
onOpenApi,
}: ServicePanelProps) {
const host = typeof window === 'undefined' ? '-' : window.location.host
return (
@@ -56,10 +64,23 @@ export function ServicePanel({ projects, live, refreshing, onRefresh }: ServiceP
重新扫描
</Button>
<a className="link-button" href="/api/projects" target="_blank" rel="noreferrer">
<Icon name="link_3_line" />
<Button
size="sm"
variant="secondary"
className="service-action"
icon={<Icon name="link_3_line" />}
onClick={(event) => {
// 无修饰键时走站内路由;按住修饰键仍可新标签打开接口页
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
window.open('/interface', '_blank', 'noopener')
return
}
onOpenApi()
}}
title="查看接口一览"
>
查看接口
</a>
</Button>
</div>
</Card>
)
@@ -1,64 +0,0 @@
import type { Project } from '../../api'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
import type { ProfileSettings } from '../../settings'
import { CategoryNav } from './CategoryNav'
import { ProfileCard } from './ProfileCard'
import { ServicePanel } from './ServicePanel'
/**
* 左侧栏容器:用户资料 + 本地服务面板 + 分类导航。
* 只负责布局与数据透传,具体内容由三个子组件分别渲染。
*/
type SidebarProps = {
projects: Project[]
/** 内容目录;侧栏直接由它派生 */
categories: Category[]
profile: ProfileSettings
live: boolean
refreshing: boolean
onRefresh: () => void
activeCategoryId?: string
activeGroupId?: string
/** 是否在首页 */
homeActive?: boolean
onNavigate: (target: NavTarget) => void
/** 点击「首页」 */
onHome: () => void
}
export function Sidebar({
projects,
categories,
profile,
live,
refreshing,
onRefresh,
activeCategoryId,
activeGroupId,
homeActive = false,
onNavigate,
onHome,
}: SidebarProps) {
return (
<div className="sidebar">
<ProfileCard profile={profile} />
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={onRefresh}
/>
<CategoryNav
categories={categories}
activeCategoryId={activeCategoryId}
activeGroupId={activeGroupId}
homeActive={homeActive}
onNavigate={onNavigate}
onHome={onHome}
/>
</div>
)
}
-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
}
/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */
export type ContentGroup<T> = {
id: string
name: string
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
items: T[]
}
export type AppGroup = ContentGroup<AppItem>
/** 竖版海报条目(电影) */
export type PosterItem = {
id: string
@@ -79,160 +65,3 @@ export type ArticleItem = {
body?: string
}
/** 首页展示块:分区卡片展示哪个节点、显示几条 */
export type SectionBlock = {
/** 要展示的节点业务键:通常是分组;电影/文章这类无分组的填分区自己 */
nodeKey: string
/** 块标题,缺省用节点标题 */
title?: string
/** 卡片显示几条,缺省用分类的 previewLimit 或默认值 */
previewLimit?: number
}
/** 应用类分类:内部按分组组织 */
export type AppCategory = {
id: string
kind: 'apps'
title: string
/** 标题左侧色条颜色,各分类用不同颜色区分 */
barColor: string
/** 明细页 hero 区的字符图标 */
icon: string
groups: AppGroup[]
/** 直接挂在分区上的条目(未分组) */
items?: AppItem[]
/** 首页预览条数,默认 4 */
previewLimit?: number
/** 首页预览为空时的提示文案 */
emptyText?: string
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
/** 海报类分类:竖版海报网格 */
export type PosterCategory = {
id: string
kind: 'posters'
title: string
barColor: string
icon: string
groups: ContentGroup<PosterItem>[]
/** 直接挂在分区上的条目(未分组) */
items: PosterItem[]
/** 首页预览条数,默认 6 */
previewLimit?: number
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
/** 图文类分类:上图下文卡片网格 */
export type ArticleCategory = {
id: string
kind: 'articles'
title: string
barColor: string
icon: string
groups: ContentGroup<ArticleItem>[]
/** 直接挂在分区上的条目(未分组) */
items: ArticleItem[]
/** 首页预览条数,默认 4 */
previewLimit?: number
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
export type Category = AppCategory | PosterCategory | ArticleCategory
/** 分类内的条目总数(直属 + 分组的),用于角标与明细页计数 */
export function categoryCount(category: Category): number {
const direct = category.items?.length ?? 0
const grouped = category.groups.reduce((total, group) => total + group.items.length, 0)
return direct + grouped
}
/** 把应用类分类的条目拍平(分组 + 直属),首页预览用 */
export function flattenAppItems(category: AppCategory): AppItem[] {
return [
...category.groups.flatMap((group) => group.items),
...(category.items ?? []),
]
}
/** 按 id 在分类里查找条目;详情页用 */
export function findAppItem(category: Category, itemId: string): AppItem | undefined {
if (category.kind !== 'apps') return undefined
const direct = (category.items ?? []).find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
/** 取同分类下的其他条目,用于详情页的「推荐阅读」 */
export function relatedAppItems(
category: Category,
excludeId: string | undefined,
limit = 4,
): AppItem[] {
if (category.kind !== 'apps') return []
return flattenAppItems(category)
.filter((item) => item.id !== excludeId)
.slice(0, limit)
}
/** 按 id 查找海报条目 */
export function findPosterItem(
category: Category,
itemId: string,
): PosterItem | undefined {
if (category.kind !== 'posters') return undefined
const direct = category.items.find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
/** 取同分类下的其他海报,用于详情页的「推荐阅读」 */
export function relatedPosterItems(
category: Category,
excludeId: string | undefined,
limit = 4,
): PosterItem[] {
if (category.kind !== 'posters') return []
return [
...category.items,
...category.groups.flatMap((group) => group.items),
]
.filter((item) => item.id !== excludeId)
.slice(0, limit)
}
/** 按 id 查找图文条目 */
export function findArticleItem(
category: Category,
itemId: string,
): ArticleItem | undefined {
if (category.kind !== 'articles') return undefined
const direct = category.items.find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
+2 -60
View File
@@ -16,7 +16,7 @@
* 条目地址带上它自己所在的分组,URL 才是自解释的:刷新之后点「返回」
* 仍然落回条目所属的分组,而不是去猜一个返回目标。
*
* 这里只做「地址 ↔ 视图」的纯转换,读写 history 的部分在 `useViewRoute.ts`。
* 这里只做「视图 → 地址」的纯转换,读写 history 的部分在 `AppNext.tsx`。
*/
export type DetailRoute = {
@@ -45,15 +45,10 @@ export type View =
| ItemRoute
/** 前端路由前缀;改动这里要同步 `src/server.rs` 的 `SPA_PREFIXES`。 */
const CATEGORY_PREFIX = '/c/'
/** 前缀去掉两侧斜杠后的那一段,拼地址时当第一个路径段用 */
const CATEGORY_SEGMENT = CATEGORY_PREFIX.slice(1, -1)
const CATEGORY_SEGMENT = 'c'
/** 条目段标记:`/c/<分区>[/<分组>]/i/<条目>` */
const ITEM_MARK = 'i'
/** 首页视图;未知地址一律归到这里 */
export const HOME_VIEW: View = { type: 'home' }
/** 把视图转成地址。 */
export function viewPath(view: View): string {
if (view.type === 'home') return '/'
@@ -65,60 +60,7 @@ export function viewPath(view: View): string {
return `/${segments.join('/')}`
}
/**
* 把地址解析成视图;看不懂的地址(段数不对、百分号编码非法)按首页处理。
*
* 这里只做形状校验,不判断分类 / 条目是否真的存在——那是数据层的事,
* 而且首屏还没拿到内容库时,它们本来就「暂时不存在」。
*/
export function parseViewPath(pathname: string): View {
if (!pathname.startsWith(CATEGORY_PREFIX)) return HOME_VIEW
const segments = pathname
.slice(CATEGORY_PREFIX.length)
.split('/')
.filter((segment) => segment !== '')
const [categorySegment] = segments
if (categorySegment === undefined) return HOME_VIEW
const categoryId = decodeSegment(categorySegment)
if (categoryId === undefined) return HOME_VIEW
if (segments.length === 1) return { type: 'detail', categoryId }
if (segments.length === 2) {
const groupId = decodeSegment(segments[1])
if (groupId === undefined) return HOME_VIEW
return { type: 'detail', categoryId, groupId }
}
if (segments.length === 3 && segments[1] === ITEM_MARK) {
const itemId = decodeSegment(segments[2])
if (itemId === undefined) return HOME_VIEW
return { type: 'item', categoryId, itemId }
}
if (segments.length === 4 && segments[2] === ITEM_MARK) {
const groupId = decodeSegment(segments[1])
const itemId = decodeSegment(segments[3])
if (groupId === undefined || itemId === undefined) return HOME_VIEW
return { type: 'item', categoryId, itemId, groupId }
}
return HOME_VIEW
}
/** 单个路径段编码:id 里可能带斜杠、空格、中文 */
function encodeSegment(value: string): string {
return encodeURIComponent(value)
}
/** 解码单个路径段;百分号编码非法时返回 undefined */
function decodeSegment(segment: string): string | undefined {
try {
return decodeURIComponent(segment)
} catch {
return undefined
}
}
+4
View File
@@ -11,6 +11,10 @@
-moz-osx-font-smoothing: grayscale;
}
html[data-scheme='dark'] {
color-scheme: dark;
}
* {
box-sizing: border-box;
margin: 0;
+7 -3
View File
@@ -19,7 +19,6 @@ export type GroupNode = {
key: string
parentKey?: string | null
title: string
format: GroupFormat
icon: string
barColor: string
previewLimit?: number | null
@@ -189,9 +188,14 @@ export async function setCardGroups(
export function flattenGroups(
groups: GroupNode[],
depth = 0,
): { key: string; title: string; depth: number; format: GroupFormat }[] {
): { key: string; title: string; depth: number; enabled: boolean }[] {
return groups.flatMap((group) => [
{ key: group.key, title: group.title, depth, format: group.format },
{
key: group.key,
title: group.title,
depth,
enabled: group.enabled !== false,
},
...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: '局域网',
}
+184 -259
View File
@@ -1,10 +1,9 @@
/**
* 站点设置:页头滚动信息 + 侧栏顺序/显隐。
* 站点设置:页头滚动信息 + 侧栏卡片显隐。
*
* 接口是公开读、登录后写;写的时候带 `version` 做乐观锁,
* 冲突(多标签页同时改)时返回 409,页面提示刷新。
*/
import type { Category } from './data/types'
export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral'
@@ -14,13 +13,6 @@ export type HeaderItem = {
tone: SettingsTone
}
export type SectionSettings = {
hidden: string[]
order: string[]
titles: Record<string, string>
limits: Record<string, number>
}
export type ProfileSettings = {
name: string
role: string
@@ -31,11 +23,141 @@ export type ProfileSettings = {
avatar: string
}
export type BackgroundSettings = {
/** 亮色背景图地址;空表示使用内置图 */
light: string
/** 暗色背景图地址;空表示使用内置图 */
dark: string
}
export type FontSource = 'builtin' | 'custom'
/** 一条存下来的字体配置:名字只用于后台下拉,url 才是真正加载的东西 */
export type FontLink = {
key: string
name: string
url: string
/** 字体族名:CSS 链接时填 CSS 里声明的名字;字体文件留空则用 Stash Font */
family: string
}
export type FontSettings = {
/** 字体来源:内置霞鹜文楷,或下面保存的字体链接 */
source: FontSource
/** 当前启用的 FontLink.key;为空时用第一条 */
current: string
/** 存下来的字体:常用字体放这里,切换时不用重新填 */
items: FontLink[]
}
const BACKGROUND_CACHE_KEY = 'stash-background'
/**
* 上次拿到的背景地址。
*
* 设置要等 `/api/settings` 回来,刷新时首帧只能先画内置背景,自定义背景随后才换上来,
* 会闪一下。先用上次缓存上屏,等接口回来再校准。
*/
export function readCachedBackground(): BackgroundSettings | null {
try {
const raw = window.localStorage.getItem(BACKGROUND_CACHE_KEY)
if (raw === null) return null
const value = JSON.parse(raw) as Partial<BackgroundSettings>
if (typeof value.light !== 'string' || typeof value.dark !== 'string') return null
return { light: value.light, dark: value.dark }
} catch {
return null
}
}
export function writeCachedBackground(background: BackgroundSettings): void {
try {
window.localStorage.setItem(BACKGROUND_CACHE_KEY, JSON.stringify(background))
} catch {
// 隐私模式下 localStorage 不可写,忽略即可
}
}
export type 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 = {
header: {
enabled: boolean
/** 滚动一轮的秒数(8–120) */
speed: number
/** 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽 */
gap: number
items: HeaderItem[]
}
sidebar: {
@@ -43,21 +165,44 @@ export type SiteSettings = {
show_profile: boolean
/** 是否显示「本地服务」面板 */
show_service: 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[]>
/** 是否显示「分组导航」卡片(首页 / 分组树) */
show_nav: boolean
}
sections: SectionSettings
background: BackgroundSettings
font: FontSettings
search: SearchSettings
profile: ProfileSettings
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
site: SiteMetaSettings
}
export type SiteTheme = 'light' | 'dark' | 'auto'
export type SiteMetaSettings = {
/** 浏览器标签标题 */
title: string
/** favicon 地址;空表示用内置图标 */
favicon: string
/** 配色:浅色 / 深色 / 跟随系统 */
theme: SiteTheme
brand: {
/** 页头品牌文字,可留空 */
name: string
/** 没有图片时圆形标记里的首字 */
mark: string
/** logo 图片地址;空表示用首字 */
logo: string
}
actions: {
/** 明暗切换 */
theme: boolean
/** 刷新图标(重扫本地项目并重拉内容库) */
refresh: boolean
/** 站点设置图标 */
settings: boolean
/** 登录 / 退出登录 */
session: boolean
}
}
export type SettingsDocument = {
@@ -81,6 +226,7 @@ export function defaultSettings(): SiteSettings {
header: {
enabled: true,
speed: 24,
gap: 26,
items: [
{ text: '共 {count} 个本地应用', tone: 'plain' },
{ text: '{project}', tag: '本地', tone: 'primary' },
@@ -90,15 +236,14 @@ export function defaultSettings(): SiteSettings {
sidebar: {
show_profile: true,
show_service: true,
hidden: [],
order: [],
hidden_groups: [],
shown_groups: [],
group_order: {},
tree_hidden: [],
tree_order: {},
show_nav: true,
},
background: { light: '', dark: '' },
font: { source: 'builtin', current: '', items: [] },
search: {
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
},
sections: { hidden: [], order: [], titles: {}, limits: {} },
profile: {
name: '本地管理员',
role: '管理员',
@@ -106,6 +251,13 @@ export function defaultSettings(): SiteSettings {
location: 'IP属地:局域网',
avatar: '',
},
site: {
title: 'Stash · 本地应用工作区',
favicon: '',
theme: 'auto',
brand: { name: 'STASH', mark: 'S', logo: '' },
actions: { theme: true, refresh: true, settings: true, session: true },
},
}
}
@@ -133,7 +285,8 @@ export async function fetchSettings(): Promise<SettingsDocument> {
export async function saveSettings(
version: number,
settings: SiteSettings,
): Promise<SettingsResult> { try {
): Promise<SettingsResult> {
try {
const response = await fetch('/api/settings', {
method: 'PATCH',
credentials: 'same-origin',
@@ -159,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 : '网络错误' }
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* 明暗主题的「本机偏好」。
*
* 站点设置里的 `site.theme` 是**所有人**的默认值;这里的 localStorage 是**这台浏览器**
* 的选择,读到时优先于默认值。前台页头的切换按钮只改本机偏好,不需要登录;
* 后台「页头 → 配色」改的是站点默认值。
*/
export type ThemeChoice = 'light' | 'dark'
const STORAGE_KEY = 'stash-theme'
export function readThemeOverride(): ThemeChoice | null {
try {
const value = window.localStorage.getItem(STORAGE_KEY)
return value === 'light' || value === 'dark' ? value : null
} catch {
return null
}
}
export function writeThemeOverride(value: ThemeChoice | null): void {
try {
if (value === null) window.localStorage.removeItem(STORAGE_KEY)
else window.localStorage.setItem(STORAGE_KEY, value)
} catch {
// 隐私模式下 localStorage 可能不可写,忽略即可
}
}
/** `light` / `dark` / `auto` 里,当前实际是不是深色 */
export function isDarkScheme(scheme: string): boolean {
if (scheme === 'dark') return true
if (scheme === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
-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>