133 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
CNWei 54fd0edf84 fix: 左右两栏共用滚动容器,滚动位移保持一致
- 将 Layout body (#page-scroll) 作为唯一滚动容器,左右两栏同容器滚动
- 滚动区域从搜索框下方开始,搜索框固定
- 内容区 overflow visible、侧栏 height auto;返回顶部/路由归零改用 page-scroll
- 验证:滚动 300px 后两侧 top 均为 -152,搜索框不动,返回顶部有效
2026-10-31 15:40:08 +08:00
CNWei 57ab90a1fa feat: 搜索框下滚动、本地服务卡片排序、拖拽末位修复
- 恢复内容区内部滚动:页头/搜索框固定,滚动区域从搜索框下方开始;侧栏跟随内容滚动,侧栏滚轮驱动内容区
- v22:本地服务以 card 行 local-service 参与排序;首页按卡片顺序渲染,后台可拖动排序
- 本地服务卡片后端禁止删除/改名/配置分组
- 修复向下拖动时插到目标之后,倒数第二项可拖到最后
- 验证:搜索框位置固定、侧栏 scrollTop 跟随、本地服务与倒数第二拖末位均落库、首页顺序一致
2026-10-31 15:30:09 +08:00
CNWei 0e6c772466 feat: 侧栏卡片显隐、本地服务卡片恢复、页面同步滚动
- 侧边栏设置新增「侧栏卡片」:本地管理员/本地服务显隐,即时保存 show_profile/show_service
- 首页固定第一位恢复「本地服务」卡片(运行时项目网格),移除卡片三点菜单
- 后台首页卡片新增只读「本地服务」表,标记不可删除
- 前台改为整页滚动,左右两栏同步
- 验证:显隐落库、三点为 0、本地表无删除、滚动两栏同步 300px
2026-10-31 14:59:41 +08:00
CNWei 11f992cdad feat: 格式从分组下沉到条目(v21)
- entry 新增 format(apps/posters/articles),老数据按根分组 view_mode 回填;seed 按分区写入
- 分组管理移除格式字段/显示,根分组只作容器
- 内容弹窗新增逐条格式选择,列表增加格式列;ItemEditor 保存时保留原格式
- 前台按 entry.format 渲染:卡片/分组页支持同组混合格式,详情按条目格式选模板
- 迁移后 live 20 应用 / 13 海报;cargo test 59 passed;Playwright 验证混合展示
2026-10-31 14:26:28 +08:00
CNWei 8d094175fd feat(admin): 卡片新建弹窗、标题保存/删除与拖动排序
- 新建卡片改为弹窗输入标题,确认新建/取消关闭,创建后自动选中
- 卡片标题右侧增加「保存」「删除」按钮
- 左侧卡片支持拖拽排序,顺序写入 card.sort 并影响首页卡片顺序
- 标签栏与内容增加间距,卡片列表加拖动手柄与提示
- Playwright 验证:弹窗取消/确认、按钮、拖动后 API 顺序变化
2026-10-31 13:57:34 +08:00
CNWei 2c126b410c feat(admin): 首页卡片改为左侧列表 + 右侧分组表格
- 左侧卡片列表切换,右侧当前卡片的分组表格(分组/条数/展示)
- 新建卡片即新建一张表,创建后自动选中
- 表格内开关与条数即时保存,标题失焦保存
- 去掉纵向堆叠与卡片级保存按钮
- Playwright 验证:切换、创建、开关落库
2026-10-31 11:46:50 +08:00
CNWei c1a4da5458 feat(admin): 首页卡片按侧边栏逻辑交互
- 分组勾选:标题在左、开关在右,勾选即保存,去掉每卡保存按钮
- 卡片标题与每组条数改为失焦保存
- 保留删除卡片按钮
- Playwright 验证:无保存按钮、开关右置、勾选/条数/改名即时落库
2026-10-31 11:35:48 +08:00
CNWei 87b3c3b35a fix(admin): 内容下拉去格式后缀,侧边栏开关右置并即时保存
- 内容页/条目弹窗的分组下拉只显示名称,去掉格式后缀与缩进,统一对齐
- 侧边栏开关移到右侧;拨动即保存到 settings.sidebar.tree_hidden
- 移除侧边栏页的保存/撤销按钮
- Playwright 验证:选项无括号且对齐、开关在右、无保存按钮、设置即时落库
2026-10-31 11:19:30 +08:00
CNWei b04842978c feat(admin): 内容页列表化,主分组汇总子分组条目
- 内容页改为列表:标题 / 跳转链接 / 所属分组 / 操作
- 选中主分组时递归列出全部子分组条目;选子分组只列该子树
- 编辑弹窗始终显示「标识」(编辑时只读)
- 图文条目接口补齐 href,列表统一显示跳转链接
- 测试 59 passed;Playwright 验证 20 条汇总、子分组 4 条、标识只读
2026-10-31 10:46:02 +08:00
CNWei c249fbd826 feat(admin): 分组管理改为树形表格(图标/排序/状态)
- 参考需求页重做分组管理:树形表格(名称/排序权重/关联网页数/状态/操作),去掉展开全部/折叠全部,新建根分组置于左侧
- 分组弹窗支持上级分组移动、格式、图标(可留空)、排序权重、启停状态
- 数据层 v20:node.enabled 启停;icon/sort 可编辑;禁用分组前台侧栏与卡片隐藏
- 修复同一父级下编辑排序权重被重置为末尾
- 测试 59 passed;Playwright 验证无图标/图标/排序/禁用/折叠
2026-10-31 10:22:58 +08:00
CNWei 165d3f112e feat(front): 前台切换到分组树 + 卡片组合
- 新 AppNext:首页按 /api/catalog2 的卡片勾选渲染(支持混合格式、每组条数),侧栏按分组树 + tree_hidden 渲染
- 分组页 /g/<key>、条目页 /g/<key>/i/<id>;条目在子树内递归查找,面包屑显示祖先链
- SPA 回落增加 /g/ 前缀;旧 /c/... 地址兼容
- 文档同步(路由、API、v19、限制)
- 测试 59 passed;Playwright 验证首页/混合卡片/分组页/条目页/侧栏隐藏
2026-10-30 20:45:39 +08:00
CNWei 2ccfb9b623 feat(admin): 新后台信息架构(分组管理/内容/侧边栏/卡片)
- /admin 切换为 AdminNext:分组管理(多层树、格式、增删改)、内容(选分组新增/编辑/删除条目)
- 侧边栏按分组树勾选,存入 settings.sidebar.tree_hidden
- 首页卡片:新建卡片、勾选分组(可混合格式)、每组条数
- 新增 manage.ts 客户端;旧 AdminPage 暂留作回退
- 测试 59 passed;Playwright 跑通 建根/子分组 → 加条目 → 侧栏勾选 → 混合卡片
2026-10-30 20:40:31 +08:00
CNWei 6340e435e9 feat(api): 原子模型分组树 / 卡片 / catalog2
- 新增 /api/catalog2:多层分组树(继承格式、带条目)+ 卡片勾选列表
- 新增 /api/nodes CRUD:任意层级分组、移动(防环)、排序、根分组格式
- 新增 /api/cards CRUD 与 PUT /api/cards/{id}/groups:一张卡可混合格式、可复用分组
- 条目允许挂在任意层级分组
- 测试 59 passed;API 冒烟通过(三级分组、卡片混合格式)
2026-10-30 20:35:37 +08:00
CNWei eb4fded8db feat(schema): v19 卡片/分组组合表与迁移回填
- 新增 card / card_group:卡片只存标题与顺序,内容由勾选分组组成,支持混合格式
- v18→v19 迁移:按现有 section_block 回填卡片与分组勾选(card-<section>)
- 新库 seed 完成后同样回填,旧界面暂不受影响
- 测试 59 passed(含 15→19 迁移)
2026-10-30 20:32:58 +08:00
CNWei e52db54bdc fix(admin): 子分组开关即时保存并立即显示分组管理
- 拨动开关即调用 PATCH allowGroups,成功后本地更新分类,无需再点保存
- 侧栏导航的分组列表同样跟随开关显示
- 失败时回滚开关状态
2026-10-30 20:10:07 +08:00
CNWei 22c73c1145 docs: 同步子分组开关与 v18 迁移 2026-10-30 20:05:33 +08:00
CNWei be7a0747ed feat(admin): 分区增加「允许子分组」开关(与类型解耦)
- node 新增 allow_groups(迁移 v18):应用默认开、海报/图文默认关,已有分组的非应用分区自动开启
- 后台每行一个「子分组」开关;分组管理、卡片预览、内容弹窗选组、侧栏建组都跟随开关
- 关闭开关时条目平铺且不丢内容;首页卡片指向分组会自动挪回分区
- 三种类型共用同一模板,类型只决定渲染方式
- 测试 59 passed(迁移 15→18、开关建组、类型切换)
2026-10-30 20:05:33 +08:00
CNWei 8df9ecdfda docs: 同步分组按类型收口 2026-10-30 19:55:45 +08:00
CNWei 261d202ce0 feat(admin): 分组能力按类型收口(应用可分组,海报/图文平铺)
- 只有应用类分区显示分组管理与卡片预览分组;海报/图文保持平铺
- 后端 create_group 拒绝非应用分区;海报/图文读取时把历史分组条目一并平铺
- 侧栏不再显示非应用分组与空分组
- 类型 应用 ↔ 海报 切换时分组入口和侧栏子项随之出现/消失
- 测试 59 passed
2026-10-30 19:55:45 +08:00
CNWei 1a4a6a3655 style(content): 明细页分组标题增加分区色条
- 分组标题与首页卡片统一为「色条 + 标题」,颜色取分区的 barColor
- 空分组不再在明细页占标题(后台仍保留)
2026-10-30 19:42:18 +08:00
CNWei 58725caa95 docs: 同步统一内容模型(分组对所有类型生效) 2026-10-30 18:56:18 +08:00
CNWei c84c41dc7d feat(content): 分组对所有内容类型生效,统一分区/分组/条目模型
- 分区=容器,分组=任意类型的可选子容器,条目可挂分区或分组(直属分区)
- catalog 所有类型统一输出 groups + items,条目查询按节点取
- 详情/侧栏/首页卡片预览支持所有类型的分组;多组显示小标题 + 未分组
- 后台「分区」「侧栏导航」对所有类型可建组;「内容」弹窗分组可选直属分区,编辑可换组
- 修复首页卡片配置为「整个分区」时预览为空
- 测试 59 passed
2026-10-30 18:56:17 +08:00
CNWei cc08188c1e docs: 交接文档同步弹窗编辑与侧栏建组 2026-10-30 18:31:41 +08:00
CNWei 71061c6b87 feat(admin): 内容弹窗新增/编辑、侧栏直接建子分组
- 内容 Tab 改为弹窗式新增;列表支持编辑(含换分区/分组)与删除
- 条目 PATCH 支持 nodeKey 移动,EntryPatch 改为 camelCase 解析
- 侧栏导航的每个应用分区下可直接新建子分组
- 测试 59 passed;Playwright 覆盖 建组→新增→编辑→移动分组
2026-10-30 18:31:41 +08:00
CNWei 26042c3e3f docs: 交接文档同步后台内容新增与分组 CRUD 2026-10-30 12:53:49 +08:00
CNWei 5982e0db5a feat(admin): 后台新增条目与分组 CRUD
- POST /api/items + DELETE /api/items/{key}:新增应用/海报/图文,按分区/分组挂载;PATCH 支持 nodeKey 移动
- 分组 CRUD:POST /api/categories/{id}/groups、PATCH/DELETE /api/groups/{id}(删除级联组内条目)
- PATCH /api/categories/{id} 增加 blockNodeKey,首页卡片可选择预览分组
- /admin 新增「内容」Tab(新增/删除条目),「分区」Tab 内联分组管理与卡片预览
- 测试 59 passed(条目创建/移动/删除、分组增删改)
2026-10-30 12:53:49 +08:00
CNWei f56614489a docs: 交接文档同步跳转修复、子分类与归类管理 2026-10-30 12:33:52 +08:00
CNWei 94e16b783c feat(admin): 跳转规范化、侧栏子分类与归类管理
- 条目保存时规范化 href(裸域名补 https,拒绝 javascript:/data: 等协议),详情按钮按解析结果置灰/跳转
- settings.sidebar 增加 hidden_groups / shown_groups / group_order,侧栏 Tab 支持分组显隐与排序
- 新增 PATCH /api/categories/{id} 与「归类」Tab:编辑类型/标题/图标/色条/条数;改类型自动迁移条目与首页卡片
- 测试 54 passed(href 规范化 3 例、分类更新 3 例)
2026-10-30 12:33:52 +08:00
CNWei 00e0360c85 docs: 交接文档同步组件库 v0.3.1 2026-10-30 08:59:22 +08:00
CNWei 5a411fdda8 docs: 交接文档同步组件库 v0.3.0(42 个组件) 2026-10-29 20:46:11 +08:00
CNWei fe9d1b2659 refactor(admin): 接入 PasswordInput,删除分类改用 Modal 确认
- 登录弹窗与系统改密统一用 @stash/ui 的 PasswordInput(改密页补上显隐切换)
- 删除分类不再用 window.confirm,改用 Modal + 危险按钮(组件库推荐的组合)
- 删除页面私有的 PasswordInput 组件与 .password-field 样式
2026-10-29 20:46:11 +08:00
stash 5de1382262 docs: 新增交接文档(架构、命令、状态、限制、下一步) 2026-10-29 20:31:52 +08:00
stash f9915535b6 fix(admin): 修复新增分类行 Select 覆盖「新增」按钮
- Select 自带 min-width:160px,原 120px 网格列导致溢出遮挡
- 第三列改为 160px,网格项 min-width:0
- 实测:select 875..1035,按钮 1043..1135,间隔 8px
2026-10-29 20:31:19 +08:00
stash 6d43b191c9 fix(admin): 删除按钮按可删性区分颜色(危险色 / 弱化色,均用主题令牌)
- 可删:var(--su-color-danger);hover 用 danger 10% 背景
- 不可删(本地服务):disabled + var(--su-text-muted)、opacity .45
- 侧栏与首页卡片两处共用同一个按钮组
2026-10-29 20:28:11 +08:00
stash 427338b329 feat(admin): 分区增删、资料设置、操作条与交互收尾
- POST/DELETE /api/categories(需登录,级联,local 保护)
- profile 设置(头像/名称/角色/简介/位置)应用到资料卡
- 详情操作条:浏览 +1、点赞切换、分享复制、跳转外链
- 编辑按钮垂直居中且留 12px;设置页按钮上间距、保存 Toast、Tabs 去滚动条
- 登录密码可见切换
- 测试 48 passed
2026-10-29 20:15:56 +08:00
stash 3c6c20e534 feat(admin): 首页卡片配置 + 页头开关验证 + 编辑按钮仅登录可见
- settings.sections:显隐 / 顺序 / 标题覆盖 / 预览条数,HomeView 应用
- /admin 新增「首页卡片」Tab,卡片标题使用展示块标题(常用站点等)
- 详情页 [编辑] 仅在登录后渲染(canEdit)
- 测试:46 passed;页头禁用、卡片隐藏/改标题/限条数、编辑可见性均实测
- 文档:ADMIN-PLAN、README
2026-10-29 19:21:34 +08:00
stash 8f688885ed feat(edit): P2 条目就地编辑 + P3 备份/审计/改密
- 迁移 v17:entry.updated_at(乐观锁)+ audit 表
- GET/PATCH /api/items/{key}:字段编辑、标签替换、409 冲突、400 校验
- 详情页 [编辑] → Drawer 表单(标题/简介/标签/评价/正文/封面/跳转),保存后局部刷新
- GET /api/backup:VACUUM INTO 一致性快照,附件下载
- /admin 系统 Tab:修改密码(清空会话)、下载备份
- 审计:item.update / settings.update
- 测试 46 passed(entries 4 例)
- 文档:ADMIN-PLAN P2/P3 记录、README
2026-10-29 12:59:21 +08:00
stash 9ae5a455c5 feat(admin): 登录改页内弹窗 + P1 站点设置(页头/侧栏)
- 登录:LoginDialog(Modal)替代 /login 页面;/login 重定向回首页
- 设置接口:GET /api/settings 公开读、PATCH 需登录 + version 乐观锁(409/400)
- setting 表存 site JSON:页头(启用/速度/条目+标签+色调)、侧栏(hidden/order)
- /admin:Tabs 页头/侧栏,条目增删与上下移,保存后首页生效
- 首页应用设置:ticker 文本占位符与速度、侧栏顺序与显隐
- 测试 42 passed(settings 5 例)
2026-10-29 12:52:29 +08:00
stash dea7781b86 feat(auth): P0 登录与会话(迁移 v16、argon2id、HttpOnly Cookie)
- 迁移机制:schema::migrate 支持 15→16,新增 admin/session/setting 表
- 接口:session/setup/login/logout/password;SameSite=Strict + Origin 校验 + 失败限流
- 会话只存 token 的 sha256;改密清空全部会话
- 前端:/login 独立壳(首次=设置密码),页头登录/退出切换
- 测试:37 passed(迁移 + 登录/会话 4 例)
- 文档:README 登录说明、docs/ADMIN-PLAN.md 方案与 P0 记录
2026-10-29 12:41:03 +08:00
stash 1aac8bcbe7 docs: 后台/编辑能力方案(登录、设置页、就地编辑、数据模型、风险清单) 2026-10-29 12:26:27 +08:00
stash fcba8274c8 fix(web): 渐隐遮罩不吃布局高度(抵消 gap)
- margin-bottom 改为 -32px - gap,首卡回到 148,滚动高度回到 979
2026-10-29 11:34:22 +08:00
stash f456829d16 fix(web): 内容区顶部渐隐恢复(content-veil)
- 滚动后用 sticky 遮罩在内容顶端做 32px 渐变,避免卡片/标题被硬切
- 未滚动时 opacity 0,滚动阈值沿用 scrolled 状态(>4px)
- 尊重 reduced-motion
2026-10-29 11:33:00 +08:00
stash 22f7853bdd feat(web): 页头滚动信息、移除返回按钮、面包屑左侧可点、侧栏加首页
- 页头中间信息改为无缝滚动 ticker(3 份拼接,24s 线性,尊重 reduced-motion)
- 去掉分区 / 条目 / 文章三处「返回」「返回首页」按钮
- 面包屑移到左侧,中间项可点击并通过 onNavigate 走 SPA 路由
- 侧栏新增「首页」,首页时高亮,点击回首页
2026-10-29 11:26:47 +08:00
stash 2724d6f3ff fix(web): 计数角标改回悬挂在标题右上角,搜索框圆角恢复 12px
- 角标:绝对定位 top -8 / right -18、16 高、8 圆角、12/700、危险色阴影(原来跟在标题后面占行内空间)
- 标题前色条补 8px 右间距,标题起点与旧版一致
- 搜索框:圆角 6 → 12(旧版 card-box 的圆角)
2026-10-29 10:23:09 +08:00
stash de1af92ee5 fix(web): 条目详情恢复 24px 标题、179px 封面与左对齐正文
- 标题:level1 24/600(原来 level2 18/500)
- 封面:portrait 179x269、square 96x96,margin 18/10,底色 primary-soft(原来 160 宽、灰底)
- 标签:22 高、12px、2px 10px 内边距(原来 24 高 13px)
- 正文段落:width 100%、14/1.7、次级色、左对齐(原来 16px 主色,flex 下收缩成内容宽而居中)
- 引用:引号 32/700 红色 35%(原来 18px 主色)
2026-10-29 10:17:54 +08:00
stash 43bf742df6 fix(web): 分区头恢复 44px 圆形图标、24/700 标题与 [n] 计数
- 图标:44×44 圆形 + 主色浅底(原来是裸字形 40px)
- 标题:24/700(原来用 Title level=2 的 18/500)
- 计数:改回旧版 [12] 文本(原来用了 Badge 胶囊)
- 间距:图标下 8、标题行高 1.5、计数上 4,与原版逐像素一致
2026-10-29 09:22:59 +08:00
stash ffb96ec7d1 fix(web): 文章头恢复居中排版,内容区补 4px 右留白
- 文章头:align-items center + text-align center + gap 12 + padding 4px 0(旧版规则)
- 文章元信息:改回 .article-meta 12px 行,圆点 3px、左右 8px(原来用 Space + 13px 文本)
- 内容区补 padding-right 4px(旧版 scroll 容器的留白),整体宽度与原版一致
2026-10-29 09:16:59 +08:00
stash 95b6cedb06 fix(web): 搜索框聚焦双框与滚动条
- 组件库 Input:聚焦只用边框变主色,不再叠加 outline(文本框点击也会命中 :focus-visible)
- 页面全局 :focus-visible 收窄到非表单控件,避免覆盖输入框自身的 outline 处理
- 搜索框恢复旧版浅边框 + 卡片阴影,聚焦不追加边框/焦点环
- Layout 新增 hideScrollbar,内容区滚动条隐藏(旧版行为)
2026-10-28 20:29:36 +08:00
stash 1942e201e9 fix(web): 视觉与交互 1:1(页头/侧栏/卡片/详情/动效)
对齐项(8080 vs 8081 实测,误差 ≤1px):
- 侧栏:资料卡 224、服务面板 144、导航 143,navTop 548 与原版一致
- 分区卡:142/142/327/270 vs 142/142/326/269;标题 16/1.2、更多按钮 24px
- 应用条目:48px 圆形头像 + 10px 小认证标;hover 名字变主色(不再整行变色)
- 封面:hover 上浮 2px(0.2s ease),去掉缩放;CES 封面恢复 16/10
- 搜索框:44 高、无边框 + 卡片阴影
- 服务面板:状态药丸 22、统计 12/18、操作按钮 28、刷新时图标原地旋转
- 详情:返回链接 hover 下划线、跳转按钮 hover 深主色、view-in 淡入动画
- 菜单:hover 主色、子菜单 0.28s grid 高度动画、chevron 6px 旋转
- 回到顶部:right 30、top 50%、滚动后淡入(补内容区滚动监听)
- 主题:卡片阴影恢复 2%/6%(旧版值)
2026-10-28 20:16:41 +08:00
stash 6b0c0a62e9 fix(web): 1:1 对齐——页头通栏、内部滚动、侧栏菜单与详情导航
- Layout: screen 模式下约束 body/main 高度,恢复内容区内部滚动(原来内容把根撑高被裁切)
- Layout: contentOffset=88,滚动区从页头下方开始,内容不再从悬浮搜索框下穿过
- 页头改回通栏 60px(contained 只包页身);资料卡头像恢复 64px 图标 + 品牌底色
- 详情导航条左右撑开(返回 / 面包屑),不再挤在左侧
- 侧栏菜单:行高 39、字重 500、圆角 8、CSS chevron、项间距 4px
- 服务面板去掉多余分隔线与外边距,恢复原高度
2026-10-28 19:45:37 +08:00
stash 1bdcca025b fix(web): 恢复与旧版一致的布局与组件参数
- 布局:Layout 使用 contained(1380)+gutter20+siderWidth240+gap20、solid 60px 页头、整屏内部滚动
- 搜索框恢复悬浮居中;侧栏与内容恢复 52/88px 顶部偏移
- 新增并使用 @stash/ui 的 Cover(海报 2:3 / 图文 4:3 / 宽图)替换手写封面
- 头像恢复 64px 品牌底色;正文恢复 14/1.9;详情标题、标签、引用、统计、跳转按原版还原
- 分区卡 padding 20、分区标题 16、网格间距 12/16 对齐原值
2026-10-28 18:12:14 +08:00
stash 64f1fbf906 refactor(web): 前端改用 @stash/ui 组件库
- 新增本地依赖 @stash/ui(file:../../stash-ui),引入其 style.css 与设计令牌
- 页面骨架改用 Layout(contained + header/sider),搜索框改为吸顶
- 卡片/菜单/按钮/标签/头像/分页/空态/提示全部换成组件库组件
- 删除本地 iconfont 与 1400 行手写组件样式,App.css 只保留页面级样式
- 数据层、路由、过滤逻辑未改动;33 个 Rust 测试通过
2026-10-28 12:44:50 +08:00
86 changed files with 11669 additions and 8842 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` |
Generated
+163
View File
@@ -79,6 +79,18 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -159,12 +171,36 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bytes"
version = "1.12.1"
@@ -242,6 +278,36 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -318,6 +384,27 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -656,6 +743,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -694,6 +792,15 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -712,6 +819,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -839,6 +976,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -891,6 +1039,7 @@ name = "stash"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"async-stream",
"axum",
"clap",
@@ -898,9 +1047,11 @@ dependencies = [
"include_dir",
"mime_guess",
"notify",
"rand",
"rusqlite",
"serde",
"serde_json",
"sha2",
"thiserror",
"tokio",
"tower 0.4.13",
@@ -916,6 +1067,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
@@ -1145,6 +1302,12 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
version = "2.10.0"
+3
View File
@@ -27,6 +27,9 @@ notify = "8"
# 内容库:SQLite 随二进制静态编译,运行时不需要任何外部 DLL
rusqlite = { version = "0.32", features = ["bundled"] }
argon2 = "0.5"
rand = "0.8"
sha2 = "0.10"
[build-dependencies]
# build.rs 用它递归复制前端产物到 $OUT_DIR/web
+262
View File
@@ -0,0 +1,262 @@
# Stash 交接文档
> 更新于 2026-11-04 19:30(Asia/Shanghai)。本文件是当前状态,取代旧版历史快照。
> 新会话先读这份,再按需看 `README.md`、`docs/ADMIN-PLAN.md`、`stash-ui/README.md`。
## 1. 一句话现状
Stash 是「本地项目托管 + 可配置导航首页」:Rust 单 crate 内嵌 React 前端,单管理员登录,内容库为 SQLite,分组 / 条目 / 首页卡片 / 站点设置都在 `/admin` 管理。
当前开发分支 `develop`,本地服务:
- 前台 + 后台:<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`
## 2. 仓库与分支
| 仓库 | 路径 | 分支 | 状态 |
| --- | --- | --- | --- |
| 主项目 | `D:\CNWei\CNW\Rust\stash` | `develop` | 工作区干净;功能最新 `1d1ece6`,本文件随后单独提交 |
| 组件库 | `D:\CNWei\CNW\Rust\stash-ui` | `master` | 工作区只有忽略的 `.idea/`;`994436a` 最新 |
- 主项目 remote 名为 `master`(不是 `origin`),地址 `https://gitea.ngmao.com/CNWei/stash.git`。
- `develop` 比 `master/develop` **领先 20 个提交,尚未推送**;此前 HTTPS 凭据不可用,需要时先准备 Gitea 凭据。
- `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
cd D:\CNWei\CNW\Rust\stash\stash-web
npm run build # tsc -b + vite,产物 dist/
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
```
测试(Windows 下覆盖 TEMP 权限):
```powershell
$env:TEMP = "D:\CNWei\CNW\Rust\stash\target\tmp"; $env:TMP = $env:TEMP
cargo test # 交接时 52 passed
```
组件库:
```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 端口 / 数据目录 / 托管目录
server.rs 全部 API + 内嵌前端 + SPA 回落
listing.rs 托管目录扫描
watcher.rs 文件监听 + SSE 防抖
db/
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/
AppNext.tsx 前台壳:路由、卡片、侧栏、搜索框、主题
main.tsx /admin 与普通前台分流
settings.ts SiteSettings 类型 / 默认值 / API 客户端
manage.ts 分组、卡片、catalog2 客户端
items.ts 条目编辑客户端
api.ts 项目扫描 / SSE 客户端
components/
admin/AdminNext.tsx 后台全部面板
header/SiteHeader.tsx 品牌 / 滚动信息 / 操作图标
content/ 卡片、网格、详情、编辑器
sidebar/ 资料卡、本地服务、导航
stash-ui/src/components/ SearchField / Input / EngineIcon 等
```
## 5. 数据库(v23)
路径:`target/debug/data/stash.db`(`--data` 可覆盖)。
- `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/`。
## 6. settings JSON
`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/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
GET /api/backup 需登录
GET /api/auth/session
POST /api/auth/setup / login / logout
PATCH /api/auth/password
```
已删除:`/api/catalog`、`/api/categories/*`、旧 `/api/groups/{id}`。
## 9. 组件库改动(stash-ui)
- `SearchField` 新增可选 `suffix` 与 `leading` 插槽;
- `Input` 新增 `leading` ReactNode,优先于 `icon`;
- 新增 `EngineIcon`(`internal/google/bing/baidu/sogou/360`),图标路径来自参考稿;`18px` 默认、`14px` 用于顶部小标签;
- 构建产物已更新,`stash-web` 通过 file 依赖读取;改库后必须重建 dist,否则主项目构建仍用旧类型/资源。
## 10. 已知限制 / 坑
| 项 | 说明 |
| --- | --- |
| 推送 | 本地 `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 改中文源码 |
## 11. 最近提交(主项目,倒序)
```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 同款深色背景
```
组件库最近:
```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`,确认工作区干净。
+183 -117
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 工具修改)。
@@ -31,6 +48,24 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
- Rust 工具链(2024 Edition)
- Node.js 24,推荐用 [Volta](https://volta.sh/) 管理;`stash-web/package.json` 里已用 `volta` 字段固定版本
### 登录与设置(v0.1.1 起)
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
- 登录后点「站点设置」进入 `/admin`,左侧导航 + 右侧内容:
- **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除;
- **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除;
- **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示);
- **资料管理**与**系统管理**(改密码、下载整库备份;系统管理里还能设正文字体:内置文楷 / 字体链接二选一,链接模式下可存多套字体(`font/` 目录里的文件或外部链接)并随时启用其中一套);
- 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效;
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
- `/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)。
## 工作方式
请求按下面的顺序命中:
@@ -39,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 页面(显示请求路径) |
@@ -54,11 +91,11 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| 地址 | 页面 |
| --- | --- |
| `/` | 首页分区卡片 |
| `/c/<分区>` | 分类明细 |
| `/c/<分区>/<分组>` | 只展示该分组 |
| `/c/<分区>/i/<条目>` | 条目详情,返回 → 分类明细 |
| `/c/<分区>/<分组>/i/<条目>` | 条目详情,返回 → 该分组 |
| `/` | 首页卡片(按后台勾选的分组组合) |
| `/g/<分组>`、`/c/<分组>` | 分组页(自身内容 + 子分组) |
| `/g/<分组>/i/<条目>` | 条目详情 |
| `/interface` | 接口一览(站内页面,侧边栏「查看接口」进入) |
| `/admin` | 站点设置(左侧导航 + 右侧内容) |
地址里的每一段都是**业务键**(也就是接口 JSON 里的 `id`):库里是 `node.key` / `entry.key`,
本地项目则是目录名(访问地址仍然是 `route_path`,两者不再混用)。
@@ -69,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 点卡片或链接仍可新标签打开。
## 目录结构
@@ -93,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 组件库(令牌 / 组件 / 展示站)
```
## 前端怎么被内嵌
@@ -131,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 —— 分区与分组(一棵容器树)
| 字段 | 类型 | 说明 |
@@ -151,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 —— 内容条目(网站 / 电影 / 文章共用)
@@ -171,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? | 字符图标,封面缺失时的占位 |
@@ -199,7 +249,7 @@ WHERE COALESCE(p.parent_key, p.key) = 'recommend';
好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况;
`node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。
三种内容共用一张表,只是**用到的字段不同**,形态由所属分区的 `view_mode` 决定:
三种内容共用一张表,只是**用到的字段不同**,形态由每条自己的 `entry.format` 决定:
| 类型 | 常用字段 |
| --- | --- |
@@ -232,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`,再删表,不会丢内容。
### 表关系
@@ -255,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(勾选的分组 + 每个分组的预览条数)
```
| 关系 | 外键 | 级联 |
@@ -266,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
@@ -350,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
@@ -370,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 自己的代码。
## 命令行参数
@@ -434,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` 派生。
### 订阅内容库变化
@@ -477,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` 与本站同源。
## 测试
@@ -503,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");
});
})();
+388
View File
@@ -0,0 +1,388 @@
# Stash 后台与编辑能力方案(讨论稿)
> 目标:登录后能改「左侧布局」「分区布局」「页头滚动信息」,并且能编辑内容。
> 状态:**待评审**,评审通过后按 §8 分期实现。
## 1. 结论先行
| 问题 | 建议 | 理由 |
| --- | --- | --- |
| 要不要单独的设置页? | **要**,`/admin`,只放「全局配置」 | 布局、页头、侧栏是低频、结构化、跨页面的配置,塞进当前页会让页面变编辑器 |
| 内容(条目/文章)在哪编辑 | **就地编辑**:详情页的 `[编辑]` 打开右侧 Drawer | 编辑需要上下文预览;跳设置页会丢上下文 |
| 本地服务面板纳入设置吗 | **只纳入「显隐/位置」**,数据本身不进设置 | 项目数、连接状态是运行时只读状态,不是可编辑配置 |
| 详情页要不要独立设置页 | **不要** | 内容编辑用 Drawer,全局配置才进 `/admin` |
一句话:**全局配置进 `/admin`,单个内容就地改。**
## 2. 登录方案(单用户、本地)
- 账号:单管理员(`admin`),密码用 **argon2id** 哈希存库;首次启动若无密码,生成随机密码打印到控制台 / 或首访引导设置。
- 会话:登录成功下发 **HttpOnly + SameSite=Lax + Secure(可选)** 的 Cookie(随机 256bit token,库里存哈希,7 天过期,可撤销)。
- 写操作:所有 `POST/PATCH/DELETE` 校验会话 + **CSRF token**(双提交 Cookie 或自定义头)。
- 读操作:页面与 `GET /api/*` 保持公开(本地工具,保持只读可用);写接口一律要求登录。
- 频控:登录失败 5 次锁 1 分钟,防止本地暴破。
- 不做的:多用户/角色、OAuth、找回密码(单用户场景用「重置密码」命令)。
## 3. 信息架构:`/admin`
| Tab | 内容 | 存储 |
| --- | --- | --- |
| 布局 | 侧栏项顺序/显隐/分组、分区卡片顺序、每卡展示的分组与条数 | `settings.layout` |
| 页头 | 滚动信息条目(文本/标签/强调色)、速度、是否启用;品牌名 | `settings.header` |
| 内容 | 分区、分组、条目的增删改与排序(批量) | 现有内容库表 |
| 系统 | 修改密码、本地服务面板显隐、备份/导出、恢复默认 | `settings.system` / `auth` |
侧栏与页头的实时预览:设置页右侧放一个缩小的 `Layout` 预览(复用组件库)。
## 4. 编辑模型
### 4.1 内容编辑(就地)
- 详情页 `[编辑]` → `Drawer`(宽 480)表单:标题、标签、描述、正文(Markdown)、封面、链接;
- 保存:`PATCH /api/items/:id`,乐观锁(`updatedAt`),冲突时提示「内容已被修改,刷新后重试」;
- 成功:`toast.success` + 局部刷新当前条目(不整页刷新)。
### 4.2 全局配置(`/admin`)
- 表单保存走 `PATCH /api/settings`,整块 JSON 校验后落库;
- 提供「恢复默认」与「导出 JSON / 导入」。
## 5. 数据模型
```sql
-- 设置:一个 key 一段 JSON,带版本号用于乐观锁
settings(key TEXT PRIMARY KEY, value TEXT NOT NULL, version INTEGER NOT NULL, updated_at TEXT);
-- 管理员(单行)
admin(id INTEGER PRIMARY KEY CHECK (id = 1), username TEXT, password_hash TEXT, updated_at TEXT);
-- 会话
sessions(token_hash TEXT PRIMARY KEY, created_at TEXT, expires_at TEXT, last_seen_at TEXT);
-- 审计(谁在什么时候改了什么)
audit(id INTEGER PRIMARY KEY AUTOINCREMENT, at TEXT, action TEXT, target TEXT, detail TEXT);
```
**迁移问题**:当前 `PRAGMA user_version = 15` 且「删库重建」。加这些表要引入**第一套迁移**(v15 → v16),否则老库升级会坏。这是必须先解决的前置项。
## 6. API 草案
```
POST /api/auth/login {username,password} -> set-cookie
POST /api/auth/logout
GET /api/auth/session -> {authenticated, username}
PATCH /api/auth/password {current,next}
GET /api/settings -> {layout, header, system, version}
PATCH /api/settings {partial} + If-Match: version
PATCH /api/items/:id 条目编辑(乐观锁)
POST /api/categories 新建分区
PATCH /api/categories/:id 分区配置(展示块、预览条数、色条)
DELETE /api/items/:id
```
## 7. 没被提到、但必须一起考虑的
### 安全
1. **XSS**:正文是 Markdown,现有渲染器不处理 HTML;编辑后必须转义/白名单,否则登录用户可自注入脚本(虽然是本地单用户,仍要防)。
2. **CSRF**:Cookie 会话必须配 token 或 `SameSite=Strict`。
3. **上传**:封面/配图上传的存储路径、大小限制、MIME 校验、防目录穿越(public 是静态托管目录)。
4. **密码**:哈希算法与参数、首次设置流程、重置命令。
5. **会话**:过期、撤销、记住我、并发登录。
6. **CSP / 安全头**:内嵌页面与 iconfont 的 CSP 白名单。
### 数据与迁移
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. **撤销**:误删条目能否恢复(软删除 + 回收站)。
### 编辑体验
14. **未保存离开**:Drawer 关闭前的确认(beforeunload / 二次确认)。
15. **表单校验**:必填、长度、URL 合法性、错误定位。
16. **图片上传**:拖拽、进度、失败重试。
17. **排序交互**:拖拽排序(新组件),键盘可达的排序替代方案。
18. **实时预览**:Markdown 编辑预览(复用 `ArticleBody`)。
19. **移动端编辑**:Drawer 全宽、软键盘遮挡、保存按钮吸底。
20. **可访问性**:Drawer 焦点陷阱(已有 `<dialog>`)、表单 label/错误关联、快捷键。
21. **编辑模式提示**:登录后页面出现明显「编辑中」状态,避免误操作。
### 工程
22. **SSE 推送**:编辑后前端已有 `live` 通道,需要定义「设置变更」是否也推送。
23. **缓存**:设置读取缓存与失效策略。
24. **并发写 SQLite**:WAL、事务边界、写冲突重试。
25. **测试**:登录/权限/迁移/设置校验/编辑接口的单测 + e2e。
26. **权限边界**:未来多用户时,哪些接口要按角色收敛(现在先留 `require_auth` 中间件层)。
## 8. 分期建议
| 阶段 | 范围 | 依赖 |
| --- | --- | --- |
| **P0** | 迁移机制 v16 + admin/sessions/settings 表 + 登录/登出/会话 + 登录页 + 写接口鉴权 | 无 |
| **P1** | `/admin` 设置页:页头滚动信息、侧栏项显隐/顺序、分区卡配置 | P0 |
| **P2** | 内容就地编辑(Drawer 表单 + 乐观锁 + 富文本/图片上传) | P0 + 迁移 |
| **P3** | 备份/恢复、审计、回收站、导入导出 | P1/P2 |
## 9. 组件库缺口(P1/P2 会用到)
| 缺口 | 说明 |
| --- | --- |
| `Textarea` | 多行输入(Markdown、描述),当前只有单行 `Input` |
| `ColorPicker` | 分区色条 / 标签色 |
| `SortableList` | 拖拽排序 + 键盘上/下移 |
| 表单布局 | `FormField` 有了,但「两列/分组/折叠」还需要页面组合或补 `Form` |
| `Tabs`/`Drawer`/`Switch`/`Toast` | 已有,可直接用 |
## 10. 开放决策(需拍板)
1. 设置入口:独立 `/admin` 页面(推荐)/ 右侧设置抽屉 / 两者都有。
2. 登录范围:单用户密码(推荐)/ 仅局域网免密 + 编辑口令 / 多用户。
3. 读接口是否也要登录:公开(推荐)/ 全站需登录。
4. 内容编辑是否要「草稿 → 发布」两态:先不要(推荐,保持即时生效)/ 要。
5. 编辑图片:先只接受 URL(推荐,P2 后期再做上传)/ 首期就要上传。
## 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)`,左右两栏都在这个容器里;
- 搜索框仍 fixed 在页头下方;滚动内容从搜索框下方开始;左右两栏位移完全一致(滚动 300px 后两边 top 都是 -152);
- 内容区 `overflow: visible`,侧栏高度改为 auto;返回顶部与路由切换都改用 `#page-scroll`;
- 验证:滚动后两栏 top 相同、搜索框位置不变、返回顶部回到 0、切分组后滚动归零。
### P18(2026-10-31,搜索框下滚动、本地服务排序、拖拽末位修复)
- 前台恢复「内容区内部滚动」:页头与搜索框固定,滚动区域从搜索框下方开始;侧栏不再独立滚动,内容区滚动时同步设置侧栏 `scrollTop`,滚轮在侧栏上也驱动内容区;
- 本地服务卡片正式进入 `card` 表(v22,key=`local-service`,sort=-1),后台卡片列表可拖动排序,首页按顺序渲染;后端禁止删除、改名、配置分组;
- 修复拖拽排序:向下拖时插到目标之后,倒数第二项可以拖到最后一项;
- 验证:搜索框滚动前后位置不变;内容滚动 300px 时侧栏 `scrollTop` 跟随(受可滚高度限制);本地服务拖到末位、倒数第二拖到末位均落库;首页顺序与后台一致;删除本地服务返回 400。
### P17(2026-10-31,侧栏卡片显隐、本地服务卡片、同步滚动)
- 侧边栏设置顶部新增「侧栏卡片」:本地管理员 / 本地服务各一个开关,即时保存到 `settings.sidebar.show_profile / show_service`;与下方分组导航分区显示;
- 首页恢复「本地服务」卡片(由 `/api/projects` 运行时生成,应用网格),固定排在最前;移除卡片右上角三点;
- 首页卡片后台新增只读「本地服务」表:列出项目名 / 访问地址,标记不可删除,不提供删除/改名;
- 前台放弃整屏内滚动,改为页面整体滚动,左右两栏同步滚动;
- 验证:开关即落库、前台隐藏对应卡片、三点数量为 0、本地服务表无删除按钮、滚动 300px 两栏同步位移 300px。
### P16(2026-10-31,格式下沉到条目)
- 数据库 v21:`entry.format`(apps / posters / articles),老数据按所属根分组的 `view_mode` 自动回填;新库 seed 按分区写入;
- 分组管理移除「格式」字段与显示,根分组只作为容器;
- 内容弹窗新增「格式」选择,列表增加「格式」列;同一条目可单独设为应用/海报/图文;
- 前台改为按 `entry.format` 渲染:首页卡片、分组页同一分组内可同时出现应用网格与海报/图文网格;详情页按条目格式选择模板;
- 验证:迁移后 20 应用 / 13 海报;在应用分组内新建海报条目后,内容列表、首页卡片、分组页均能混合展示。
### P15(2026-10-31,卡片新建弹窗、标题保存/删除、拖动排序)
- 新建卡片改为弹窗:输入标题 → 确认新建 / 取消关闭;创建后自动选中新表;
- 卡片标题右侧增加「保存」和「删除」按钮,标题按 Enter 或保存按钮提交;
- 左侧卡片支持 HTML5 拖动排序,拖放后按顺序写入 `card.sort`,刷新后首页卡片顺序一致;
- 标签栏与内容之间补 20px 间距;左侧卡片增加拖动手柄和提示;
- 验证:弹窗取消/确认、保存/删除按钮、拖动 `sadf`/新卡到首位后 `/api/catalog2` 顺序变化。
### P14(2026-10-31,首页卡片改为侧栏 + 表格)
- 首页卡片不再纵向堆叠:左侧是卡片列表(名称 + 已选分组数),右侧是当前卡片的分组表格(分组名称 / 预览条数 / 展示开关);
- 「新建卡片」即新增一张表,创建后自动选中;卡片标题、删除在表格上方;
- 表格内开关、条数即时保存,未选分组显示 —;侧栏卡片可一键切换;
- 验证:切换卡片表格同步、新建后自动选中、表格开关即时写入 `card_group`、无卡片级保存按钮。
### P13(2026-10-31,首页卡片按侧边栏逻辑)
- 每张卡片的分组勾选改为与侧边栏一致:标题在左、开关在右、勾选/取消即时保存(`PUT /api/cards/{id}/groups`),去掉每张卡的「保存」按钮;
- 卡片标题、单组预览条数改为失焦保存;条数清空表示使用默认;
- 卡片删除仍保留图标按钮;验证:无卡片级保存按钮、开关在右、勾选后 `card_group` 即时更新、条数 2 与改名均落库。
### P12(2026-10-31,内容/侧边栏交互微调)
- 内容页与条目弹窗的分组下拉只显示分组名,去掉格式后缀与缩进空格,选项统一左对齐;
- 侧边栏页:开关移到右侧,拨动即写入 `settings.sidebar.tree_hidden`;移除「保存侧边栏 / 撤销改动 / 保存」按钮,保存成功用 Toast 提示;
- 验证:下拉选项无括号、x 对齐;侧边栏无保存按钮、开关在右、切换后设置即时落库。
### P11(2026-10-31,内容页列表化)
- 内容页改为与分组管理一致的列表:标题 / 跳转链接 / 所属分组 / 操作;
- 选中主分组时递归列出其所有子分组的条目(显示所属分组),选子分组只列该子树;
- 编辑弹窗始终显示「标识」字段(编辑时只读,创建时可填);
- 条目接口补齐图文的 `href`,列表统一展示跳转链接(空值显示 —);
- 验证:主分组 20 条、子分组 4 条;编辑弹窗标识为 `github` 且只读;`cargo test` 59 passed。
### P10(2026-10-31,分组管理页重做)
- 参考 `TT-Helper/需求/5297/1.html` 重做分组管理:树形表格(分组名称 / 排序权重 / 关联网页数 / 状态 / 操作),去掉「展开全部 / 折叠全部」,新建根分组放左侧;
- 分组弹窗:上级分组(可移动、防环)、名称、格式(根)、**图标(可留空)**、排序权重、状态;
- 数据层:`node.enabled`(迁移 v20)控制启停,禁用分组在前台侧栏/卡片隐藏;`node.icon` 支持 iconfont 名、字符或空;
- 排序修复:同一父级下编辑排序权重不再被当成「移动」重置为末尾;
- 验证:无图标/有图标、排序 5、禁用后前台隐藏、折叠行均通过;`cargo test` 59 passed。
### P9(2026-10-30,前台切换)
- **前台**:`AppNext` 改为读 `/api/catalog2`,首页按 `card` 勾选的分组渲染(可混合格式,每组条数独立),侧栏按分组树 + `tree_hidden` 渲染,分组页 `/g/<key>`、条目页 `/g/<key>/i/<id>`;
- **详情**:条目在分组子树内递归查找,面包屑显示完整祖先链;编辑仍用 Drawer + `PATCH /api/items`;
- **服务端**:SPA 回落增加 `/g/` 前缀;
- **兼容**:旧 `/c/...` 地址按第一段作为分组 key 跳转;旧 `/api/catalog`、旧后台组件暂时保留;
- 验证:首页卡片、混合格式卡片(应用 + 海报)、侧栏隐藏指定分组、分组页/条目页均通过。
### P8(2026-10-30,子分组开关)
- **与类型解耦**:分区新增 `allow_groups` 开关(迁移 v18);三种类型共用同一模板,开启后可建分组,关闭后平铺;
- **默认值**:老库升级时应用类开启、海报/图文关闭;已有分组的非应用分区自动开启;新建分区按类型给默认值,之后可随时改;
- **平铺兼容**:关闭开关时读取会把(历史)分组内条目一起平铺,内容不丢;首页卡片若指向分组会自动挪回分区;
- **后台**:「分区」每行新增「子分组」开关,**拨动即保存并立刻出现/收起分组管理**;「内容」弹窗的分组选择随开关出现;「侧栏导航」的建组入口同理;空分组不在侧栏显示;
- 验证:推荐电影(海报)开启开关 → 建组 → 加海报 → 详情按组显示;关闭 → 恢复平铺且条目仍在;`cargo test` 59 passed。
### P7(2026-10-30,统一内容模型)
- **模型统一**:分区(section)是容器,分组(group)是任意类型的可选子容器,条目可挂在分区或分组上;`view_mode` 只决定渲染方式;
- **catalog**:所有类型都输出 `groups`(带条目)与 `items`(直属分区条目);
- **渲染**:详情页三种类型都支持分组(多组显示小标题,直属条目进「未分组」);侧栏子菜单、首页卡片预览分组对所有类型生效;
- **后台**:「分区」「侧栏导航」对所有类型可建组;「内容」弹窗的分组选择新增「直属分区」,编辑可换组;
- **修复**:首页卡片配置为「整个分区」时应用类预览为空;catalog 条目查询改为按节点取,避免分组重复/错漏;
- 测试:59 passed。
### P6(2026-10-30)
- **后台新增内容**:`POST /api/items`(`key` + `nodeKey` + 条目字段)、`DELETE /api/items/{key}`;「内容」Tab 用弹窗新增,列表逐条编辑(含换分区/分组)与删除;
- **分组 CRUD**:`POST /api/categories/{id}/groups`、`PATCH /api/groups/{id}`(改名/侧栏显隐)、`DELETE /api/groups/{id}`(级联组内条目);「分区」Tab 内联管理,「侧栏导航」Tab 也可直接新建子分组;
- **卡片预览分组**:`PATCH /api/categories/{id}` 增加 `blockNodeKey`,可在「分区」Tab 选择首页卡片预览哪个分组;
- 后端校验:应用类条目必须挂在分组上;条目移动通过 `PATCH /api/items/{key}` 的 `nodeKey`;
- 测试:59 passed(新增条目创建/移动/删除 3 例、分组 2 例)。
### P5(2026-10-30)
- **跳转修复**:保存条目时规范化 `href`(`www.zhihu.com` → `https://www.zhihu.com`,站内路径保留,`javascript:` / `data:` 等协议拒绝),前端渲染再做一次兜底;
- **侧栏子分类**:`settings.sidebar` 增加 `hidden_groups` / `shown_groups` / `group_order`,侧栏 Tab 展示分组并支持单独显隐、上下移;
- **归类页**:新增 `PATCH /api/categories/{id}`,`/admin` 的「归类」Tab 可改分区类型(应用/海报/图文)、标题、图标、色条、预览条数;切换类型时自动迁移条目与首页卡片指向;
- 测试:54 passed(新增 href 规范化 3 例、分类更新 3 例)。
### P4(2026-10-29,收尾)
- 分区增删:`POST /api/categories`、`DELETE /api/categories/{id}`(需登录,级联内容,`local` 不可删),首页与侧栏共用;
- 资料设置:头像 URL / 名称 / 角色 / 简介 / 位置,应用到侧栏资料卡;
- 操作条:进入详情页记一次浏览、点赞可切换(localStorage 记状态)、分享复制链接、跳转外链;
- 交互细节:`[编辑]` 与标题垂直居中并留 12px 间距;设置页操作按钮与内容拉开间距;保存改用 Toast(2.4s 自动消失);设置页 Tabs 不再出现滚动条;
- 登录密码框增加显示/隐藏切换(错误后仍可用);
- 测试:48 passed(新增 categories 2 例、settings 资料校验)。
### P0(已完成,2026-10-29)
- 迁移机制:`user_version 15 → 16`,`schema::migrate` 逐级升级;v15 老库启动时自动补 `admin` / `session` / `setting` 三表;
- 登录:`POST /api/auth/setup`(仅首次)、`/login`、`/logout`、`PATCH /api/auth/password`;argon2id + sha256(token) 落库;
- 会话:HttpOnly + SameSite=Strict + Path=/,7 天;改密后所有会话失效;
- 防护:Origin 同源校验、连续失败 5 次锁 60 秒;
- 前端:`/login` 独立壳(入口按 pathname 分流)、页头登录/退出登录切换;
- 测试:后端 37 个单测(新增迁移 2 个 + 登录 4 个),登录流程 e2e 手动脚本验证通过。
**未做(P1/P2)**:`/admin` 设置页、侧栏/页头编辑、内容就编辑、`setting` 表的读写接口。
### P1(已完成,2026-10-29)
- 登录改为**页内弹窗**(`LoginDialog`):`/login` 旧地址重定向回首页,不再单开页面;
- 设置读写:`GET /api/settings`(公开)、`PATCH /api/settings`(需登录,`version` 乐观锁,冲突 409、校验失败 400);
- `setting` 表存 `key='site'` 的 JSON:页头(启用 / 速度 / 条目与标签/色调)、侧栏(隐藏 / 顺序);
- `/admin` 设置页:Tabs(页头滚动信息 / 侧栏导航),条目支持增删、上移下移、占位符 `{count}`/`{project}`/`{status}`;
- 首页应用设置:滚动条目的文本/标签/速度/启用,侧栏按顺序与显隐渲染;
- 测试:42 passed(新增 settings 5 例:默认值、版本递增、冲突、校验、坏 JSON 回落)。
### P1 扩展(2026-10-29,同日)
- 首页卡片可配置:显隐、顺序、标题覆盖、每卡预览条数(`settings.sections`,前端 `HomeView` 应用);
- 页头滚动信息支持**启用/禁用**(`header.enabled`,关闭后页头中间留空);
- 「编辑」按钮只在登录后出现(`canEdit`,未登录时详情页与文章页不渲染 `[编辑]`);
- 侧栏「首页」与卡片顺序相互独立:侧栏管导航,卡片管首页布局。
### P2(已完成,2026-10-29)
- 库新增 `Textarea`(stash-ui v0.2.0):`rows` / `resize` / `invalid`;
- 条目编辑:`GET /api/items/{key}`(公开读)、`PATCH /api/items/{key}`(需登录);
- 乐观锁:`entry.updated_at`(迁移 v17),冲突返回 409;
- 可编辑字段:标题、简介、标签、一句话评价、正文 Markdown、封面、跳转地址;
- 前端:详情页 `[编辑]` → `Drawer` 表单;保存后 `catalog.reload()` 局部刷新,不整页刷新;
- 审计:每次条目更新写入 `audit` 表;
- 测试:46 passed(新增 entries 4 例:更新/标签替换、冲突、空标题、不存在)。
### P3(部分完成,2026-10-29)
- 审计表 `audit`:记录 `item.update`、`settings.update`;
- 备份:`GET /api/backup`(需登录)用 `VACUUM INTO` 生成一致性副本并以附件下载;
- 系统设置 Tab:修改密码(成功后所有会话失效)、下载备份;
- **未做**:恢复/导入(需要停机替换文件或在线校验导入流程)、回收站/软删除、图片上传、拖拽排序(当前用上移/下移按钮替代)。
+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!(
+309
View File
@@ -0,0 +1,309 @@
//! 登录与会话。
//!
//! - 单管理员:`admin` 表只允许一行,密码用 argon2id 存 PHC 字符串;
//! - 会话:随机 32 字节 token 放在 HttpOnly Cookie 里,库里只存它的 sha256;
//! - 所有函数都是同步的,调用方负责放到 `spawn_blocking` 里。
use std::time::{SystemTime, UNIX_EPOCH};
use argon2::{
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
password_hash::{SaltString, rand_core::OsRng},
};
use rusqlite::{Connection, OptionalExtension, params};
use sha2::{Digest, Sha256};
use crate::{db::map_error, error::StashError};
/// 会话有效期:7 天
pub const SESSION_TTL_SECS: i64 = 7 * 24 * 60 * 60;
/// 密码最短长度
pub const MIN_PASSWORD_LEN: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
pub struct SessionState {
pub authenticated: bool,
/// 是否已经设置过管理员密码(前端据此显示「登录」还是「首次设置」)
pub has_admin: bool,
pub username: Option<String>,
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0)
}
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect()
}
fn random_token() -> String {
let bytes: [u8; 32] = rand::random();
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn hash_password(password: &str) -> Result<String, StashError> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|error| StashError::Database(format!("密码哈希失败: {error}")))
}
fn verify_hash(password: &str, hash: &str) -> bool {
match PasswordHash::new(hash) {
Ok(parsed) => Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok(),
Err(_) => false,
}
}
pub fn has_admin(conn: &Connection) -> Result<bool, StashError> {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM admin", [], |row| row.get(0))
.map_err(map_error)?;
Ok(count > 0)
}
fn admin_row(conn: &Connection) -> Result<Option<(String, String)>, StashError> {
conn.query_row("SELECT username, password_hash FROM admin WHERE id = 1", [], |row| {
Ok((row.get(0)?, row.get(1)?))
})
.optional()
.map_err(map_error)
}
fn validate_password(password: &str) -> Result<(), StashError> {
if password.chars().count() < MIN_PASSWORD_LEN {
return Err(StashError::InvalidDirectory(format!(
"密码至少 {MIN_PASSWORD_LEN} 位"
)));
}
Ok(())
}
/// 首次设置管理员密码;已经设置过则拒绝。
pub fn create_admin(conn: &Connection, username: &str, password: &str) -> Result<(), StashError> {
validate_password(password)?;
if has_admin(conn)? {
return Err(StashError::InvalidDirectory("管理员已存在,不能重复初始化".into()));
}
let hash = hash_password(password)?;
conn.execute(
"INSERT INTO admin (id, username, password_hash, updated_at) VALUES (1, ?1, ?2, ?3)",
params![username, hash, now()],
)
.map_err(map_error)?;
Ok(())
}
/// 校验密码;成功返回用户名。
pub fn verify_login(conn: &Connection, password: &str) -> Result<Option<String>, StashError> {
let Some((username, hash)) = admin_row(conn)? else {
return Ok(None);
};
Ok(verify_hash(password, &hash).then_some(username))
}
/// 修改密码:必须提供当前密码;成功后清掉其它会话。
pub fn change_password(
conn: &mut Connection,
current: &str,
next: &str,
) -> Result<bool, StashError> {
validate_password(next)?;
let Some((_, hash)) = admin_row(conn)? else {
return Err(StashError::InvalidDirectory("尚未设置管理员密码".into()));
};
if !verify_hash(current, &hash) {
return Ok(false);
}
let new_hash = hash_password(next)?;
let tx = conn.transaction().map_err(map_error)?;
tx.execute(
"UPDATE admin SET password_hash = ?1, updated_at = ?2 WHERE id = 1",
params![new_hash, now()],
)
.map_err(map_error)?;
tx.execute("DELETE FROM session", []).map_err(map_error)?;
tx.commit().map_err(map_error)?;
Ok(true)
}
/// 新建会话,返回**原始 token**(只在这里出现一次,库里存哈希)。
pub fn issue_session(conn: &Connection) -> Result<String, StashError> {
let token = random_token();
let issued = now();
conn.execute(
"INSERT INTO session (token_hash, created_at, expires_at, last_seen_at)
VALUES (?1, ?2, ?3, ?2)",
params![hash_token(&token), issued, issued + SESSION_TTL_SECS],
)
.map_err(map_error)?;
Ok(token)
}
/// 读取会话状态;token 无效/过期都返回未登录。
pub fn state(conn: &Connection, token: Option<&str>) -> Result<SessionState, StashError> {
let has = has_admin(conn)?;
let Some(token) = token else {
return Ok(SessionState {
authenticated: false,
has_admin: has,
username: None,
});
};
let token_hash = hash_token(token);
let row: Option<(i64,)> = conn
.query_row(
"SELECT expires_at FROM session WHERE token_hash = ?1",
[&token_hash],
|row| Ok((row.get(0)?,)),
)
.optional()
.map_err(map_error)?;
let Some((expires_at,)) = row else {
return Ok(SessionState {
authenticated: false,
has_admin: has,
username: None,
});
};
if expires_at <= now() {
conn.execute("DELETE FROM session WHERE token_hash = ?1", [&token_hash])
.map_err(map_error)?;
return Ok(SessionState {
authenticated: false,
has_admin: has,
username: None,
});
}
conn.execute(
"UPDATE session SET last_seen_at = ?1 WHERE token_hash = ?2",
params![now(), token_hash],
)
.map_err(map_error)?;
Ok(SessionState {
authenticated: true,
has_admin: has,
username: admin_row(conn)?.map(|(username, _)| username),
})
}
pub fn revoke(conn: &Connection, token: &str) -> Result<(), StashError> {
conn.execute("DELETE FROM session WHERE token_hash = ?1", [hash_token(token)])
.map_err(map_error)?;
Ok(())
}
/// 清理过期会话(登录时顺手调用即可)。
pub fn purge_expired(conn: &Connection) -> Result<(), StashError> {
conn.execute("DELETE FROM session WHERE expires_at <= ?1", [now()])
.map_err(map_error)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.execute_batch(
"CREATE TABLE admin (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE session (
token_hash TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);",
)
.expect("应能建表");
conn
}
#[test]
fn setup_then_login_and_session() {
let conn = db();
assert!(!has_admin(&conn).expect("可查"));
create_admin(&conn, "admin", "stash-pass-1").expect("首次设置应成功");
assert!(has_admin(&conn).expect("可查"));
assert!(verify_login(&conn, "wrong-pass").expect("可校验").is_none());
assert_eq!(
verify_login(&conn, "stash-pass-1").expect("可校验").as_deref(),
Some("admin")
);
let token = issue_session(&conn).expect("应能建会话");
let session = state(&conn, Some(&token)).expect("应能读状态");
assert!(session.authenticated);
assert_eq!(session.username.as_deref(), Some("admin"));
// 原始 token 不落库
let stored: String = conn
.query_row("SELECT token_hash FROM session", [], |row| row.get(0))
.expect("应有会话行");
assert_ne!(stored, token);
assert_eq!(stored, hash_token(&token));
revoke(&conn, &token).expect("应能撤销");
assert!(!state(&conn, Some(&token)).expect("应能读状态").authenticated);
}
#[test]
fn rejects_short_password_and_second_setup() {
let conn = db();
assert!(create_admin(&conn, "admin", "short").is_err());
create_admin(&conn, "admin", "stash-pass-1").expect("首次设置应成功");
assert!(create_admin(&conn, "admin", "stash-pass-2").is_err());
}
#[test]
fn change_password_requires_current_and_clears_sessions() {
let mut conn = db();
create_admin(&conn, "admin", "stash-pass-1").expect("应能设置");
let token = issue_session(&conn).expect("应能建会话");
assert!(!change_password(&mut conn, "nope", "stash-pass-2").expect("可调用"));
assert!(change_password(&mut conn, "stash-pass-1", "stash-pass-2").expect("可调用"));
assert_eq!(
verify_login(&conn, "stash-pass-2").expect("可校验").as_deref(),
Some("admin")
);
assert!(!state(&conn, Some(&token)).expect("可读").authenticated);
}
#[test]
fn expired_session_is_rejected_and_deleted() {
let conn = db();
create_admin(&conn, "admin", "stash-pass-1").expect("应能设置");
let token = issue_session(&conn).expect("应能建会话");
conn.execute("UPDATE session SET expires_at = ?1", [now() - 1])
.expect("应能改过期时间");
assert!(!state(&conn, Some(&token)).expect("可读").authenticated);
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM session", [], |row| row.get(0))
.expect("可查");
assert_eq!(count, 0, "过期会话应被删除");
}
}
+237
View File
@@ -0,0 +1,237 @@
//! 首页卡片:只存标题/顺序,内容由 `card_group` 勾选分组组成。
//!
//! 卡片与分组是多对多:一张卡可以放不同格式的分组,分组也可以被多张卡复用。
//! 卡片本身不拥有数据,删卡不影响内容。
use rusqlite::{Connection, OptionalExtension, params};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{db::entries, db::map_error, error::StashError};
#[derive(Debug, Clone, Deserialize)]
pub struct NewCard {
pub title: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCard {
pub title: Option<String>,
pub sort: Option<i64>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CardGroupInput {
pub group_key: String,
#[serde(default)]
pub preview_limit: Option<i64>,
}
pub fn load(conn: &Connection) -> Result<Vec<Value>, StashError> {
let mut stmt = conn
.prepare("SELECT id, key, title, sort FROM card ORDER BY sort, id")
.map_err(map_error)?;
let cards = stmt
.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, i64>(3)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
let mut result = Vec::with_capacity(cards.len());
for (id, key, title, sort) in cards {
let mut group_stmt = conn
.prepare(
"SELECT group_key, preview_limit FROM card_group
WHERE card_id = ?1 ORDER BY sort",
)
.map_err(map_error)?;
let groups = group_stmt
.query_map([id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<i64>>(1)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
result.push(json!({
"id": id,
"key": key,
"title": title,
"sort": sort,
"groups": groups
.into_iter()
.map(|(group_key, preview_limit)| json!({
"groupKey": group_key,
"previewLimit": preview_limit,
}))
.collect::<Vec<_>>(),
}));
}
Ok(result)
}
pub fn create(conn: &mut Connection, new: NewCard) -> Result<i64, StashError> {
let title = new.title.trim();
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("卡片名称需为 1–40 个字".into()));
}
let tx = conn.transaction().map_err(map_error)?;
let sort: i64 = tx
.query_row("SELECT COALESCE(MAX(sort), 0) + 1 FROM card", [], |row| {
row.get(0)
})
.map_err(map_error)?;
tx.execute(
"INSERT INTO card (key, title, sort) VALUES ('pending', ?1, ?2)",
params![title, sort],
)
.map_err(map_error)?;
let id = tx.last_insert_rowid();
tx.execute(
"UPDATE card SET key = ?1 WHERE id = ?2",
params![format!("card-{id}"), id],
)
.map_err(map_error)?;
entries::audit(&tx, "card.create", &format!("card-{id}"), title)?;
tx.commit().map_err(map_error)?;
Ok(id)
}
pub fn update(conn: &mut Connection, id: i64, patch: UpdateCard) -> Result<(), StashError> {
let current: Option<(String, String, i64)> = conn
.query_row(
"SELECT key, title, sort FROM card WHERE id = ?1",
[id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.optional()
.map_err(map_error)?;
let Some((current_key, current_title, current_sort)) = current else {
return Err(StashError::NotFound(format!("卡片不存在: {id}")));
};
if current_key == "local-service" && patch.title.is_some() {
return Err(StashError::InvalidDirectory(
"本地服务卡片由系统生成,不能改名".into(),
));
}
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 sort = patch.sort.unwrap_or(current_sort);
let tx = conn.transaction().map_err(map_error)?;
tx.execute(
"UPDATE card SET title = ?1, sort = ?2 WHERE id = ?3",
params![title, sort, id],
)
.map_err(map_error)?;
entries::audit(&tx, "card.update", &format!("card-{id}"), &title)?;
tx.commit().map_err(map_error)?;
Ok(())
}
pub fn remove(conn: &Connection, id: i64) -> Result<(), StashError> {
let key: Option<String> = conn
.query_row("SELECT key FROM card WHERE id = ?1", [id], |row| row.get(0))
.optional()
.map_err(map_error)?;
if key.as_deref() == Some("local-service") {
return Err(StashError::InvalidDirectory(
"本地服务卡片由系统生成,不能删除".into(),
));
}
let removed = conn
.execute("DELETE FROM card WHERE id = ?1", [id])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("卡片不存在: {id}")));
}
entries::audit(conn, "card.delete", &format!("card-{id}"), "")?;
Ok(())
}
/// 整组替换卡片勾选的分组(顺序即数组顺序)。
pub fn set_groups(
conn: &mut Connection,
id: i64,
groups: Vec<CardGroupInput>,
) -> Result<(), StashError> {
let key: Option<String> = conn
.query_row("SELECT key FROM card WHERE id = ?1", [id], |row| row.get(0))
.optional()
.map_err(map_error)?;
if key.as_deref() == Some("local-service") {
return Err(StashError::InvalidDirectory(
"本地服务卡片由系统生成,不能配置分组".into(),
));
}
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM card WHERE id = ?1", [id], |row| row.get(0))
.map_err(map_error)?;
if exists == 0 {
return Err(StashError::NotFound(format!("卡片不存在: {id}")));
}
for group in &groups {
let node_exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [&group.group_key], |row| {
row.get(0)
})
.map_err(map_error)?;
if node_exists == 0 {
return Err(StashError::NotFound(format!(
"分组不存在: {}",
group.group_key
)));
}
if let Some(limit) = group.preview_limit {
if !(1..=60).contains(&limit) {
return Err(StashError::InvalidDirectory("预览条数需在 1–60 之间".into()));
}
}
}
let tx = conn.transaction().map_err(map_error)?;
tx.execute("DELETE FROM card_group WHERE card_id = ?1", [id])
.map_err(map_error)?;
for (sort, group) in groups.iter().enumerate() {
tx.execute(
"INSERT INTO card_group (card_id, group_key, sort, preview_limit)
VALUES (?1, ?2, ?3, ?4)",
params![id, group.group_key, sort as i64, group.preview_limit],
)
.map_err(map_error)?;
}
entries::audit(&tx, "card.groups", &format!("card-{id}"), "")?;
tx.commit().map_err(map_error)?;
Ok(())
}
+119 -244
View File
@@ -11,26 +11,14 @@ 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,
}
/// 条目行:与 `entry` 表的列一一对应
struct EntryRow {
key: String,
node_key: String,
format: String,
name: String,
description: Option<String>,
icon: Option<String>,
@@ -46,221 +34,139 @@ 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,
icon: Option<String>,
bar_color: Option<String>,
preview_limit: Option<i64>,
sort: i64,
enabled: bool,
}
/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。
fn load_sections(conn: &Connection) -> Result<Vec<Value>, StashError> {
/// 新的原子视图:完整分组树(多层、带条目),供新首页/侧栏/后台使用。
pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
let mut stmt = conn
.prepare(
"SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar
FROM node
WHERE kind = 'section'
ORDER BY sort, key",
"SELECT key, parent_key, title, icon, bar_color, preview_limit, sort, enabled
FROM node ORDER BY sort, key",
)
.map_err(map_error)?;
let sections = stmt
let nodes = stmt
.query_map([], |row| {
Ok(NodeRow {
Ok(TreeNode {
key: row.get(0)?,
title: row.get(1)?,
view_mode: row.get(2)?,
bar_color: row.get(3)?,
icon: row.get(4)?,
parent: row.get(1)?,
title: row.get(2)?,
icon: row.get(3)?,
bar_color: row.get(4)?,
preview_limit: row.get(5)?,
empty_text: row.get(6)?,
show_in_sidebar: row.get(7)?,
sort: row.get(6)?,
enabled: row.get(7)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
// 先释放语句再发后续查询,避免同一连接上的借用冲突
drop(stmt);
// 每个分区对应的首页卡片:展示哪个节点、几条
let blocks = load_blocks(conn)?;
// 子节点索引(按 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);
}
sections
.iter()
.map(|section| build_section(conn, section, blocks.get(&section.key)))
.collect()
let tags = load_all_tags(conn)?;
fn render(
conn: &Connection,
nodes: &[TreeNode],
children: &HashMap<Option<String>, Vec<usize>>,
tags: &HashMap<String, Vec<String>>,
idx: usize,
) -> Result<Value, StashError> {
let node = &nodes[idx];
let entries = load_node_entries(conn, &node.key)?;
let items = entries
.iter()
.map(|entry| item_for(entry, tags))
.collect::<Vec<_>>();
let child_values = children
.get(&Some(node.key.clone()))
.map(|list| {
list.iter()
.map(|child| render(conn, nodes, children, tags, *child))
.collect::<Result<Vec<_>, _>>()
})
.transpose()?
.unwrap_or_default();
Ok(json!({
"key": node.key,
"parentKey": node.parent,
"title": node.title,
"icon": node.icon.clone().unwrap_or_default(),
"barColor": node.bar_color.clone().unwrap_or_default(),
"previewLimit": node.preview_limit,
"sort": node.sort,
"enabled": node.enabled,
"entries": items,
"children": child_values,
}))
}
let roots = children
.get(&None)
.map(|list| {
list.iter()
.map(|idx| render(conn, &nodes, &children, &tags, *idx))
.collect::<Result<Vec<_>, _>>()
})
.transpose()?
.unwrap_or_default();
Ok(Value::Array(roots))
}
/// 读出所有卡片配置,按推导出的分区业务键索引。
///
/// 分区不单独存:节点是分组就取它的 `parent_key`,节点是分区就取它自己。
/// `title` 同样在 SQL 里解析:卡片自己配了标题就用它,否则用被展示节点的标题。
fn load_blocks(conn: &Connection) -> Result<HashMap<String, Value>, StashError> {
/// 全部条目标签(一次查询,树组装时用)
fn load_all_tags(conn: &Connection) -> Result<HashMap<String, Vec<String>>, 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",
)
.prepare("SELECT entry_key, label FROM entry_tag ORDER BY entry_key, sort")
.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)?,
))
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.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));
let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
for (entry_key, label) in rows {
grouped.entry(entry_key).or_default().push(label);
}
Ok(blocks)
Ok(grouped)
}
/// 按 `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));
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
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" => object.insert("groups".into(), build_groups(conn, section)?),
"posters" => object.insert("items".into(), build_poster_items(conn, section)?),
"articles" => object.insert("items".into(), build_article_items(conn, section)?),
other => {
return Err(StashError::Database(format!(
"分区 {} 的 view_mode 非法: {other}",
section.key
)));
}
/// 按条目自身的格式组装列表项。
fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value {
let mut value = match entry.format.as_str() {
"posters" => poster_item(entry, tags.get(&entry.key)),
"articles" => article_item(entry, tags.get(&entry.key)),
_ => app_item(entry),
};
Ok(Value::Object(object))
}
/// 应用类:取出分组节点,再把条目按 `node_key` 分派进去。
fn build_groups(conn: &Connection, section: &NodeRow) -> 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 entries = load_entries(conn, &section.key)?;
let mut rendered_groups = Vec::with_capacity(groups.len());
for (group_key, group_title, show_in_sidebar) in groups {
let items = entries
.iter()
// 保持 entry.sort 决定的顺序
.filter(|entry| entry.node_key == group_key)
.map(app_item)
.collect::<Vec<_>>();
rendered_groups.push(json!({
// 对外用业务键,不用数字主键
"id": group_key,
"name": group_title,
"showInSidebar": show_in_sidebar,
"items": items,
}));
if let Some(object) = value.as_object_mut() {
object.insert("format".into(), json!(entry.format));
}
Ok(Value::Array(rendered_groups))
value
}
/// 海报类:竖版卡片要 `title` / `cover` / `tags`。
fn build_poster_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
let entries = load_entries(conn, &section.key)?;
let tags = load_tags(conn, &section.key)?;
let items = entries
.iter()
.map(|entry| poster_item(entry, tags.get(&entry.key)))
.collect::<Vec<_>>();
Ok(Value::Array(items))
}
/// 图文类:需要 `title` / `desc` / `cover` / `meta` / `body`。
fn build_article_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
let entries = load_entries(conn, &section.key)?;
let tags = load_tags(conn, &section.key)?;
let items = entries
.iter()
.map(|entry| article_item(entry, tags.get(&entry.key)))
.collect::<Vec<_>>();
Ok(Value::Array(items))
}
/// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。
fn app_item(entry: &EntryRow) -> Value {
let mut object = Map::new();
object.insert("id".into(), json!(entry.key));
@@ -330,6 +236,7 @@ fn article_item(entry: &EntryRow, meta: Option<&Vec<String>>) -> Value {
if let Some(body) = entry.body.as_ref().filter(|body| !body.trim().is_empty()) {
object.insert("body".into(), json!(body));
}
insert_optional(&mut object, "href", &entry.href);
Value::Object(object)
}
@@ -341,40 +248,37 @@ fn insert_optional(object: &mut Map<String, Value>, key: &str, value: &Option<St
}
}
/// 取某个分区下的全部条目,按 `sort` 升序。
///
/// 条目只存 `node_key`,所属分区由父节点推导:
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
fn load_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.node_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
"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
WHERE e.node_key = ?1
ORDER BY e.sort, e.key",
)
.map_err(map_error)?;
let entries = stmt
.query_map([section_key], |row| {
.query_map([node_key], |row| {
Ok(EntryRow {
key: row.get(0)?,
node_key: row.get(1)?,
name: row.get(2)?,
description: row.get(3)?,
icon: row.get(4)?,
href: row.get(5)?,
cover: row.get(6)?,
banner: row.get(7)?,
badge: row.get(8)?,
comment: row.get(9)?,
summary: row.get(10)?,
body: row.get(11)?,
hot: row.get(12)?,
views: row.get(13)?,
likes: row.get(14)?,
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)?
@@ -383,32 +287,3 @@ fn load_entries(conn: &Connection, section_key: &str) -> Result<Vec<EntryRow>, S
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)
}
+686
View File
@@ -0,0 +1,686 @@
//! 条目的读取与编辑(P2)。
//!
//! 编辑用 `updated_at` 做乐观锁:带上打开表单时的时间戳,
//! 别人已经改过就返回冲突,而不是静默覆盖。
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use crate::{db::map_error, error::StashError};
const MAX_NAME: usize = 120;
const MAX_BODY: usize = 100_000;
const MAX_TAGS: usize = 30;
const MAX_TAG_LEN: usize = 40;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct EntryRecord {
pub key: String,
pub format: String,
pub name: String,
pub description: String,
pub icon: String,
pub href: String,
pub cover: String,
pub banner: String,
pub badge: String,
pub comment: String,
pub summary: String,
pub body: String,
pub tags: Vec<String>,
pub updated_at: i64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EntryPatch {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub icon: String,
#[serde(default)]
pub href: String,
#[serde(default)]
pub cover: String,
#[serde(default)]
pub banner: String,
#[serde(default)]
pub badge: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub tags: Vec<String>,
/// 可选:把条目移动到另一个分区 / 分组(不传则保持原位)
#[serde(default)]
pub node_key: Option<String>,
/// 条目自身的展示格式:apps / posters / articles;不传默认 apps
#[serde(default)]
pub format: Option<String>,
}
/// 新建条目的请求体:`key` 是业务键,`nodeKey` 决定挂在哪个分区 / 分组。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewEntry {
pub key: String,
pub node_key: String,
pub item: EntryPatch,
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0)
}
/// 规范化跳转地址:补协议、拒绝危险协议、空值统一成空串。
///
/// 用户经常直接填 `www.zhihu.com`。原样存进 `href` 会被浏览器当成
/// 相对地址(点「跳转到」会跳到 `/c/.../www.zhihu.com`),所以这里统一补
/// `https://`。`href` 会原样渲染进 `<a>`,`javascript:` / `data:` 这类
/// 协议必须直接拒绝,不能只靠前端过滤。
fn normalize_href(raw: &str) -> Result<String, StashError> {
let value = raw.trim();
if value.is_empty() || value == "#" {
return Ok(String::new());
}
if value.chars().any(char::is_whitespace) {
return Err(StashError::InvalidDirectory("跳转地址不能包含空格".into()));
}
// 站内路径(单个斜杠开头);`//host` 是协议相对地址,按外链处理
if value.starts_with('/') && !value.starts_with("//") {
return Ok(value.to_string());
}
let lower = value.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
return Ok(value.to_string());
}
if value.starts_with("//") {
return Ok(format!("https:{value}"));
}
// 带「其他协议」的地址一律拒绝(javascript: / data: / vbscript: / file: / mailto: …);
// 只放行上面已经处理过的 http(s) 与站内路径。
if let Some((scheme, _)) = lower.split_once(':') {
let looks_like_scheme = !scheme.is_empty()
&& scheme.chars().next().is_some_and(|ch| ch.is_ascii_alphabetic())
&& scheme
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.');
if looks_like_scheme && !looks_like_domain(value) {
return Err(StashError::InvalidDirectory(
"跳转地址只支持 http(s)、站内路径或域名".into(),
));
}
}
if looks_like_domain(value) {
return Ok(format!("https://{value}"));
}
Err(StashError::InvalidDirectory(
"跳转地址需以 http(s):// 或 / 开头".into(),
))
}
/// 裸域名判断:`www.zhihu.com`、`example.com:8080/path` 这类没有协议头的地址。
fn looks_like_domain(value: &str) -> bool {
let host_port = value.split('/').next().unwrap_or("");
let host = host_port.split(':').next().unwrap_or("");
if !host.contains('.') {
return false;
}
let mut parts = host.split('.');
let Some(tld) = parts.next_back() else {
return false;
};
tld.len() >= 2
&& tld.chars().all(|ch| ch.is_ascii_alphabetic())
&& parts.all(|part| {
!part.is_empty()
&& part
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
})
}
fn validate(patch: &EntryPatch) -> Result<(), StashError> {
if let Some(format) = patch.format.as_deref() {
if !matches!(format, "apps" | "posters" | "articles") {
return Err(StashError::InvalidDirectory("格式不合法".into()));
}
}
if patch.name.trim().is_empty() {
return Err(StashError::InvalidDirectory("标题不能为空".into()));
}
if patch.name.chars().count() > MAX_NAME {
return Err(StashError::InvalidDirectory(format!(
"标题最多 {MAX_NAME} 个字"
)));
}
if patch.body.chars().count() > MAX_BODY {
return Err(StashError::InvalidDirectory("正文过长".into()));
}
if patch.tags.len() > MAX_TAGS {
return Err(StashError::InvalidDirectory(format!(
"标签最多 {MAX_TAGS} 个"
)));
}
for tag in &patch.tags {
if tag.chars().count() > MAX_TAG_LEN {
return Err(StashError::InvalidDirectory(format!(
"单个标签最多 {MAX_TAG_LEN} 个字"
)));
}
}
Ok(())
}
/// 读一条条目(编辑表单用),不存在返回 None。
pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashError> {
let row = conn
.query_row(
"SELECT key, COALESCE(format, 'apps'), name, COALESCE(description, ''), COALESCE(icon, ''), COALESCE(href, ''),
COALESCE(cover, ''), COALESCE(banner, ''), COALESCE(badge, ''),
COALESCE(comment, ''), COALESCE(summary, ''), COALESCE(body, ''), updated_at
FROM entry WHERE key = ?1",
[key],
|row| {
Ok(EntryRecord {
key: row.get(0)?,
format: row.get(1)?,
name: row.get(2)?,
description: row.get(3)?,
icon: row.get(4)?,
href: row.get(5)?,
cover: row.get(6)?,
banner: row.get(7)?,
badge: row.get(8)?,
comment: row.get(9)?,
summary: row.get(10)?,
body: row.get(11)?,
tags: Vec::new(),
updated_at: row.get(12)?,
})
},
)
.optional()
.map_err(map_error)?;
let Some(mut entry) = row else {
return Ok(None);
};
let mut statement = conn
.prepare("SELECT label FROM entry_tag WHERE entry_key = ?1 ORDER BY sort")
.map_err(map_error)?;
entry.tags = statement
.query_map([key], |row| row.get::<_, String>(0))
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
Ok(Some(entry))
}
/// 条目能挂到哪:分区本身,或分区下的分组(任意类型都支持分组)。
fn validate_target(conn: &Connection, node_key: &str) -> Result<(), StashError> {
let row: Option<(String, Option<String>)> = conn
.query_row(
"SELECT n.kind, p.kind
FROM node n LEFT JOIN node p ON p.key = n.parent_key
WHERE n.key = ?1",
[node_key],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(map_error)?;
let Some((kind, parent_kind)) = row else {
return Err(StashError::NotFound(format!("节点不存在: {node_key}")));
};
match (kind.as_str(), parent_kind.as_deref()) {
("section", _) => Ok(()),
// 多层分组:父级可以是分区或任意分组
("group", Some(_)) => Ok(()),
("group", _) => Err(StashError::InvalidDirectory(
"分组的父节点必须是分区或分组".into(),
)),
_ => Err(StashError::InvalidDirectory(
"条目只能挂在分区或分组下".into(),
)),
}
}
fn valid_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 64
&& key
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
}
fn write_tags(conn: &Connection, key: &str, tags: &[String]) -> Result<(), StashError> {
conn.execute("DELETE FROM entry_tag WHERE entry_key = ?1", [key])
.map_err(map_error)?;
for (index, tag) in tags.iter().enumerate() {
conn.execute(
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
params![key, index as i64, tag],
)
.map_err(map_error)?;
}
Ok(())
}
/// 新建条目:用于后台「内容」页添加网址 / 海报 / 图文。
pub fn create(conn: &mut Connection, new: NewEntry) -> Result<EntryRecord, StashError> {
let key = new.key.trim().to_string();
if !valid_key(&key) {
return Err(StashError::InvalidDirectory(
"标识只能用小写字母、数字与连字符(≤64)".into(),
));
}
let patch = new.item;
validate(&patch)?;
let format = patch.format.clone().unwrap_or_else(|| "apps".into());
let href = normalize_href(&patch.href)?;
validate_target(conn, &new.node_key)?;
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM entry WHERE key = ?1", [&key], |row| {
row.get(0)
})
.map_err(map_error)?;
if exists > 0 {
return Err(StashError::Conflict(format!("条目已存在: {key}")));
}
let tags: Vec<String> = patch
.tags
.iter()
.map(|tag| tag.trim().to_string())
.filter(|tag| !tag.is_empty())
.collect();
let timestamp = now();
let tx = conn.transaction().map_err(map_error)?;
let sort: i64 = tx
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM entry WHERE node_key = ?1",
[&new.node_key],
|row| row.get(0),
)
.map_err(map_error)?;
tx.execute(
"INSERT INTO entry (key, node_key, format, name, description, icon, href, cover, banner,
badge, comment, summary, body, hot, views, likes, sort, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0, 0, 0, ?14, ?15)",
params![
key,
new.node_key,
format,
patch.name.trim(),
patch.description,
patch.icon,
href,
patch.cover,
patch.banner,
patch.badge,
patch.comment,
patch.summary,
patch.body,
sort,
timestamp
],
)
.map_err(map_error)?;
write_tags(&tx, &key, &tags)?;
tx.execute(
"INSERT INTO audit (at, action, target, detail) VALUES (?1, 'item.create', ?2, ?3)",
params![timestamp, key, patch.name.trim()],
)
.map_err(map_error)?;
tx.commit().map_err(map_error)?;
load(conn, &key)?
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
}
/// 删除条目(后台内容页用)。
pub fn remove(conn: &Connection, key: &str) -> Result<(), StashError> {
let removed = conn
.execute("DELETE FROM entry WHERE key = ?1", [key])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("条目不存在: {key}")));
}
audit(conn, "item.delete", key, "")?;
Ok(())
}
/// 更新条目;`expected_updated_at` 与库里不一致时冲突。
pub fn update( conn: &mut Connection,
key: &str,
patch: EntryPatch,
expected_updated_at: i64,
) -> Result<EntryRecord, StashError> {
validate(&patch)?;
let format = patch.format.clone().unwrap_or_else(|| "apps".into());
// 跳转地址先规范化:补 https、拒绝 javascript: 等危险协议
let href = normalize_href(&patch.href)?;
if let Some(node_key) = patch.node_key.as_deref() {
validate_target(conn, node_key)?;
}
let tags: Vec<String> = patch
.tags
.iter()
.map(|tag| tag.trim().to_string())
.filter(|tag| !tag.is_empty())
.collect();
let timestamp = now();
let tx = conn.transaction().map_err(map_error)?;
let updated = tx
.execute(
"UPDATE entry SET
name = ?1, description = ?2, icon = ?3, href = ?4,
cover = ?5, banner = ?6, badge = ?7, comment = ?8,
summary = ?9, body = ?10, updated_at = ?11,
node_key = COALESCE(?14, node_key),
format = ?15
WHERE key = ?12 AND updated_at = ?13",
params![
patch.name.trim(),
patch.description,
patch.icon,
href,
patch.cover,
patch.banner,
patch.badge,
patch.comment,
patch.summary,
patch.body,
timestamp,
key,
expected_updated_at,
patch.node_key,
format
],
)
.map_err(map_error)?;
if updated == 0 {
return Err(StashError::Conflict(
"条目已被修改,请刷新后重试".into(),
));
}
tx.execute("DELETE FROM entry_tag WHERE entry_key = ?1", [key])
.map_err(map_error)?;
for (index, tag) in tags.iter().enumerate() {
tx.execute(
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
params![key, index as i64, tag],
)
.map_err(map_error)?;
}
tx.execute(
"INSERT INTO audit (at, action, target, detail) VALUES (?1, 'item.update', ?2, ?3)",
params![timestamp, key, patch.name.trim()],
)
.map_err(map_error)?;
tx.commit().map_err(map_error)?;
load(conn, key)?
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
}
/// 记录一次浏览:views +1,返回 (views, likes)。
pub fn bump_view(conn: &Connection, key: &str) -> Result<(i64, i64), StashError> {
conn.execute("UPDATE entry SET views = views + 1 WHERE key = ?1", [key])
.map_err(map_error)?;
counts(conn, key)
}
/// 点赞 / 取消点赞:likes 加一或减一(不低于 0),返回 (views, likes)。
pub fn set_like(conn: &Connection, key: &str, liked: bool) -> Result<(i64, i64), StashError> {
let delta: i64 = if liked { 1 } else { -1 };
conn.execute(
"UPDATE entry SET likes = MAX(0, likes + ?1) WHERE key = ?2",
params![delta, key],
)
.map_err(map_error)?;
counts(conn, key)
}
fn counts(conn: &Connection, key: &str) -> Result<(i64, i64), StashError> {
conn.query_row("SELECT views, likes FROM entry WHERE key = ?1", [key], |row| {
Ok((row.get(0)?, row.get(1)?))
})
.optional()
.map_err(map_error)?
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
}
/// 记录一条审计(设置保存等)。
pub fn audit(conn: &Connection, action: &str, target: &str, detail: &str) -> Result<(), StashError> {
conn.execute(
"INSERT INTO audit (at, action, target, detail) VALUES (?1, ?2, ?3, ?4)",
params![now(), action, target, detail],
)
.map_err(map_error)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.execute_batch(
"CREATE TABLE entry (
key TEXT PRIMARY KEY,
node_key TEXT NOT NULL,
format TEXT NOT NULL DEFAULT 'apps',
name TEXT NOT NULL,
description TEXT, icon TEXT, href TEXT, cover TEXT, banner TEXT,
badge TEXT, comment TEXT, summary TEXT, body TEXT,
hot INTEGER NOT NULL DEFAULT 0, views INTEGER NOT NULL DEFAULT 0,
likes INTEGER NOT NULL DEFAULT 0, sort INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE entry_tag (
entry_key TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL,
PRIMARY KEY (entry_key, sort)
);
CREATE TABLE audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at INTEGER NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT
);
CREATE TABLE node (
key TEXT PRIMARY KEY,
parent_key TEXT,
title TEXT NOT NULL,
kind TEXT NOT NULL,
view_mode TEXT
);
INSERT INTO node (key, parent_key, title, kind, view_mode) VALUES
('movie', NULL, '推荐电影', 'section', 'posters'),
('apps1', NULL, '常用推荐', 'section', 'apps'),
('g1', 'apps1', '常用站点', 'group', NULL);
INSERT INTO entry (key, node_key, name, updated_at) VALUES ('movie-1', 'movie', '千与千寻', 0);",
)
.expect("应能建表");
conn
}
fn patch(name: &str) -> EntryPatch {
EntryPatch {
name: name.into(),
description: "简介".into(),
icon: String::new(),
href: "https://example.com".into(),
cover: String::new(),
banner: String::new(),
badge: "推荐".into(),
comment: "一句话".into(),
summary: "正文".into(),
body: "## 标题".into(),
tags: vec!["推荐电影".into(), "动画".into()],
node_key: None,
format: None,
}
}
#[test]
fn update_replaces_fields_and_tags() {
let mut conn = db();
let saved = update(&mut conn, "movie-1", patch("千与千寻 2"), 0).expect("应能更新");
assert_eq!(saved.name, "千与千寻 2");
assert_eq!(saved.tags, vec!["推荐电影".to_string(), "动画".to_string()]);
assert!(saved.updated_at > 0);
let audit: i64 = conn
.query_row("SELECT COUNT(*) FROM audit", [], |row| row.get(0))
.expect("应能查审计");
assert_eq!(audit, 1);
}
#[test]
fn stale_timestamp_conflicts() {
let mut conn = db();
let saved = update(&mut conn, "movie-1", patch("A"), 0).expect("应能更新");
let error = update(&mut conn, "movie-1", patch("B"), saved.updated_at - 1)
.expect_err("应冲突");
assert!(matches!(error, StashError::Conflict(_)));
}
#[test]
fn rejects_empty_name() {
let mut conn = db();
assert!(update(&mut conn, "movie-1", patch(" "), 0).is_err());
}
#[test]
fn missing_entry_is_not_found() {
let mut conn = db();
let error = update(&mut conn, "none", patch("X"), 0).expect_err("应不存在");
assert!(matches!(error, StashError::Conflict(_) | StashError::NotFound(_)));
}
#[test]
fn normalizes_links_so_jump_works() {
// 裸域名补 https,避免被浏览器当成相对地址
assert_eq!(normalize_href("www.zhihu.com").unwrap(), "https://www.zhihu.com");
assert_eq!(
normalize_href("example.com:8080/path").unwrap(),
"https://example.com:8080/path"
);
// 已带协议的保持原样;站内路径保持站内
assert_eq!(normalize_href("https://example.com/a").unwrap(), "https://example.com/a");
assert_eq!(normalize_href("/git_helper/").unwrap(), "/git_helper/");
// 空值和占位符统一成空串
assert_eq!(normalize_href(" ").unwrap(), "");
assert_eq!(normalize_href("#").unwrap(), "");
}
#[test]
fn rejects_dangerous_or_unknown_links() {
for bad in ["javascript:alert(1)", "data:text/html,hi", "mailto:[email protected]", "not a url"] {
assert!(normalize_href(bad).is_err(), "{bad} 应被拒绝");
}
}
#[test]
fn update_stores_normalized_href() {
let mut conn = db();
let mut input = patch("知乎");
input.href = "www.zhihu.com".into();
let saved = update(&mut conn, "movie-1", input, 0).expect("应能更新");
assert_eq!(saved.href, "https://www.zhihu.com");
}
#[test]
fn creates_entry_under_section_or_group() {
let mut conn = db();
let saved = create(
&mut conn,
NewEntry {
key: "new-site".into(),
node_key: "g1".into(),
item: patch("新站点"),
},
)
.expect("应能创建");
assert_eq!(saved.key, "new-site");
assert_eq!(saved.name, "新站点");
let node: String = conn
.query_row("SELECT node_key FROM entry WHERE key='new-site'", [], |row| {
row.get(0)
})
.expect("应能查");
assert_eq!(node, "g1");
// 任何类型的分区都允许条目直属分区
let direct = create(
&mut conn,
NewEntry {
key: "direct-site".into(),
node_key: "apps1".into(),
item: patch("直属条目"),
},
)
.expect("应能创建");
assert_eq!(direct.key, "direct-site");
}
#[test]
fn moves_entry_between_nodes() {
let mut conn = db();
let mut input = patch("搬家");
input.node_key = Some("g1".into());
update(&mut conn, "movie-1", input, 0).expect("应能移动");
let node: String = conn
.query_row("SELECT node_key FROM entry WHERE key='movie-1'", [], |row| {
row.get(0)
})
.expect("应能查");
assert_eq!(node, "g1");
}
#[test]
fn deletes_entry() {
let conn = db();
remove(&conn, "movie-1").expect("应能删除");
assert!(remove(&conn, "movie-1").is_err(), "重复删除应失败");
}
}
+255
View File
@@ -0,0 +1,255 @@
//! 分组树的管理:多层分组、父级移动与排序。
//!
//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`),
//! 下面的节点都叫分组(`kind='group'`),可以继续嵌套。
//! 条目的展示格式由 `entry.format` 决定,分组不再保存格式。
use rusqlite::{Connection, OptionalExtension, params};
use serde::Deserialize;
use crate::{db::entries, db::map_error, error::StashError};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewNode {
/// 业务键,只允许小写字母、数字与连字符
pub key: String,
pub title: String,
/// 为空表示新建一个根分组
#[serde(default)]
pub parent_key: Option<String>,
/// 图标:可以是 iconfont 名或字符;空字符串表示不显示
#[serde(default)]
pub icon: Option<String>,
/// 排序权重;不传则排到同级末尾
#[serde(default)]
pub sort: Option<i64>,
/// 是否启用,默认 true
#[serde(default)]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateNode {
pub title: Option<String>,
/// 传入即移动到新的父节点;不支持移回根(根分组请直接新建)
pub parent_key: Option<String>,
pub sort: Option<i64>,
/// 图标:空字符串表示清除图标
pub icon: Option<String>,
pub enabled: Option<bool>,
}
fn valid_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 64
&& key
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
}
pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
let key = new.key.trim();
let title = new.title.trim();
if !valid_key(key) {
return Err(StashError::InvalidDirectory(
"分组标识只能用小写字母、数字与连字符(≤64)".into(),
));
}
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("分组名称需为 1–40 个字".into()));
}
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [key], |row| {
row.get(0)
})
.map_err(map_error)?;
if exists > 0 {
return Err(StashError::Conflict(format!("分组已存在: {key}")));
}
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| {
row.get(0)
})
.map_err(map_error)?;
if parent_exists == 0 {
return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
}
"group"
}
None => "section",
};
let default_sort: i64 = match new.parent_key.as_deref() {
Some(parent) => conn
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1",
[parent],
|row| row.get(0),
)
.map_err(map_error)?,
None => conn
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key IS NULL",
[],
|row| row.get(0),
)
.map_err(map_error)?,
};
let sort = new.sort.unwrap_or(default_sort);
let icon = new
.icon
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let enabled = new.enabled.unwrap_or(true);
let tx = conn.transaction().map_err(map_error)?;
tx.execute(
"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,
icon,
enabled,
sort
],
)
.map_err(map_error)?;
entries::audit(&tx, "node.create", key, title)?;
tx.commit().map_err(map_error)?;
Ok(())
}
pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> {
let current: Option<(String, Option<String>, i64, Option<String>, bool)> = conn
.query_row(
"SELECT title, parent_key, sort, icon, enabled FROM node WHERE key = ?1",
[key],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
},
)
.optional()
.map_err(map_error)?;
let Some((current_title, current_parent, current_sort, current_icon, current_enabled)) = current
else {
return Err(StashError::NotFound(format!("分组不存在: {key}")));
};
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 parent_key = match patch.parent_key.clone() {
Some(parent) => {
if parent == key {
return Err(StashError::InvalidDirectory("不能把分组移到自己下面".into()));
}
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [&parent], |row| {
row.get(0)
})
.map_err(map_error)?;
if exists == 0 {
return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
}
// 防止把节点移进自己的子孙
let mut cursor = Some(parent.clone());
while let Some(candidate) = cursor {
if candidate == key {
return Err(StashError::InvalidDirectory(
"不能把分组移到自己的子分组下面".into(),
));
}
cursor = conn
.query_row(
"SELECT parent_key FROM node WHERE key = ?1",
[candidate],
|row| row.get::<_, Option<String>>(0),
)
.optional()
.map_err(map_error)?
.flatten();
}
Some(parent)
}
// 不传表示保持原父级(不支持移回根)
None => current_parent.clone(),
};
let icon = match patch.icon {
Some(icon) => {
let icon = icon.trim().to_string();
(!icon.is_empty()).then_some(icon)
}
None => current_icon,
};
let enabled = patch.enabled.unwrap_or(current_enabled);
let sort = patch.sort.unwrap_or(current_sort);
let tx = conn.transaction().map_err(map_error)?;
// 只有真正换了父级才排到末尾;同一父级下编辑排序权重时按传入值保存
let parent_changed = patch
.parent_key
.as_deref()
.is_some_and(|parent| Some(parent) != current_parent.as_deref());
let final_sort = if parent_changed {
let parent = patch.parent_key.as_deref().unwrap_or_default();
tx.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1 AND key <> ?2",
params![parent, key],
|row| row.get::<_, i64>(0),
)
.map_err(map_error)?
} else {
sort
};
tx.execute(
"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)?;
tx.commit().map_err(map_error)?;
Ok(())
}
pub fn remove(conn: &Connection, key: &str) -> Result<(), StashError> {
let removed = conn
.execute("DELETE FROM node WHERE key = ?1", [key])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("分组不存在: {key}")));
}
entries::audit(conn, "node.delete", key, "")?;
Ok(())
}
+288 -107
View File
@@ -8,8 +8,13 @@
//! 3. **开发期不做迁移**:表结构还在变,改结构就删掉 `data/stash.db` 重启,
//! 启动时会重新建表并灌入内嵌种子。
pub mod auth;
pub mod cards;
mod catalog;
pub mod entries;
pub mod groups;
mod schema;
pub mod settings;
use std::sync::Mutex;
@@ -21,14 +26,12 @@ use crate::{config::Config, error::StashError};
pub use schema::SeedReport;
/// 一次查询得到的完整内容目录。
///
/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。
#[derive(Debug)]
pub struct Catalog {
/// 分类树,结构对应前端 `types.ts` 的 `Category[]`
pub categories: Vec<Value>,
}
/// 登录/会话的对外状态(server 层直接用)。
pub use auth::SessionState;
/// 站点设置:页头滚动信息 + 侧栏顺序/显隐。
pub use settings::{Settings, SettingsDocument};
/// 条目编辑(P2)。
pub use entries::{EntryPatch, EntryRecord};
/// 内容库句柄。
///
@@ -57,14 +60,14 @@ impl Db {
)
.map_err(map_error)?;
// 先校验结构版本,再动任何东西:旧库直接报错,避免用旧表跑新查询
let version = schema::check_version(&conn)?;
schema::apply(&conn)?;
// 写入当前结构版本,供下次启动比对
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
.map_err(map_error)?;
// 先迁移结构,再动数据:迁移失败会直接报错,不会用半套表跑查询
let version = schema::migrate(&conn)?;
if version != schema::SCHEMA_VERSION {
return Err(StashError::Database(format!(
"内容库迁移后版本为 {version},期望 {}",
schema::SCHEMA_VERSION
)));
}
// 空库才灌种子:用户删掉内置条目后重启不会被重新塞回来
let existing: i64 = conn
@@ -94,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 靠轮询它来推送刷新。
@@ -115,6 +112,146 @@ impl Db {
.lock()
.map_err(|_| StashError::Database("数据库连接锁已损坏,请重启进程".into()))
}
// ---------- 登录 / 会话 ----------
pub fn auth_state(&self, token: Option<&str>) -> Result<SessionState, StashError> {
let conn = self.lock()?;
auth::state(&conn, token)
}
pub fn setup_admin(&self, username: &str, password: &str) -> Result<(), StashError> {
let conn = self.lock()?;
auth::create_admin(&conn, username, password)
}
pub fn login(&self, password: &str) -> Result<Option<String>, StashError> {
let conn = self.lock()?;
auth::purge_expired(&conn)?;
auth::verify_login(&conn, password)
}
pub fn issue_session(&self) -> Result<String, StashError> {
let conn = self.lock()?;
auth::issue_session(&conn)
}
pub fn logout(&self, token: &str) -> Result<(), StashError> {
let conn = self.lock()?;
auth::revoke(&conn, token)
}
pub fn change_password(&self, current: &str, next: &str) -> Result<bool, StashError> {
let mut conn = self.lock()?;
auth::change_password(&mut conn, current, next)
}
// ---------- 站点设置 ----------
pub fn load_settings(&self) -> Result<SettingsDocument, StashError> {
let conn = self.lock()?;
settings::load(&conn)
}
pub fn save_settings(
&self,
next: Settings,
expected_version: i64,
) -> Result<SettingsDocument, StashError> {
let mut conn = self.lock()?;
let document = settings::save(&mut conn, next, expected_version)?;
entries::audit(&conn, "settings.update", "site", "站点设置")?;
Ok(document)
}
// ---------- 条目编辑 ----------
pub fn load_entry(&self, key: &str) -> Result<Option<EntryRecord>, StashError> {
let conn = self.lock()?;
entries::load(&conn, key)
}
pub fn create_entry(&self, new: entries::NewEntry) -> Result<EntryRecord, StashError> {
let mut conn = self.lock()?;
entries::create(&mut conn, new)
}
pub fn remove_entry(&self, key: &str) -> Result<(), StashError> {
let conn = self.lock()?;
entries::remove(&conn, key)
}
pub fn update_entry(
&self,
key: &str,
patch: EntryPatch,
expected_updated_at: i64,
) -> Result<EntryRecord, StashError> {
let mut conn = self.lock()?;
entries::update(&mut conn, key, patch, expected_updated_at)
}
pub fn bump_view(&self, key: &str) -> Result<(i64, i64), StashError> {
let conn = self.lock()?;
entries::bump_view(&conn, key)
}
pub fn set_like(&self, key: &str, liked: bool) -> Result<(i64, i64), StashError> {
let conn = self.lock()?;
entries::set_like(&conn, key, liked)
}
// ---------- 原子模型:分组树 / 卡片 ----------
/// 完整分组树(多层,带条目)
pub fn load_tree(&self) -> Result<Value, StashError> {
let conn = self.lock()?;
catalog::load_tree(&conn)
}
pub fn load_cards(&self) -> Result<Vec<Value>, StashError> {
let conn = self.lock()?;
cards::load(&conn)
}
pub fn create_node(&self, new: groups::NewNode) -> Result<(), StashError> {
let mut conn = self.lock()?;
groups::create(&mut conn, new)
}
pub fn update_node(&self, key: &str, patch: groups::UpdateNode) -> Result<(), StashError> {
let mut conn = self.lock()?;
groups::update(&mut conn, key, patch)
}
pub fn remove_node(&self, key: &str) -> Result<(), StashError> {
let conn = self.lock()?;
groups::remove(&conn, key)
}
pub fn create_card(&self, new: cards::NewCard) -> Result<i64, StashError> {
let mut conn = self.lock()?;
cards::create(&mut conn, new)
}
pub fn update_card(&self, id: i64, patch: cards::UpdateCard) -> Result<(), StashError> {
let mut conn = self.lock()?;
cards::update(&mut conn, id, patch)
}
pub fn remove_card(&self, id: i64) -> Result<(), StashError> {
let conn = self.lock()?;
cards::remove(&conn, id)
}
pub fn set_card_groups(
&self,
id: i64,
groups: Vec<cards::CardGroupInput>,
) -> Result<(), StashError> {
let mut conn = self.lock()?;
cards::set_groups(&mut conn, id, groups)
}
}
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
@@ -170,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("应能统计标签");
@@ -190,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
@@ -226,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 的勾选应被级联删除");
}
/// 删除分组节点时,组内条目也要一起删掉。
@@ -341,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"),
@@ -383,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 里:直接按列查询也应该拿得到。
@@ -446,37 +593,71 @@ mod tests {
assert!(db.data_version().expect("应能读取 data_version") >= 0);
}
/// 全新库(user_version = 0)允许直接建表;当前版本也允许。
/// 全新库(user_version = 0)建全量结构;当前版本直接返回;
/// 15 会迁移到 16;比程序新的版本拒绝。
#[test]
fn accepts_fresh_or_current_schema_version() {
fn migrates_fresh_previous_and_current_schema() {
let conn = Connection::open_in_memory().expect("内存库应能打开");
assert_eq!(
schema::check_version(&conn).expect("全新库应放行"),
0,
"新库的 user_version 应为 0"
schema::migrate(&conn).expect("全新库应能建表"),
schema::SCHEMA_VERSION,
"新库应直接建到当前版本"
);
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
.expect("应能写入版本");
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,
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;",
)
.expect("应能造出 v15 老库");
assert_eq!(
schema::check_version(&conn).expect("当前版本应放行"),
schema::migrate(&fresh).expect("v15 应能迁移"),
schema::SCHEMA_VERSION
);
let migrated: i64 = fresh
.pragma_query_value(None, "user_version", |row| row.get(0))
.expect("应能读出版本");
assert_eq!(migrated, schema::SCHEMA_VERSION);
let table: i64 = fresh
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('admin','session','setting')",
[],
|row| row.get(0),
)
.expect("应能查表");
assert_eq!(table, 3, "迁移后必须有登录/设置三张表");
}
/// 结构变过之后,旧库必须被拒绝并提示删库,而不是用旧表跑新查询。
/// 比程序更新的库必须被拒绝,不能按旧结构乱读。
#[test]
fn rejects_database_from_older_schema() {
fn rejects_database_from_newer_schema() {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION - 1)
.expect("应能写入旧版本号");
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION + 1)
.expect("应能写入新版本号");
let error = schema::check_version(&conn).expect_err("旧库必须被拒绝");
let error = schema::migrate(&conn).expect_err("更新的库必须被拒绝");
let message = error.to_string();
assert!(message.contains("结构版本"), "报错要说明原因: {message}");
assert!(message.contains("删除数据库"), "报错要给出处理方式: {message}");
assert!(message.contains("升级程序"), "报错要给出处理方式: {message}");
}
/// 回归测试:外部连接提交后,服务端连接读到的 data_version 必须变化。
+325 -73
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,18 +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 表示只当首页卡片/内容,不进导航
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)
)
);
@@ -62,6 +58,7 @@ CREATE TABLE IF NOT EXISTS entry (
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 直接所属容器:应用是分组,海报/图文是分区本身
-- 所属分区不单独存:由 node_key 推导(分组看它的 parent_key)
format TEXT NOT NULL DEFAULT 'apps', -- 条目自身的展示格式:apps / posters / articles
name TEXT NOT NULL, -- 名称:网站名 / 电影名 / 文章标题
description TEXT, -- 一句话简介,列表卡片的副标题
icon TEXT, -- 字符图标(封面缺失时的占位)
@@ -75,7 +72,8 @@ CREATE TABLE IF NOT EXISTS entry (
hot INTEGER NOT NULL DEFAULT 0 CHECK (hot IN (0, 1)),-- 是否热门:列表里显示 HOT 徽标
views INTEGER NOT NULL DEFAULT 0, -- 浏览量,详情页操作条「眼睛」后的数字
likes INTEGER NOT NULL DEFAULT 0, -- 点赞数,详情页操作条「赞」后的数字
sort INTEGER NOT NULL DEFAULT 0 -- 条目顺序(组内或分区内),越小越靠前
sort INTEGER NOT NULL DEFAULT 0, -- 条目顺序(组内或分区内),越小越靠前
updated_at INTEGER NOT NULL DEFAULT 0 -- 最近一次编辑时间(unix 秒),0 = 从未编辑
);
CREATE INDEX IF NOT EXISTS entry_parent_idx ON entry(node_key, sort);
@@ -89,16 +87,218 @@ 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:登录与设置 ==========
-- 管理员:单用户场景只允许一行
CREATE TABLE IF NOT EXISTS admin (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL,
password_hash TEXT NOT NULL, -- argon2id PHC 字符串
updated_at INTEGER NOT NULL -- unix 秒
);
-- 会话:只存 token 的 sha256,原始 token 只在 Cookie 里
CREATE TABLE IF NOT EXISTS session (
token_hash TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS session_expiry_idx ON session(expires_at);
-- 全局设置:一个 key 一段 JSON,version 用于乐观锁
CREATE TABLE IF NOT EXISTS setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);
-- ========== v17:审计 ==========
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at INTEGER NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT
);
-- ========== v19:首页卡片(展示层组合) ==========
-- 一张卡片 = 标题 + 顺序;内容由 card_group 勾选分组组成,卡片不拥有数据
CREATE TABLE IF NOT EXISTS card (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0
);
-- 卡片包含哪些分组(可混合格式),每个分组可单独设预览条数
CREATE TABLE IF NOT EXISTS card_group (
card_id INTEGER NOT NULL REFERENCES card(id) ON DELETE CASCADE,
group_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
sort INTEGER NOT NULL DEFAULT 0,
preview_limit INTEGER,
PRIMARY KEY (card_id, group_key)
);
"#;
/// 把老的「分区 → 一个展示块」迁移成「卡片 → 多个分组」。
const BACKFILL_CARDS_SQL: &str = r#"
INSERT OR IGNORE INTO card (key, title, sort)
SELECT 'card-' || COALESCE(p.key, n.key),
COALESCE(b.title, COALESCE(p.title, n.title)),
MIN(b.sort)
FROM section_block b
JOIN node n ON n.key = b.node_key
LEFT JOIN node p ON p.key = n.parent_key
GROUP BY COALESCE(p.key, n.key);
INSERT OR IGNORE INTO card_group (card_id, group_key, sort, preview_limit)
SELECT c.id, b.node_key, b.sort, 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
JOIN card c ON c.key = 'card-' || COALESCE(p.key, n.key);
-- 本地服务是运行时卡片,但用一行 card 记录它的位置,参与首页排序
INSERT OR IGNORE INTO card (key, title, sort) VALUES ('local-service', '本地服务', -1);
"#;
/// v16 → v17:条目加 `updated_at`(乐观锁),新增审计表。
const V17_SQL: &str = r#"
ALTER TABLE entry ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at INTEGER NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT
);
"#;
/// v17 → v18:分区增加「允许子分组」开关(与类型无关)。
///
/// 老库默认保持原行为:应用类开启,其余类型关闭;已经有分组的非应用分区也开启。
const V18_SQL: &str = r#"
ALTER TABLE node ADD COLUMN allow_groups INTEGER NOT NULL DEFAULT 1;
UPDATE node SET allow_groups = 0
WHERE kind = 'section' AND view_mode <> 'apps';
UPDATE node SET allow_groups = 1
WHERE kind = 'section' AND key IN (
SELECT DISTINCT parent_key FROM node
WHERE kind = 'group' AND parent_key IS NOT NULL
);
"#;
/// v19 → v20:分组增加启停状态。
const V20_SQL: &str = r#"
ALTER TABLE node ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
"#;
/// v21 → v22:本地服务卡片入库,用 card.sort 参与首页排序(内容仍来自运行时项目)。
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';
WITH RECURSIVE roots(key, root_key, view_mode) AS (
SELECT key, key, view_mode FROM node WHERE parent_key IS NULL
UNION ALL
SELECT n.key, r.root_key, r.view_mode
FROM node n JOIN roots r ON n.parent_key = r.key
)
UPDATE entry
SET format = COALESCE(
(SELECT view_mode FROM roots WHERE roots.key = entry.node_key),
'apps'
);
"#;
/// v18 → v19:首页卡片改为「卡片 + 勾选分组」。
const V19_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS card (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS card_group (
card_id INTEGER NOT NULL REFERENCES card(id) ON DELETE CASCADE,
group_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
sort INTEGER NOT NULL DEFAULT 0,
preview_limit INTEGER,
PRIMARY KEY (card_id, group_key)
);
"#;
/// v15 → v16 的增量(老库升级用;新库直接走 SCHEMA_SQL)。
const V16_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS admin (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS session (
token_hash TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS session_expiry_idx ON session(expires_at);
CREATE TABLE IF NOT EXISTS setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);
"#;
@@ -115,28 +315,91 @@ pub struct SeedReport {
/// 当前表结构版本,写在 SQLite 的 `PRAGMA user_version` 里。
///
/// 开发阶段不做迁移:版本对不上时直接拒绝启动,让用户删库重建,
/// 比悄悄用旧表跑新查询(最终表现为 500)好排查得多。
/// 结构调整时把这个数字 +1。
pub const SCHEMA_VERSION: i64 = 15;
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
pub const SCHEMA_VERSION: i64 = 23;
/// 校验库结构版本:0 表示全新库,允许;其它非当前版本一律拒绝。
pub fn check_version(conn: &Connection) -> Result<i64, StashError> {
let version: i64 = conn
.pragma_query_value(None, "user_version", |row| row.get(0))
.map_err(map_error)?;
fn current_version(conn: &Connection) -> Result<i64, StashError> {
conn.pragma_query_value(None, "user_version", |row| row.get(0))
.map_err(map_error)
}
if version != 0 && version != SCHEMA_VERSION {
/// 把库升到 `SCHEMA_VERSION`,返回升级后的版本。
///
/// 逐级迁移:0 建全量;15 → 16 → 17;已最新直接返回;更高版本拒绝。
pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
let mut version = current_version(conn)?;
if version == 0 {
conn.execute_batch(SCHEMA_SQL).map_err(map_error)?;
conn.pragma_update(None, "user_version", SCHEMA_VERSION)
.map_err(map_error)?;
return Ok(SCHEMA_VERSION);
}
if version > SCHEMA_VERSION {
return Err(StashError::Database(format!(
"内容库结构版本为 {version},当前程序需要 {SCHEMA_VERSION}。\
开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。"
"内容库结构版本为 {version},比当前程序({SCHEMA_VERSION})更新;\
请升级程序,或用旧版本打开。"
)));
}
if version < 15 {
return Err(StashError::Database(format!(
"内容库结构版本为 {version},缺少可用的迁移路径(当前程序支持 15 → {SCHEMA_VERSION})。\
请备份后删除数据库重建。"
)));
}
if version == 15 {
conn.execute_batch(V16_SQL).map_err(map_error)?;
version = 16;
}
if version == 16 {
conn.execute_batch(V17_SQL).map_err(map_error)?;
version = 17;
}
if version == 17 {
conn.execute_batch(V18_SQL).map_err(map_error)?;
version = 18;
}
if version == 18 {
conn.execute_batch(V19_SQL).map_err(map_error)?;
conn.execute_batch(BACKFILL_CARDS_SQL).map_err(map_error)?;
version = 19;
}
if version == 19 {
conn.execute_batch(V20_SQL).map_err(map_error)?;
version = 20;
}
if version == 20 {
conn.execute_batch(V21_SQL).map_err(map_error)?;
version = 21;
}
if version == 21 {
conn.execute_batch(V22_SQL).map_err(map_error)?;
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)?;
Ok(version)
}
/// 建表(幂等):每次启动都跑一遍,缺表就补,已有数据不动。
/// 建表(幂等):只在测试里直接用;启动流程走 `migrate`。
#[cfg(test)]
pub fn apply(conn: &Connection) -> Result<(), StashError> {
conn.execute_batch(SCHEMA_SQL).map_err(map_error)
}
@@ -173,17 +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, sort)
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
"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),
section.sort,
],
)
@@ -193,41 +453,44 @@ 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;
for (index, entry) in group.entries.iter().enumerate() {
insert_entry(&tx, &group.key, index, entry, &mut report)?;
insert_entry(&tx, &group.key, &section.view, index, entry, &mut report)?;
}
}
} else {
// 海报与图文没有分组,直接挂在分区上(node_key 就是分区自己)
for (index, entry) in section.entries.iter().enumerate() {
insert_entry(&tx, &section.key, index, entry, &mut report)?;
insert_entry(&tx, &section.key, &section.view, index, entry, &mut report)?;
}
}
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身)
// 首页卡片:仓库里直接生成卡片与勾选的分组
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}",
@@ -248,18 +511,20 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
fn insert_entry(
tx: &rusqlite::Transaction<'_>,
node_key: &str,
format: &str,
index: usize,
entry: &SeedEntry,
report: &mut SeedReport,
) -> Result<(), StashError> {
tx.execute(
"INSERT INTO entry (
key, node_key, name, description, icon, href, cover,
key, node_key, format, name, description, icon, href, cover,
banner, badge, comment, summary, body, hot, views, likes, sort
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
params![
entry.key,
node_key,
format,
entry.name,
entry.description,
entry.icon,
@@ -316,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>,
@@ -352,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);
+763
View File
@@ -0,0 +1,763 @@
//! 站点级设置(页头滚动信息、侧栏顺序/显隐)。
//!
//! 存 `setting` 表:`key = 'site'`,`value` 是一段 JSON,`version` 做乐观锁。
//! 读的时候任何字段缺失都回落默认值,坏 JSON 也回落默认值——
//! 设置坏掉不应该让首页白屏。
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use crate::{db::map_error, error::StashError};
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")]
pub enum Tone {
#[default]
Plain,
Primary,
Success,
Neutral,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct HeaderItem {
/// 文本,支持占位符 `{count}` / `{project}` / `{status}`
pub text: String,
/// 前置小标签,如「本地」「状态」
pub tag: Option<String>,
pub tone: Tone,
}
impl Default for HeaderItem {
fn default() -> Self {
Self {
text: String::new(),
tag: None,
tone: Tone::Plain,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct HeaderSettings {
pub enabled: bool,
/// 滚动一轮的秒数
pub speed: u32,
/// 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽
pub gap: u32,
pub items: Vec<HeaderItem>,
}
impl Default for HeaderSettings {
fn default() -> Self {
Self {
enabled: true,
speed: 24,
gap: 26,
items: vec![
HeaderItem {
text: "共 {count} 个本地应用".into(),
tag: None,
tone: Tone::Plain,
},
HeaderItem {
text: "{project}".into(),
tag: Some("本地".into()),
tone: Tone::Primary,
},
HeaderItem {
text: "{status}".into(),
tag: Some("状态".into()),
tone: Tone::Success,
},
],
}
}
}
fn default_true() -> bool {
true
}
impl Default for SidebarSettings {
fn default() -> Self {
Self {
show_profile: true,
show_service: true,
show_nav: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SidebarSettings {
/// 是否显示「本地管理员」资料卡
#[serde(default = "default_true")]
pub show_profile: bool,
/// 是否显示「本地服务」面板
#[serde(default = "default_true")]
pub show_service: bool,
/// 是否显示「分组导航」卡片(首页 / 分组树)
#[serde(default = "default_true")]
pub show_nav: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
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)]
#[serde(default)]
pub struct ProfileSettings {
pub name: String,
pub role: String,
pub description: String,
pub location: String,
/// 头像地址;空字符串表示用首字占位
pub avatar: String,
}
impl Default for ProfileSettings {
fn default() -> Self {
Self {
name: "本地管理员".into(),
role: "管理员".into(),
description: "本地静态站点托管与预览,所有项目都运行在这台机器上。".into(),
location: "IP属地:局域网".into(),
avatar: String::new(),
}
}
}
#[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 background: BackgroundSettings,
pub font: FontSettings,
pub search: SearchSettings,
pub profile: ProfileSettings,
pub site: SiteMetaSettings,
}
/// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SettingsDocument {
pub version: i64,
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() {
return Err(StashError::InvalidDirectory("页头至少保留一条信息".into()));
}
if settings.header.items.len() > MAX_HEADER_ITEMS {
return Err(StashError::InvalidDirectory(format!(
"页头最多 {MAX_HEADER_ITEMS} 条信息"
)));
}
if !(MIN_SPEED..=MAX_SPEED).contains(&settings.header.speed) {
return Err(StashError::InvalidDirectory(format!(
"滚动速度需在 {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() {
return Err(StashError::InvalidDirectory("信息文本不能为空".into()));
}
if text.chars().count() > 60 {
return Err(StashError::InvalidDirectory("单条信息最多 60 个字".into()));
}
if let Some(tag) = &item.tag {
if tag.chars().count() > 8 {
return Err(StashError::InvalidDirectory("标签最多 8 个字".into()));
}
}
}
if settings.profile.name.chars().count() > 40 {
return Err(StashError::InvalidDirectory("名称最多 40 个字".into()));
}
if settings.profile.role.chars().count() > 20 {
return Err(StashError::InvalidDirectory("角色最多 20 个字".into()));
}
if settings.profile.description.chars().count() > 200 {
return Err(StashError::InvalidDirectory("描述最多 200 个字".into()));
}
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(())
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0)
}
/// 读设置;没有记录时返回默认值与 version = 0。
pub fn load(conn: &Connection) -> Result<SettingsDocument, StashError> {
let row: Option<(String, i64)> = conn
.query_row(
"SELECT value, version FROM setting WHERE key = ?1",
[SETTINGS_KEY],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(map_error)?;
let Some((raw, version)) = row else {
return Ok(SettingsDocument {
version: 0,
settings: Settings::default(),
});
};
let settings = serde_json::from_str::<Settings>(&raw).unwrap_or_else(|error| {
tracing::warn!("设置 JSON 解析失败,使用默认值: {error}");
Settings::default()
});
Ok(SettingsDocument { version, settings })
}
/// 保存设置;`expected_version` 与库里不一致时返回冲突。
pub fn save(
conn: &mut Connection,
settings: Settings,
expected_version: i64,
) -> Result<SettingsDocument, StashError> {
validate(&settings)?;
let value = serde_json::to_string(&settings)
.map_err(|error| StashError::Database(format!("设置序列化失败: {error}")))?;
let tx = conn.transaction().map_err(map_error)?;
let updated = if expected_version == 0 {
tx.execute(
"INSERT INTO setting (key, value, version, updated_at) VALUES (?1, ?2, 1, ?3)
ON CONFLICT(key) DO NOTHING",
params![SETTINGS_KEY, value, now()],
)
.map_err(map_error)?
} else {
tx.execute(
"UPDATE setting SET value = ?1, version = version + 1, updated_at = ?2
WHERE key = ?3 AND version = ?4",
params![value, now(), SETTINGS_KEY, expected_version],
)
.map_err(map_error)?
};
if updated == 0 {
return Err(StashError::Conflict(
"设置已被其它页面修改,请刷新后重试".into(),
));
}
let (version,): (i64,) = tx
.query_row(
"SELECT version FROM setting WHERE key = ?1",
[SETTINGS_KEY],
|row| Ok((row.get(0)?,)),
)
.map_err(map_error)?;
tx.commit().map_err(map_error)?;
Ok(SettingsDocument { version, settings })
}
#[cfg(test)]
mod tests {
use super::*;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.execute_batch(
"CREATE TABLE setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);",
)
.expect("应能建表");
conn
}
#[test]
fn defaults_are_stable_and_valid() {
let settings = Settings::default();
assert!(settings.header.enabled);
assert_eq!(settings.header.items.len(), 3);
assert!(validate(&settings).is_ok());
}
#[test]
fn roundtrip_and_version_bump() {
let mut conn = db();
let saved = save(&mut conn, Settings::default(), 0).expect("首次保存应成功");
assert_eq!(saved.version, 1);
let loaded = load(&conn).expect("应能读");
assert_eq!(loaded, saved);
let mut next = saved.settings.clone();
next.header.speed = 40;
let saved2 = save(&mut conn, next, saved.version).expect("更新应成功");
assert_eq!(saved2.version, 2);
assert_eq!(saved2.settings.header.speed, 40);
}
#[test]
fn stale_version_conflicts() {
let mut conn = db();
let saved = save(&mut conn, Settings::default(), 0).expect("首次保存应成功");
let error = save(&mut conn, Settings::default(), saved.version - 1).expect_err("应冲突");
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();
let mut bad = Settings::default();
bad.header.speed = 1;
assert!(save(&mut conn, bad, 0).is_err());
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]
fn broken_json_falls_back_to_defaults() {
let conn = db();
conn.execute(
"INSERT INTO setting (key, value, version, updated_at) VALUES (?1, '{bad json', 3, 0)",
[SETTINGS_KEY],
)
.expect("应能写入");
let loaded = load(&conn).expect("坏 JSON 不应报错");
assert_eq!(loaded.version, 3);
assert_eq!(loaded.settings, Settings::default());
}
}
+5
View File
@@ -30,6 +30,10 @@ pub enum StashError {
/// 统一收敛成一个变体,避免把 rusqlite 的类型泄漏到库的公开 API。
#[error("数据库错误: {0}")]
Database(String),
/// 乐观锁冲突:资源在本次编辑期间被别人改过。
#[error("冲突: {0}")]
Conflict(String),
}
impl IntoResponse for StashError {
@@ -37,6 +41,7 @@ impl IntoResponse for StashError {
let (status, err_msg) = match self {
StashError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
StashError::InvalidDirectory(msg) => (StatusCode::BAD_REQUEST, msg),
StashError::Conflict(msg) => (StatusCode::CONFLICT, msg),
_ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
};
-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());
+840 -47
View File
@@ -1,10 +1,11 @@
use axum::{
Json, Router,
extract::{OriginalUri, Path, State},
http::{HeaderValue, Method, StatusCode, header},
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,
routing::{get, patch, post, put},
};
use include_dir::{Dir, include_dir};
use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
@@ -36,6 +37,44 @@ 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>>,
}
/// 连续登录失败 5 次 → 锁 60 秒。
#[derive(Debug, Default)]
pub struct LoginGuard {
failures: u32,
locked_until: Option<std::time::Instant>,
}
impl LoginGuard {
fn locked(&mut self) -> bool {
if let Some(until) = self.locked_until {
if std::time::Instant::now() < until {
return true;
}
self.failures = 0;
self.locked_until = None;
}
false
}
fn record_failure(&mut self) {
self.failures += 1;
if self.failures >= 5 {
self.locked_until = Some(std::time::Instant::now() + Duration::from_secs(60));
self.failures = 0;
}
}
fn reset(&mut self) {
self.failures = 0;
self.locked_until = None;
}
}
pub struct ServerHandle {
@@ -122,11 +161,14 @@ fn not_found_page(path: &str) -> Response {
/// 只接管明确列出的前缀,托管目录里写错的路径仍然返回 404 页面:
/// 把所有未命中都冒充成 200 的「万能 SPA 回落」,会让人分不清
/// 到底是路由拼错了还是文件真的不存在。
const SPA_PREFIXES: &[&str] = &["/c/"];
const SPA_PREFIXES: &[&str] = &["/c/", "/g/"];
/// 精确匹配的前端页面(没有实体文件,返回入口页)
const SPA_PAGES: &[&str] = &["/login", "/admin", "/interface"];
/// 是否为前端路由路径(没有实体文件,应该返回入口页)。
fn is_spa_path(path: &str) -> bool {
SPA_PREFIXES.iter().any(|prefix| path.starts_with(prefix))
SPA_PAGES.contains(&path) || SPA_PREFIXES.iter().any(|prefix| path.starts_with(prefix))
}
/// `ServeDir` 的兜底:前端路由回落到入口页,其余路径仍是 404 页面。
@@ -164,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` 而不是监听数据库文件:
@@ -196,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>(
@@ -235,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();
@@ -275,8 +321,699 @@ fn project_event(projects: Vec<AppProject>) -> Event {
})
}
/// 组装路由:API + 内嵌前端 + 托管目录。
// ---------------- 登录 / 会话 ----------------
const SESSION_COOKIE: &str = "stash_session";
#[derive(serde::Deserialize)]
struct PasswordBody {
password: String,
}
#[derive(serde::Deserialize)]
struct SetupBody {
password: String,
username: Option<String>,
}
#[derive(serde::Deserialize)]
struct ChangePasswordBody {
current: String,
next: String,
}
fn cookie_token(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
raw.split(';')
.filter_map(|part| part.trim().split_once('='))
.find(|(key, _)| *key == SESSION_COOKIE)
.map(|(_, value)| value.to_string())
}
fn session_cookie(token: &str) -> String {
format!(
"{SESSION_COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}",
crate::db::auth::SESSION_TTL_SECS
)
}
fn clear_cookie() -> String {
format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0")
}
fn api_error(status: StatusCode, message: &str) -> Response {
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
fn with_cookie(mut response: Response, cookie: String) -> Response {
if let Ok(value) = HeaderValue::from_str(&cookie) {
response.headers_mut().append(header::SET_COOKIE, value);
}
response
}
/// 同源校验:跨站请求带 Origin 且 host 不匹配时拒绝。
/// 和 Cookie 的 `SameSite=Strict` 互为补充,不依赖浏览器实现。
fn same_origin(headers: &HeaderMap) -> bool {
let Some(origin) = headers
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
else {
return true;
};
let Some(host) = headers.get(header::HOST).and_then(|value| value.to_str().ok()) else {
return false;
};
origin.ends_with(host)
}
fn login_locked(state: &AppState) -> bool {
state
.login_guard
.lock()
.map(|mut guard| guard.locked())
.unwrap_or(true)
}
fn login_failed(state: &AppState) {
if let Ok(mut guard) = state.login_guard.lock() {
guard.record_failure();
}
}
fn login_ok(state: &AppState) {
if let Ok(mut guard) = state.login_guard.lock() {
guard.reset();
}
}
async fn issue_token(state: &AppState) -> Result<String, Response> {
let db = Arc::clone(&state.db);
tokio::task::spawn_blocking(move || db.issue_session())
.await
.map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()))?
.map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()))
}
async fn read_session(state: &AppState, headers: &HeaderMap) -> Result<crate::db::SessionState, Response> {
let token = cookie_token(headers);
let db = Arc::clone(&state.db);
tokio::task::spawn_blocking(move || db.auth_state(token.as_deref()))
.await
.map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()))?
.map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()))
}
/// GET /api/auth/session
async fn auth_session(State(state): State<AppState>, headers: HeaderMap) -> Response {
match read_session(&state, &headers).await {
Ok(session) => Json(session).into_response(),
Err(response) => response,
}
}
/// POST /api/auth/setup —— 只在还没有管理员时可用
async fn auth_setup(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<SetupBody>,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
let username = body.username.unwrap_or_else(|| "admin".into());
let password = body.password;
let db = Arc::clone(&state.db);
let created = tokio::task::spawn_blocking(move || db.setup_admin(&username, &password)).await;
match created {
Ok(Ok(())) => match issue_token(&state).await {
Ok(token) => with_cookie(
Json(serde_json::json!({ "ok": true })).into_response(),
session_cookie(&token),
),
Err(response) => response,
},
Ok(Err(error)) => api_error(StatusCode::BAD_REQUEST, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// POST /api/auth/login
async fn auth_login(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<PasswordBody>,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
if login_locked(&state) {
return api_error(StatusCode::TOO_MANY_REQUESTS, "尝试过多,请一分钟后再试");
}
let password = body.password;
let db = Arc::clone(&state.db);
let verified = tokio::task::spawn_blocking(move || db.login(&password)).await;
match verified {
Ok(Ok(Some(_username))) => {
login_ok(&state);
match issue_token(&state).await {
Ok(token) => with_cookie(
Json(serde_json::json!({ "ok": true })).into_response(),
session_cookie(&token),
),
Err(response) => response,
}
}
Ok(Ok(None)) => {
login_failed(&state);
api_error(StatusCode::UNAUTHORIZED, "密码错误")
}
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/auth/logout
async fn auth_logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Some(token) = cookie_token(&headers) {
let db = Arc::clone(&state.db);
let _ = tokio::task::spawn_blocking(move || db.logout(&token)).await;
}
with_cookie(
Json(serde_json::json!({ "ok": true })).into_response(),
clear_cookie(),
)
}
/// PATCH /api/auth/password —— 必须已登录,成功后所有会话失效
async fn auth_password(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<ChangePasswordBody>,
) -> 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);
let (current, next) = (body.current, body.next);
let changed =
tokio::task::spawn_blocking(move || db.change_password(&current, &next)).await;
match changed {
Ok(Ok(true)) => with_cookie(
Json(serde_json::json!({ "ok": true })).into_response(),
clear_cookie(),
),
Ok(Ok(false)) => api_error(StatusCode::UNAUTHORIZED, "当前密码不正确"),
Ok(Err(error)) => api_error(StatusCode::BAD_REQUEST, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
// ---------------- 站点设置 ----------------
#[derive(serde::Deserialize)]
struct SettingsPatch {
version: i64,
settings: crate::db::Settings,
}
/// GET /api/settings —— 读接口公开,前端首屏直接取
async fn get_settings(State(state): State<AppState>) -> Response {
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.load_settings()).await {
Ok(Ok(document)) => Json(document).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()),
}
}
/// PATCH /api/settings —— 需要登录;`version` 做乐观锁
async fn patch_settings(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<SettingsPatch>,
) -> 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);
let (settings, version) = (body.settings, body.version);
match tokio::task::spawn_blocking(move || db.save_settings(settings, version)).await {
Ok(Ok(document)) => Json(document).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()),
}
}
// ---------------- 字体 ----------------
/// 字体文件后缀:后台字体下拉只列这几种。
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} —— 编辑表单的初始值(公开,便于未登录时预览)
async fn get_item(State(state): State<AppState>, Path(key): Path<String>) -> Response {
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.load_entry(&key)).await {
Ok(Ok(Some(entry))) => Json(entry).into_response(),
Ok(Ok(None)) => api_error(StatusCode::NOT_FOUND, "条目不存在"),
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
#[derive(serde::Deserialize)]
struct ItemPatchBody {
#[serde(rename = "updatedAt")]
updated_at: i64,
item: crate::db::EntryPatch,
}
/// PATCH /api/items/{key} —— 需要登录,`updatedAt` 乐观锁
async fn patch_item(
State(state): State<AppState>,
Path(key): Path<String>,
headers: HeaderMap,
Json(body): Json<ItemPatchBody>,
) -> 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);
let (patch, updated_at) = (body.item, body.updated_at);
match tokio::task::spawn_blocking(move || db.update_entry(&key, patch, updated_at)).await {
Ok(Ok(entry)) => Json(entry).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()),
}
}
/// GET /api/backup —— 下载整库快照(需要登录)。
///
/// 用 SQLite 的 `VACUUM INTO` 生成一致性副本,避免直接复制正在写的文件。
async fn backup_db(State(state): State<AppState>, headers: HeaderMap) -> Response {
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db_path = state.config.db_path.clone();
let created = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
let target = db_path.with_file_name(format!(
"stash-backup-{}.db",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
));
let conn = rusqlite::Connection::open(&db_path).map_err(|error| error.to_string())?;
conn.execute("VACUUM INTO ?1", [target.to_string_lossy().as_ref()])
.map_err(|error| error.to_string())?;
let bytes = std::fs::read(&target).map_err(|error| error.to_string())?;
let _ = std::fs::remove_file(&target);
Ok(bytes)
})
.await;
match created {
Ok(Ok(bytes)) => {
let filename = format!(
"stash-backup-{}.db",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
);
(
[
(header::CONTENT_TYPE, "application/octet-stream".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
),
],
bytes,
)
.into_response()
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// POST /api/items/{key}/view —— 记一次浏览(公开)
async fn post_view(State(state): State<AppState>, Path(key): Path<String>) -> Response {
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.bump_view(&key)).await {
Ok(Ok((views, likes))) => Json(serde_json::json!({ "views": views, "likes": likes })).into_response(),
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
#[derive(serde::Deserialize)]
struct LikeBody {
liked: bool,
}
/// POST /api/items/{key}/like —— 点赞 / 取消(公开,前端用 localStorage 记是否已赞)
async fn post_like(
State(state): State<AppState>,
Path(key): Path<String>,
Json(body): Json<LikeBody>,
) -> Response {
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.set_like(&key, body.liked)).await {
Ok(Ok((views, likes))) => Json(serde_json::json!({ "views": views, "likes": likes })).into_response(),
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &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/items —— 新建条目(需要登录)
async fn create_item(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<crate::db::entries::NewEntry>,
) -> 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_entry(body)).await {
Ok(Ok(entry)) => Json(entry).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()),
}
}
/// DELETE /api/items/{key} —— 删除条目(需要登录)
async fn delete_item(
State(state): State<AppState>,
Path(key): 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_entry(&key)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
// ---------- 原子模型:分组树 / 卡片 ----------
/// GET /api/catalog2 —— 分组树 + 卡片(公开读,新首页/侧栏用)
async fn catalog2_api(State(state): State<AppState>) -> Response {
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || -> Result<serde_json::Value, StashError> {
Ok(serde_json::json!({
"groups": db.load_tree()?,
"cards": db.load_cards()?,
}))
})
.await
{
Ok(Ok(payload)) => Json(payload).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()),
}
}
async fn require_login(state: &AppState, headers: &HeaderMap) -> Result<(), Response> {
if !same_origin(headers) {
return Err(api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"));
}
match read_session(state, headers).await {
Ok(session) if session.authenticated => Ok(()),
Ok(_) => Err(api_error(StatusCode::UNAUTHORIZED, "请先登录")),
Err(response) => Err(response),
}
}
fn node_error(error: StashError) -> Response {
match error {
StashError::Conflict(message) => api_error(StatusCode::CONFLICT, &message),
StashError::NotFound(message) => api_error(StatusCode::NOT_FOUND, &message),
StashError::InvalidDirectory(message) => api_error(StatusCode::BAD_REQUEST, &message),
other => api_error(StatusCode::INTERNAL_SERVER_ERROR, &other.to_string()),
}
}
/// POST /api/groups —— 新建根分组 / 子分组
async fn create_node(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<crate::db::groups::NewNode>,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.create_node(body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// PATCH /api/groups/{key} —— 改名 / 移动 / 排序 / 根分组格式
async fn update_node(
State(state): State<AppState>,
Path(key): Path<String>,
headers: HeaderMap,
Json(body): Json<crate::db::groups::UpdateNode>,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.update_node(&key, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// DELETE /api/groups/{key} —— 删除分组(级联子分组与条目)
async fn delete_node(
State(state): State<AppState>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.remove_node(&key)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// POST /api/cards —— 新建卡片
async fn create_card(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<crate::db::cards::NewCard>,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.create_card(body)).await {
Ok(Ok(id)) => Json(serde_json::json!({ "id": id })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// PATCH /api/cards/{id} —— 卡片标题 / 顺序
async fn update_card(
State(state): State<AppState>,
Path(id): Path<i64>,
headers: HeaderMap,
Json(body): Json<crate::db::cards::UpdateCard>,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.update_card(id, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// DELETE /api/cards/{id} —— 删除卡片(不影响内容)
async fn delete_card(
State(state): State<AppState>,
Path(id): Path<i64>,
headers: HeaderMap,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.remove_card(id)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// PUT /api/cards/{id}/groups —— 整组替换卡片勾选的分组
async fn set_card_groups(
State(state): State<AppState>,
Path(id): Path<i64>,
headers: HeaderMap,
Json(body): Json<Vec<crate::db::cards::CardGroupInput>>,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.set_card_groups(id, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// 组装路由:API + 内嵌前端 + 托管目录。
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
/// 起监听任务,没法在单元测试里用。
fn build_router(state: AppState) -> Router {
@@ -294,14 +1031,37 @@ 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))
.route("/api/cards", post(create_card))
.route("/api/cards/{id}", patch(update_card).delete(delete_card))
.route("/api/cards/{id}/groups", put(set_card_groups))
.route("/api/catalog/events", get(catalog_events))
.route("/api/auth/session", get(auth_session))
.route("/api/auth/setup", post(auth_setup))
.route("/api/auth/login", post(auth_login))
.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/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 {
@@ -325,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)?);
@@ -333,6 +1094,8 @@ 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())),
};
let app = build_router(shared_state);
@@ -394,14 +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>
+24
View File
@@ -8,6 +8,7 @@
"name": "stash-web",
"version": "0.0.0",
"dependencies": {
"@stash/ui": "file:../../stash-ui",
"react": "^19.3.1",
"react-dom": "^19.3.1"
},
@@ -21,6 +22,25 @@
"vite": "^8.3.4"
}
},
"../../stash-ui": {
"name": "@stash/ui",
"version": "0.3.1",
"devDependencies": {
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"oxlint": "^1.88.0",
"playwright-core": "^1.64.1",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"typescript": "~6.0.3",
"vite": "^8.3.4"
},
"peerDependencies": {
"react": ">=19",
"react-dom": ">=19"
}
},
"node_modules/@oxc-project/types": {
"version": "0.154.0",
"resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.154.0.tgz",
@@ -616,6 +636,10 @@
"dev": true,
"license": "MIT"
},
"node_modules/@stash/ui": {
"resolved": "../../stash-ui",
"link": true
},
"node_modules/@types/node": {
"version": "24.19.2",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.19.2.tgz",
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@stash/ui": "file:../../stash-ui",
"react": "^19.3.1",
"react-dom": "^19.3.1"
},
+1313 -1295
View File
File diff suppressed because it is too large Load Diff
-359
View File
@@ -1,359 +0,0 @@
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 type { ItemRoute, 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, back } = useViewRoute()
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const categories = useMemo(
() => withLocalCategory(projects, catalog.categories),
[projects, catalog.categories],
)
const visibleCategories = useMemo(
() => filterCategories(categories, keyword),
[categories, keyword],
)
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区
const sidebarCategories = useMemo(
() =>
categories.filter(
(category) => category.id !== 'local' && category.showInSidebar !== false,
),
[categories],
)
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(() => {
scrollRef.current?.scrollTo({ top: 0 })
}, [view])
// 桌面端滚动作在内容容器里,窄屏滚的是窗口:
// 两处都要驱动「已滚动」状态(顶部渐隐层 + 返回顶部按钮共用)
useEffect(() => {
const sync = () => {
const inner = scrollRef.current?.scrollTop ?? 0
const next = Math.max(inner, window.scrollY) > 4
setScrolled((current) => (current === next ? current : next))
}
window.addEventListener('scroll', sync, { passive: true })
return () => window.removeEventListener('scroll', sync)
}, [])
/**
* 「打开这个分区」应该落到哪个视图。
*
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
* 这样卡片上的「···」和分区标题都通向卡片里实际展示的那块内容。
*/
const detailViewFor = (categoryId: string): View => {
const category = categories.find((item) => item.id === categoryId)
if (category?.kind === 'apps' && 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 => {
if (category.kind !== 'apps') return 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),
})
}
/**
* 条目详情的「返回」目标:优先回到该条目所在的分组,
* 没有分组(海报 / 图文)时回到分区,走和侧栏一样的展示块规则。
*/
const detailTargetFor = (item: ItemRoute): View =>
item.groupId === undefined
? detailViewFor(item.categoryId)
: { type: 'detail', categoryId: item.categoryId, groupId: item.groupId }
/**
* 侧栏导航:带分组时直接打开那个分组;
* 点分区本身时,交给 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' })
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 (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '分类不存在'}
</p>
)
}
if (itemCategory.kind === 'articles') {
const article = findArticleItem(itemCategory, itemView.itemId)
if (article === undefined) {
return (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '条目不存在'}
</p>
)
}
return (
<ArticleDetailView
article={article}
categoryTitle={itemCategory.title}
onBack={() => back(detailTargetFor(itemView))}
/>
)
}
if (detailModel === undefined) {
return (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '条目不存在'}
</p>
)
}
return (
<ItemDetailView
breadcrumb={`${itemCategory.title} / ${detailModel.title}`}
model={detailModel}
related={relatedItems}
onBack={() => back(detailTargetFor(itemView))}
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
/>
)
}
return (
<div className="app">
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
<div className="site-background" aria-hidden="true" />
<SiteHeader
projectCount={projects.length}
sampleProject={projects[0]?.name}
live={live}
onFocusSearch={() => searchRef.current?.focus()}
/>
<div className="search-row">
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
</div>
<div className="layout">
<Sidebar
projects={projects}
categories={sidebarCategories}
live={live}
refreshing={refreshing}
onRefresh={refresh}
activeCategoryId={viewCategoryId}
activeGroupId={detailView?.groupId}
onNavigate={navigate}
/>
<main className="content">
{/* 滚动后才淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
<div
className="content-scroll"
id="content-scroll"
ref={scrollRef}
onScroll={(event) => {
const next = event.currentTarget.scrollTop > 4
setScrolled((current) => (current === next ? current : next))
}}
>
<h1 className="sr-only">Stash 本地应用工作区</h1>
{status === 'error' && (
<div className="notice notice-error" role="alert">
<strong>连不上 Stash 服务</strong>
<p>{error}</p>
<button type="button" className="notice-action" onClick={refresh}>
重试
</button>
</div>
)}
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
{catalog.status === 'error' && (
<div className="notice notice-error" role="alert">
<strong>内容库读取失败</strong>
<p>{catalog.error}</p>
<button type="button" className="notice-action" onClick={catalog.reload}>
重试
</button>
</div>
)}
{detailView === undefined && itemView === undefined && (
<>
{catalog.status === 'loading' && (
<p className="section-empty">正在读取内容库…</p>
)}
<HomeView
categories={visibleCategories}
onOpenDetail={openDetail}
onOpenItem={openItem}
/>
{searching && visibleCategories.length === 0 && (
<div className="notice">
<strong>没有匹配「{keyword.trim()}」的内容</strong>
<p>换个关键字试试,或清空搜索查看全部。</p>
</div>
)}
</>
)}
{detailView !== undefined && detailCategory !== undefined && (
<DetailView
category={detailCategory}
groupId={detailView.groupId}
onBack={goHome}
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
/>
)}
{detailView !== undefined && detailCategory === undefined && (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '分类不存在'}
</p>
)}
{itemView !== undefined && renderItemView()}
</div>
</main>
</div>
<BackToTop visible={scrolled} />
</div>
)
}
File diff suppressed because it is too large Load Diff
+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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

+64
View File
@@ -0,0 +1,64 @@
/**
* 登录/会话的接口封装。
*
* 会话是 HttpOnly Cookie,前端拿不到 token,只能问 `/api/auth/session`。
*/
export type SessionState = {
authenticated: boolean
has_admin: boolean
username?: string | null
}
type Result = { ok: true } | { ok: false; status: number; error: string }
async function request(
method: 'POST' | 'PATCH',
url: string,
body: unknown,
): Promise<Result> {
try {
const response = await fetch(url, {
method,
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return {
ok: false,
status: response.status,
error: payload?.error ?? `请求失败(${response.status})`,
}
} catch (error) {
return { ok: false, status: 0, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function fetchSession(): Promise<SessionState> {
try {
const response = await fetch('/api/auth/session', { credentials: 'same-origin' })
if (!response.ok) return { authenticated: false, has_admin: false }
return (await response.json()) as SessionState
} catch {
return { authenticated: false, has_admin: false }
}
}
export function login(password: string): Promise<Result> {
return request('POST', '/api/auth/login', { password })
}
export function setup(password: string, username = 'admin'): Promise<Result> {
return request('POST', '/api/auth/setup', { password, username })
}
export function logout(): Promise<Result> {
return request('POST', '/api/auth/logout', {})
}
export function changePassword(current: string, next: string): Promise<Result> {
return request('PATCH', '/api/auth/password', { current, next })
}
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>
)
}
@@ -0,0 +1,117 @@
import { Button, FormField, Icon, Modal, Notice, PasswordInput, Space, Text } from '@stash/ui'
import { useEffect, useState } from 'react'
import { login, setup, type SessionState } from '../../auth'
/**
* 登录弹窗:首次使用显示「设置密码 + 确认」,之后只显示密码。
*
* 成功后由父组件刷新会话状态;弹窗本身不跳转页面。
*/
type LoginDialogProps = {
open: boolean
hasAdmin: boolean
onClose: () => void
onSuccess: (session: SessionState) => void
}
export function LoginDialog({ open, hasAdmin, onClose, onSuccess }: LoginDialogProps) {
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// 每次打开重置,避免上次的错误/输入残留
useEffect(() => {
if (!open) return
setPassword('')
setConfirm('')
setError('')
setBusy(false)
}, [open])
const isSetup = !hasAdmin
const submit = async () => {
setError('')
if (isSetup && password !== confirm) {
setError('两次输入的密码不一致')
return
}
setBusy(true)
const result = isSetup ? await setup(password) : await login(password)
setBusy(false)
if (!result.ok) {
setError(result.error)
return
}
onSuccess({
authenticated: true,
has_admin: true,
username: 'admin',
})
}
return (
<Modal
open={open}
onClose={onClose}
title={isSetup ? '设置管理员密码' : '登录'}
footer={
<Space gap="sm" justify="end">
<Button variant="secondary" onClick={onClose}>
取消
</Button>
<Button loading={busy} icon={<Icon name="lock_line" />} onClick={submit}>
{isSetup ? '设置并登录' : '登录'}
</Button>
</Space>
}
>
<Space direction="column" gap="md" align="stretch">
<Text size="sm" tone="muted">
{isSetup
? '首次使用:设置一个至少 8 位的密码,之后用它登录编辑。'
: '登录后可以编辑页头滚动信息、侧栏顺序与显示。'}
</Text>
{error !== '' && (
<Notice tone="error" title="操作失败">
{error}
</Notice>
)}
<FormField label="密码" htmlFor="login-password" required>
<PasswordInput
id="login-password"
placeholder="输入密码"
autoComplete={isSetup ? 'new-password' : 'current-password'}
value={password}
autoFocus
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
{isSetup && (
<FormField label="确认密码" htmlFor="login-confirm" required>
<PasswordInput
id="login-confirm"
placeholder="再输入一次"
autoComplete="new-password"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
)}
</Space>
</Modal>
)
}
+8 -25
View File
@@ -1,33 +1,16 @@
import { ArrowUpIcon } from './Icon'
import { ScrollTop } from '@stash/ui'
/**
* 回到顶部:复刻 Alright 主题的 .scroll-toolbar .totop-button。
*
* - 42px 圆形、浅灰底,固定在右侧(bottom: 50%)
* - 默认隐藏,只有内容区滚动后才淡入,回到顶部后再淡出
* 回到顶部:直接用组件库的 ScrollTop,滚动容器是应用的内容区。
*/
type BackToTopProps = {
/** 内容区是否已滚动(由 App 传入) */
/** 页面是否已滚动(由 App 传入) */
visible: boolean
/** 滚动容器 id,默认旧版内容区 */
targetId?: string
}
export function BackToTop({ visible }: BackToTopProps) {
const handleClick = () => {
// 桌面端滚动的是内容容器,窄屏滚动的是窗口,两处都滚一遍
document.getElementById('content-scroll')?.scrollTo({ top: 0, behavior: 'smooth' })
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<button
type="button"
className={`back-to-top${visible ? ' is-visible' : ''}`}
title="回到顶部"
aria-label="回到顶部"
tabIndex={visible ? 0 : -1}
onClick={handleClick}
>
<ArrowUpIcon />
</button>
)
export function BackToTop({ visible, targetId = 'content-scroll' }: BackToTopProps) {
return <ScrollTop visible={visible} targetId={targetId} offset={30} />
}
@@ -1,11 +0,0 @@
/**
* 数量角标:贴在分区标题右上角的小红标。
* 纯展示组件,只接收数字,宽度由内容撑开(两位数不会变形)。
*/
type CountBadgeProps = {
count: number
}
export function CountBadge({ count }: CountBadgeProps) {
return <span className="count-badge">{count}</span>
}
@@ -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}</>
}
-63
View File
@@ -1,63 +0,0 @@
/**
* 通用图标组件。
*
* 全部使用 Alright 主题的 iconfont 线性图标(见 src/iconfont.css),
* 尺寸与颜色由使用处的 CSS 控制(font-size / color),
* 因此同一套图标可以复用在按钮、统计条、导航等不同位置。
*/
type IconProps = {
/** 附加类名,便于局部覆盖字号或颜色 */
className?: string
}
const base = (name: string, className?: string) =>
`iconfont icon-${name}${className !== undefined ? ` ${className}` : ''}`
export function SearchIcon({ className }: IconProps) {
return <i className={base('search', className)} aria-hidden="true" />
}
export function UserIcon({ className }: IconProps) {
return <i className={base('user', className)} aria-hidden="true" />
}
export function RefreshIcon({ className }: IconProps) {
return <i className={base('refresh', className)} aria-hidden="true" />
}
export function LinkIcon({ className }: IconProps) {
return <i className={base('link', className)} aria-hidden="true" />
}
export function ExternalIcon({ className }: IconProps) {
return <i className={base('external', className)} aria-hidden="true" />
}
export function EyeIcon({ className }: IconProps) {
return <i className={base('eye', className)} aria-hidden="true" />
}
export function ThumbUpIcon({ className }: IconProps) {
return <i className={base('thumb-up', className)} aria-hidden="true" />
}
export function ShareIcon({ className }: IconProps) {
return <i className={base('share', className)} aria-hidden="true" />
}
export function CloseIcon({ className }: IconProps) {
return <i className={base('close', className)} aria-hidden="true" />
}
export function MoreIcon({ className }: IconProps) {
return <i className={base('more', className)} aria-hidden="true" />
}
export function ArrowUpIcon({ className }: IconProps) {
return <i className={base('arrow-up', className)} aria-hidden="true" />
}
export function ArrowDownIcon({ className }: IconProps) {
return <i className={base('arrow-down', className)} aria-hidden="true" />
}
+12 -35
View File
@@ -1,14 +1,9 @@
import { EntryRow, ItemBadge } from '@stash/ui'
import type { AppItem } from '../../data/types'
/**
* 应用卡片:逐项复刻 Alright 主题「推荐作者」条目的结构。
*
* 对应主题的:
* .flex-avatar > img.avatar-48 + span.avatar-badge.badge_5
* .item-content > h3.font-title.text-md(名字 + span.item-badge.hot)
* + div.text-xs.text-muted.h-1x(单行描述)
*
* 热门条目才会同时出现头像角标和 HOT 徽标,普通条目两者都没有。
* 应用卡片:头像 + 名称(热门带 HOT 角标)+ 单行描述。
* 版式与点击语义都在库里的 EntryRow,这里只做数据映射。
*/
type AppCardProps = {
item: AppItem
@@ -17,33 +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)
}}
>
<span className="app-card-avatar" aria-hidden="true">
{item.cover !== undefined ? (
<img src={item.cover} alt="" loading="lazy" />
) : (
<span className="app-card-letter">{item.icon}</span>
)}
{item.hot === true && <span className="app-card-verify" />}
</span>
<span className="app-card-info">
<span className="app-card-title">
<span className="app-card-name">{item.name}</span>
{item.hot === true && <span className="app-card-badge" />}
</span>
<span className="app-card-desc">{item.desc}</span>
</span>
</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)}
/>
)
}
+9 -6
View File
@@ -1,10 +1,8 @@
import { Grid } from '@stash/ui'
import type { AppItem } from '../../data/types'
import { AppCard } from './AppCard'
/**
* 应用网格:默认 4 列,窄屏退化为 2 列。
* 首页预览与明细页共用,靠 modifier 调整间距。
*/
/** 应用网格:桌面 4 列,平板 / 手机 2 列(断点由组件库 Grid 负责) */
type AppGridProps = {
items: AppItem[]
/** 明细页使用更宽松的间距 */
@@ -15,10 +13,15 @@ type AppGridProps = {
export function AppGrid({ items, variant = 'home', onOpen }: AppGridProps) {
return (
<div className={`app-grid${variant === 'detail' ? ' app-grid--detail' : ''}`}>
<Grid
columns={4}
tabletColumns={2}
mobileColumns={2}
gap={variant === 'detail' ? 'lg' : 'md'}
>
{items.map((item) => (
<AppCard key={item.id} item={item} onOpen={onOpen} />
))}
</div>
</Grid>
)
}
@@ -1,3 +1,4 @@
import { Paragraph, Title } from '@stash/ui'
import type { ReactNode } from 'react'
/**
@@ -10,7 +11,6 @@ import type { ReactNode } from 'react'
* - 行内 `**粗体**`、`` `代码` ``、`[文字](链接)`
*
* 不支持列表、表格、引用块——这里渲染的是文章正文,不是通用 Markdown 编辑器。
* 需要完整语法时再换 react-markdown,渲染层已经隔离在这个组件里。
*/
type Block =
@@ -104,9 +104,9 @@ export function ArticleBody({ markdown }: ArticleBodyProps) {
{parseBlocks(markdown).map((block, index) => {
if (block.kind === 'heading') {
return (
<h3 className="article-body-heading" key={index}>
<Title level={2} as="h3" key={index}>
{renderInline(block.text)}
</h3>
</Title>
)
}
@@ -123,9 +123,9 @@ export function ArticleBody({ markdown }: ArticleBodyProps) {
}
return (
<p className="article-body-text" key={index}>
<Paragraph tone="sub" size="md" leading="relaxed" key={index}>
{renderInline(block.text)}
</p>
</Paragraph>
)
})}
</>
@@ -1,36 +0,0 @@
import type { ArticleItem } from '../../data/types'
/**
* 首页图文卡:4:3 封面 + 底部渐变条上的标题。
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
*/
type ArticleCardProps = {
item: ArticleItem
onOpen?: (itemId: string) => void
}
export function ArticleCard({ item, onOpen }: ArticleCardProps) {
const content = (
<>
<img className="article-cover" src={item.cover} alt={item.title} loading="lazy" />
<span className="article-caption">
<span className="article-title">{item.title}</span>
</span>
</>
)
if (onOpen === undefined) {
return <figure className="article-card">{content}</figure>
}
return (
<button
type="button"
className="article-card"
title={item.title}
onClick={() => onOpen(item.id)}
>
{content}
</button>
)
}
@@ -1,52 +1,76 @@
import { Breadcrumb, Card, Paragraph, Text, Title } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
import { viewPath } from '../../data/view'
import { ArticleBody } from './ArticleBody'
/**
* 文章详情页(对应模板图二):
* 大标题 + [编辑] + 元信息行 + 白色正文卡。
* 面包屑 + 大标题 + [编辑] + 元信息行 + 白色正文卡。
*
* 正文是一整段 Markdown,交给 ArticleBody 解析渲染;
* 渲染细节(支持哪些语法)都收在那一个组件里。
*/
type ArticleDetailViewProps = {
article: ArticleItem
categoryId: string
categoryTitle: string
onBack: () => void
onNavigate: (href: string) => void
onEdit: () => void
/** 只有登录后才显示 [编辑] */
canEdit: boolean
}
export function ArticleDetailView({ article, categoryTitle, onBack }: ArticleDetailViewProps) {
export function ArticleDetailView({
article,
categoryId,
categoryTitle,
onNavigate,
onEdit,
canEdit,
}: ArticleDetailViewProps) {
const meta = article.meta ?? ['2 年前', categoryTitle]
const crumbs = [
{ label: '首页', href: '/' },
{ label: categoryTitle, href: viewPath({ type: 'detail', categoryId }) },
{ label: '文章' },
]
return (
<div className="detail-view article-view">
<div className="detail-nav card-box">
<button type="button" className="detail-back" onClick={onBack}>
返回
</button>
<span className="detail-breadcrumb">{categoryTitle} / 文章</span>
</div>
<Card padding="none" className="detail-nav">
<Breadcrumb className="detail-breadcrumb" items={crumbs} onNavigate={onNavigate} />
</Card>
<header className="article-head">
<h2 className="article-heading">
<Title level={1} as="h2" className="article-heading">
{article.title}
<button type="button" className="item-edit" disabled title="编辑功能待接入">
[编辑]
</button>
</h2>
{canEdit && (
<button type="button" className="item-edit" title="编辑文章" onClick={onEdit}>
[编辑]
</button>
)}
</Title>
<div className="article-meta">
{meta.map((text, index) => (
<span className="article-meta-item" key={text}>
{index > 0 && <i className="article-meta-dot" aria-hidden="true" />}
{text}
<Text size="xs" tone="muted">
{text}
</Text>
</span>
))}
</div>
</header>
<article className="article-body card-box">
<ArticleBody markdown={article.body} />
</article>
<Card padding="none" className="article-body-card">
<div className="article-body">
<Paragraph tone="main" size="md" leading="relaxed" className="article-lead">
{article.desc}
</Paragraph>
<ArticleBody markdown={article.body} />
</div>
</Card>
</div>
)
}
@@ -1,19 +0,0 @@
import type { ArticleItem } from '../../data/types'
import { ArticleCard } from './ArticleCard'
/** 图文网格:首页 4 列,窄屏 2 列 */
type ArticleGridProps = {
items: ArticleItem[]
/** 点击卡片打开文章详情 */
onOpen?: (itemId: string) => void
}
export function ArticleGrid({ items, onOpen }: ArticleGridProps) {
return (
<div className="article-grid">
{items.map((item) => (
<ArticleCard key={item.id} item={item} onOpen={onOpen} />
))}
</div>
)
}
+7 -22
View File
@@ -1,8 +1,9 @@
import { EntryTile } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
/**
* 明细页图文卡:上图下文的白卡。
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
* 版式与点击语义都在库里的 EntryTile,这里只做数据映射。
*/
type CesCardProps = {
item: ArticleItem
@@ -10,28 +11,12 @@ type CesCardProps = {
}
export function CesCard({ item, onOpen }: CesCardProps) {
const content = (
<>
<img className="ces-cover" src={item.cover} alt={item.title} loading="lazy" />
<span className="ces-body">
<span className="ces-title">{item.title}</span>
<span className="ces-desc">{item.desc}</span>
</span>
</>
)
if (onOpen === undefined) {
return <article className="ces-card">{content}</article>
}
return (
<button
type="button"
className="ces-card"
<EntryTile
title={item.title}
onClick={() => onOpen(item.id)}
>
{content}
</button>
description={item.desc}
cover={item.cover}
onOpen={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
)
}
@@ -1,7 +1,8 @@
import { Grid } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
import { CesCard } from './CesCard'
/** 明细页图文网格:4 列白卡,窄屏 2 列 */
/** 明细页图文网格:桌面 4 列,平板 2 列,手机 1 列 */
type CesCardGridProps = {
items: ArticleItem[]
/** 点击卡片打开文章详情 */
@@ -10,10 +11,10 @@ type CesCardGridProps = {
export function CesCardGrid({ items, onOpen }: CesCardGridProps) {
return (
<div className="ces-grid">
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="lg">
{items.map((item) => (
<CesCard key={item.id} item={item} onOpen={onOpen} />
))}
</div>
</Grid>
)
}
@@ -1,86 +0,0 @@
import { categoryCount, type Category } from '../../data/types'
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
onBack: () => void
/** 点击卡片 / 海报进入条目详情 */
onOpenItem: (itemId: string) => void
}
export function DetailView({ category, groupId, onBack, onOpenItem }: DetailViewProps) {
const groups = category.kind === 'apps' ? category.groups : []
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 breadcrumb =
activeGroup !== undefined
? `首页 / ${category.title} / ${activeGroup.name}`
: `首页 / ${category.title}`
function renderBody() {
if (category.kind === 'posters') {
return <PosterGrid items={category.items} variant="detail" onOpen={onOpenItem} />
}
if (category.kind === 'articles') {
return <CesCardGrid items={category.items} onOpen={onOpenItem} />
}
if (activeGroup !== undefined) {
return <AppGrid items={activeGroup.items} variant="detail" onOpen={onOpenItem} />
}
if (category.groups.length === 1) {
return <AppGrid items={category.groups[0].items} variant="detail" onOpen={onOpenItem} />
}
if (category.groups.length === 0) {
return <p className="section-empty">{category.emptyText ?? '暂无内容'}</p>
}
return (
<>
{category.groups.map((group) => (
<GroupBlock key={group.id} group={group} onOpen={onOpenItem} />
))}
</>
)
}
return (
<div className="detail-view">
<div className="detail-nav card-box">
<button type="button" className="detail-back" onClick={onBack}>
返回首页
</button>
<span className="detail-breadcrumb">{breadcrumb}</span>
</div>
<div className="detail-hero">
<span className="detail-hero-icon" aria-hidden="true">
{category.icon}
</span>
<h2 className="detail-hero-title">{title}</h2>
<span className="detail-hero-count">[{count}]</span>
</div>
{renderBody()}
</div>
)
}
@@ -1,20 +0,0 @@
import type { AppGroup } from '../../data/types'
import { AppGrid } from './AppGrid'
/**
* 明细页的分组块:小标题 + 该组的应用网格。
* 分类被拆成多个分组时重复使用,单个分组时明细页不渲染标题。
*/
type GroupBlockProps = {
group: AppGroup
onOpen?: (itemId: string) => void
}
export function GroupBlock({ group, onOpen }: GroupBlockProps) {
return (
<section className="group-block">
<h3 className="group-title">{group.name}</h3>
<AppGrid items={group.items} variant="detail" onOpen={onOpen} />
</section>
)
}
@@ -1,92 +0,0 @@
import { categoryCount, flattenAppItems, type Category } from '../../data/types'
import { AppGrid } from './AppGrid'
import { ArticleGrid } from './ArticleGrid'
import { PosterGrid } from './PosterGrid'
import { SectionCard } from './SectionCard'
/**
* 首页:把每个分类渲染成一张分区卡片。
*
* 每个分区只展示前若干条(由分类的 previewLimit 决定),
* 右上角 ••• 进入该分类明细,点击卡片 / 海报则进入该条目的详情。
*/
type HomeViewProps = {
categories: Category[]
onOpenDetail: (categoryId: string) => void
onOpenItem: (categoryId: string, itemId: string) => void
}
export function HomeView({ categories, onOpenDetail, onOpenItem }: HomeViewProps) {
return (
<div className="home-view">
{categories.map((category) => (
<SectionCard
key={category.id}
// 卡片标题跟着实际展示的内容走:配了展示块就用块标题(如「常用站点」),
// 没配(如「本地服务」)才用分类标题
title={category.block?.title ?? category.title}
count={cardCount(category)}
barColor={category.barColor}
onMore={() => onOpenDetail(category.id)}
>
{renderPreview(category, onOpenItem)}
</SectionCard>
))}
</div>
)
}
/**
* 卡片右上角的数量角标。
*
* 配了「分组展示块」时,卡片实际展示的是那个分组,所以数量也应该只数它;
* 否则(电影 / 文章 / 本地服务)数整个分区。
*/
function cardCount(category: Category): number {
if (category.kind === 'apps' && category.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) return group.items.length
}
return categoryCount(category)
}
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
function renderPreview(
category: Category,
onOpenItem: (categoryId: string, itemId: string) => void,
) {
if (category.kind === 'apps') {
// 配了展示块就只展示那个分组;没有配置(如「本地服务」)则退回全部条目
const blockGroup =
category.block !== undefined
? category.groups.find((group) => group.id === category.block?.nodeKey)
: undefined
const source = category.block !== undefined ? (blockGroup?.items ?? []) : flattenAppItems(category)
const limit = category.block?.previewLimit ?? category.previewLimit ?? 4
const items = source.slice(0, limit)
if (items.length === 0) {
return <p className="section-empty">{category.emptyText ?? '暂无内容'}</p>
}
return <AppGrid items={items} onOpen={(itemId) => onOpenItem(category.id, itemId)} />
}
if (category.kind === 'posters') {
return (
<PosterGrid
items={category.items.slice(0, category.previewLimit ?? 6)}
onOpen={(itemId) => onOpenItem(category.id, itemId)}
/>
)
}
return (
<ArticleGrid
items={category.items.slice(0, category.previewLimit ?? 4)}
onOpen={(itemId) => onOpenItem(category.id, itemId)}
/>
)
}
@@ -1,46 +1,138 @@
import {
Breadcrumb,
Card,
Icon,
IconButton,
Paragraph,
Space,
Tag,
Text,
Title,
toast,
type BreadcrumbItem,
} from '@stash/ui'
import { useEffect, useState } from 'react'
import type { ItemDetailModel } from '../../data/detail'
import { resolveHref } from '../../data/detail'
import type { RelatedItem } from '../../data/types'
import { ExternalIcon, EyeIcon, ShareIcon, ThumbUpIcon } from '../common/Icon'
import { RelatedList } from './RelatedList'
/**
* 条目详情页:应用与电影共用。
*
* 版式:标题 + 主图(方形头像 / 竖版海报)+ 标签行 + 一句话评价 + 正文
* 版式:面包屑 + 标题 + 主图(方形头像 / 竖版海报)+ 标签行 + 一句话评价 + 正文
* + 操作条(浏览 / 点赞 / 分享 / 跳转)+ 可选横幅 + 「推荐阅读」。
* 具体形态由 ItemDetailModel 决定,这里不判断条目类型。
*/
type ItemDetailViewProps = {
breadcrumb: string
/** 条目业务键:浏览 / 点赞要写回数据库 */
itemKey: string
crumbs: BreadcrumbItem[]
model: ItemDetailModel
related: RelatedItem[]
onBack: () => void
onNavigate: (href: string) => void
onOpenItem: (itemId: string) => void
/** 打开编辑 Drawer */
onEdit: () => void
/** 只有登录后才显示 [编辑] */
canEdit: boolean
}
export function ItemDetailView({
breadcrumb,
itemKey,
crumbs,
model,
related,
onBack,
onNavigate,
onOpenItem,
onEdit,
canEdit,
}: ItemDetailViewProps) {
const [views, setViews] = useState(model.views)
const [likes, setLikes] = useState(model.likes)
// 历史数据可能是裸域名或占位符 #;统一解析成可跳转地址后再渲染
const jumpHref = resolveHref(model.href)
const [liked, setLiked] = useState(() => {
try {
return window.localStorage.getItem(`stash-liked:${itemKey}`) === '1'
} catch {
return false
}
})
// 进入详情页记一次浏览(每个页面打开一次)
useEffect(() => {
let alive = true
fetch(`/api/items/${encodeURIComponent(itemKey)}/view`, {
method: 'POST',
credentials: 'same-origin',
})
.then((response) => (response.ok ? response.json() : null))
.then((payload: { views?: number; likes?: number } | null) => {
if (!alive || payload === null) return
if (typeof payload.views === 'number') setViews(payload.views)
if (typeof payload.likes === 'number') setLikes(payload.likes)
})
.catch(() => undefined)
return () => {
alive = false
}
}, [itemKey])
const toggleLike = () => {
const next = !liked
setLiked(next)
setLikes((current) => Math.max(0, current + (next ? 1 : -1)))
try {
window.localStorage.setItem(`stash-liked:${itemKey}`, next ? '1' : '0')
} catch {
// 隐私模式下 localStorage 不可用,忽略即可
}
fetch(`/api/items/${encodeURIComponent(itemKey)}/like`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ liked: next }),
})
.then((response) => (response.ok ? response.json() : null))
.then((payload: { views?: number; likes?: number } | null) => {
if (payload === null) return
if (typeof payload.views === 'number') setViews(payload.views)
if (typeof payload.likes === 'number') setLikes(payload.likes)
})
.catch(() => undefined)
}
const share = () => {
const url = window.location.href
const clipboard = navigator.clipboard
if (clipboard === undefined) {
toast.error('当前浏览器不支持复制,请手动复制地址栏')
return
}
void clipboard
.writeText(url)
.then(() => toast.success('链接已复制'))
.catch(() => toast.error('复制失败,请手动复制地址栏'))
}
return (
<div className="detail-view item-view">
<div className="detail-nav card-box">
<button type="button" className="detail-back" onClick={onBack}>
返回
</button>
<span className="detail-breadcrumb">{breadcrumb}</span>
</div>
<Card padding="none" className="detail-nav">
<Breadcrumb className="detail-breadcrumb" items={crumbs} onNavigate={onNavigate} />
</Card>
<article className="item-card card-box">
<h2 className="item-title">
<Card padding="none" className="item-card">
<Title level={1} as="h2" weight="semibold" className="item-title">
{model.title}
<button type="button" className="item-edit" disabled title="编辑功能待接入">
[编辑]
</button>
</h2>
{canEdit && (
<button type="button" className="item-edit" title="编辑条目" onClick={onEdit}>
[编辑]
</button>
)}
</Title>
<div className={`item-cover item-cover--${model.shape}`}>
{model.cover !== undefined ? (
@@ -51,60 +143,78 @@ export function ItemDetailView({
</div>
{model.tags.length > 0 && (
<div className="item-tags">
<Space gap="sm" wrap>
{model.tags.map((tag) => (
<span className="item-tag" key={tag}>
<Tag key={tag} tone="primary" className="item-tag">
{tag}
</span>
</Tag>
))}
</div>
</Space>
)}
{model.comment !== undefined && (
<div className="item-quote">
<p className="item-quote">
<span className="item-quote-mark" aria-hidden="true">
“
</span>
<p className="item-quote-text">{model.comment}</p>
</div>
<span className="item-quote-text">{model.comment}</span>
</p>
)}
{model.paragraphs.map((paragraph, index) => (
<p className="item-desc" key={`${index}-${paragraph.slice(0, 8)}`}>
<Paragraph
key={`${index}-${paragraph.slice(0, 8)}`}
tone="sub"
size="md"
className="item-desc"
>
{paragraph}
</p>
</Paragraph>
))}
<div className="item-actions">
<span className="item-stat" title="浏览量">
<EyeIcon />
<em>{model.views}</em>
</span>
<Space gap="sm">
<button
type="button"
className="item-stat"
title="浏览量"
onClick={() => toast.success(`浏览量 ${views}`)}
>
<Icon name="eye_line" />
<Text size="xs" tone="sub">
{views}
</Text>
</button>
<button
type="button"
className={`item-stat${liked ? ' is-liked' : ''}`}
title={liked ? '取消点赞' : '点赞'}
aria-pressed={liked}
onClick={toggleLike}
>
<Icon name="thumb_up_2_line" />
<Text size="xs" tone="sub">
{likes}
</Text>
</button>
<IconButton
label="分享链接"
size="sm"
icon={<Icon name="share_3_line" />}
onClick={share}
/>
</Space>
<span className="item-stat" title="点赞数">
<ThumbUpIcon />
<em>{model.likes}</em>
</span>
<button
type="button"
className="item-stat item-stat--icon"
title="分享(待接入)"
aria-label="分享"
>
<ShareIcon />
</button>
{/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}
{model.href === '#' ? (
<button type="button" className="item-jump" disabled title="暂无跳转地址">
{/* 没有真实地址(空 / # / 不安全协议)时置灰,避免打开错误页面 */}
{jumpHref === undefined ? (
<button type="button" className="item-jump" disabled title="暂无跳转地址,可在编辑里添加">
跳转到
<ExternalIcon />
<Icon name="a-external_link_line1" />
</button>
) : (
<a className="item-jump" href={model.href} target="_blank" rel="noreferrer">
<a className="item-jump" href={jumpHref} target="_blank" rel="noreferrer">
跳转到
<ExternalIcon />
<Icon name="a-external_link_line1" />
</a>
)}
</div>
@@ -112,7 +222,7 @@ export function ItemDetailView({
{model.banner !== undefined && (
<img className="item-banner" src={model.banner} alt="" loading="lazy" />
)}
</article>
</Card>
<RelatedList items={related} onOpen={onOpenItem} />
</div>
@@ -0,0 +1,209 @@
import { Button, Drawer, FormField, Icon, Input, Notice, Space, Textarea } from '@stash/ui'
import { useEffect, useState } from 'react'
import { fetchItem, saveItem, type EntryRecord } from '../../items'
/**
* 条目编辑器(P2):右侧 Drawer 表单,就地改标题 / 简介 / 标签 / 正文等。
*
* `updatedAt` 做乐观锁:别人先改过就提示刷新,避免静默覆盖。
*/
type ItemEditorProps = {
open: boolean
itemKey: string | null
onClose: () => void
onSaved: (entry: EntryRecord) => void
}
type FormState = {
name: string
description: string
tags: string
badge: string
comment: string
summary: string
body: string
cover: string
banner: string
href: string
}
const emptyForm: FormState = {
name: '',
description: '',
tags: '',
badge: '',
comment: '',
summary: '',
body: '',
cover: '',
banner: '',
href: '',
}
export function ItemEditor({ open, itemKey, onClose, onSaved }: ItemEditorProps) {
const [entry, setEntry] = useState<EntryRecord | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!open || itemKey === null) return
let alive = true
setLoading(true)
setError('')
fetchItem(itemKey).then((result) => {
if (!alive) return
setLoading(false)
if (!result.ok) {
setError(result.error)
return
}
const next = result.entry
setEntry(next)
setForm({
name: next.name,
description: next.description,
tags: next.tags.join(','),
badge: next.badge,
comment: next.comment,
summary: next.summary,
body: next.body,
cover: next.cover,
banner: next.banner,
href: next.href,
})
})
return () => {
alive = false
}
}, [open, itemKey])
const update = (patch: Partial<FormState>) => setForm((previous) => ({ ...previous, ...patch }))
const save = async () => {
if (entry === null) return
setSaving(true)
setError('')
const result = await saveItem(entry.key, entry.updated_at, {
...form,
format: entry.format,
icon: entry.icon,
tags: form.tags
.split(/[,,]/)
.map((tag) => tag.trim())
.filter(Boolean),
})
setSaving(false)
if (!result.ok) {
setError(result.error)
return
}
onSaved(result.entry)
onClose()
}
return (
<Drawer
open={open}
onClose={onClose}
width={520}
title="编辑条目"
footer={
<Space gap="sm" justify="end">
<Button variant="secondary" onClick={onClose}>
取消
</Button>
<Button
loading={saving}
disabled={loading}
icon={<Icon name="check_line" />}
onClick={save}
>
保存
</Button>
</Space>
}
>
{loading && <p>正在读取…</p>}
{error !== '' && (
<Notice tone="error" title="保存失败">
{error}
</Notice>
)}
{!loading && entry !== null && (
<Space direction="column" gap="md" align="stretch">
<FormField label="标题" htmlFor="edit-name" required>
<Input
id="edit-name"
value={form.name}
onChange={(event) => update({ name: event.target.value })}
/>
</FormField>
<FormField label="简介" htmlFor="edit-description" hint="列表卡片上的一行说明">
<Input
id="edit-description"
value={form.description}
onChange={(event) => update({ description: event.target.value })}
/>
</FormField>
<FormField label="标签" htmlFor="edit-tags" hint="用逗号分隔,例如:推荐电影,动画">
<Input
id="edit-tags"
value={form.tags}
onChange={(event) => update({ tags: event.target.value })}
/>
</FormField>
<FormField label="一句话评价" htmlFor="edit-comment">
<Input
id="edit-comment"
value={form.comment}
onChange={(event) => update({ comment: event.target.value })}
/>
</FormField>
<FormField label="正文" htmlFor="edit-body" hint="支持 Markdown:##、段落、链接、图片">
<Textarea
id="edit-body"
rows={8}
value={form.body}
onChange={(event) => update({ body: event.target.value })}
/>
</FormField>
<FormField label="封面地址" htmlFor="edit-cover">
<Input
id="edit-cover"
value={form.cover}
placeholder="https://…"
onChange={(event) => update({ cover: event.target.value })}
/>
</FormField>
<FormField
label="跳转地址"
htmlFor="edit-href"
hint="可填 https://、www. 开头的域名或站内路径 /foo;留空则不显示跳转"
>
<Input
id="edit-href"
value={form.href}
placeholder="https://… 或 /project/"
onChange={(event) => update({ href: event.target.value })}
/>
</FormField>
</Space>
)}
</Drawer>
)
}
@@ -1,3 +1,4 @@
import { Cover } from '@stash/ui'
import type { PosterItem } from '../../data/types'
/**
@@ -10,27 +11,13 @@ type PosterCardProps = {
}
export function PosterCard({ item, onOpen }: PosterCardProps) {
const content = (
<>
<img className="poster-cover" src={item.cover} alt={item.title} loading="lazy" />
<span className="poster-caption">
<span className="poster-title">{item.title}</span>
</span>
</>
)
if (onOpen === undefined) {
return <figure className="poster-card">{content}</figure>
}
return (
<button
type="button"
className="poster-card"
title={item.title}
onClick={() => onOpen(item.id)}
>
{content}
</button>
<Cover
src={item.cover}
alt={item.title}
caption={item.title}
ratio="poster"
onClick={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
)
}
@@ -1,7 +1,8 @@
import { Grid } from '@stash/ui'
import type { PosterItem } from '../../data/types'
import { PosterCard } from './PosterCard'
/** 海报网格:首页 6 列,明细页同样 6 列但间距更大 */
/** 海报网格:桌面 6 列,平板 4 列,手机 3 列 */
type PosterGridProps = {
items: PosterItem[]
variant?: 'home' | 'detail'
@@ -9,12 +10,12 @@ type PosterGridProps = {
onOpen?: (itemId: string) => void
}
export function PosterGrid({ items, variant = 'home', onOpen }: PosterGridProps) {
export function PosterGrid({ items, onOpen }: PosterGridProps) {
return (
<div className={`poster-grid${variant === 'detail' ? ' poster-grid--detail' : ''}`}>
<Grid columns={6} tabletColumns={4} mobileColumns={3} gap="md">
{items.map((item) => (
<PosterCard key={item.id} item={item} onOpen={onOpen} />
))}
</div>
</Grid>
)
}
@@ -1,3 +1,4 @@
import { Avatar, Card, Grid, Space, Text, Title } from '@stash/ui'
import type { RelatedItem } from '../../data/types'
/**
@@ -13,33 +14,37 @@ export function RelatedList({ items, onOpen }: RelatedListProps) {
if (items.length === 0) return null
return (
<section className="related-card card-box" aria-label="推荐阅读">
<h3 className="related-title">
<span className="related-title-icon" aria-hidden="true">
📚
</span>
<Card padding="md" className="related-card">
<Title level={3} as="h3" className="related-title">
<span aria-hidden="true">📚</span>
推荐阅读
</h3>
</Title>
<ul className="related-grid">
<Grid className="related-grid" columns={2} tabletColumns={2} mobileColumns={1} gap="sm">
{items.map((item) => (
<li key={item.id}>
<button type="button" className="related-item" onClick={() => onOpen(item.id)}>
<span className="related-avatar" aria-hidden="true">
{item.cover !== undefined ? (
<img src={item.cover} alt="" loading="lazy" />
) : (
(item.icon ?? '•')
)}
</span>
<span className="related-info">
<span className="related-name">{item.name}</span>
<span className="related-desc">{item.desc}</span>
</span>
</button>
</li>
<button
key={item.id}
type="button"
className="related-item"
onClick={() => onOpen(item.id)}
>
<Avatar
size="md"
tone="primary"
src={item.cover}
fallback={item.icon ?? '•'}
/>
<Space direction="column" gap="xs" align="start" className="related-info">
<Text size="sm" strong truncate>
{item.name}
</Text>
<Text size="xs" tone="muted" truncate>
{item.desc}
</Text>
</Space>
</button>
))}
</ul>
</section>
</Grid>
</Card>
)
}
@@ -1,9 +1,8 @@
import { Badge, Card, Icon, IconButton, Title } from '@stash/ui'
import type { ReactNode } from 'react'
import { CountBadge } from '../common/CountBadge'
import { MoreIcon } from '../common/Icon'
/**
* 首页分区卡片:白底圆角容器,组成「色条 + 标题 + 角标 + 更多按钮 + 内容」。
* 首页分区卡片:白底容器,组成「色条 + 标题 + 角标 + 更多按钮 + 内容」。
* 内容由调用方传入,因此同一容器可以承载应用网格、海报网格或图文网格。
*/
type SectionCardProps = {
@@ -18,30 +17,30 @@ type SectionCardProps = {
export function SectionCard({ title, count, barColor, onMore, children }: SectionCardProps) {
return (
<section className="section-card">
<header className="section-header">
<div className="section-title-wrapper">
<Card padding="md" className="section-card">
<div className="section-header">
<span className="section-title-wrapper">
<span className="section-bar" style={{ backgroundColor: barColor }} aria-hidden="true" />
<span className="section-title-box">
<h2 className="section-title">{title}</h2>
<CountBadge count={count} />
<Title level={3} as="h2" weight="semibold">
{title}
</Title>
<Badge count={count} tone="danger" className="count-badge" />
</span>
</div>
</span>
{onMore !== undefined && (
<button
type="button"
<IconButton
size="xs"
className="section-more"
title={`查看${title}全部`}
aria-label={`查看${title}全部`}
label={`查看${title}全部`}
icon={<Icon name="more_1_line" />}
onClick={onMore}
>
<MoreIcon />
</button>
/>
)}
</header>
</div>
{children}
</section>
</Card>
)
}
@@ -1,24 +0,0 @@
import type { ReactNode } from 'react'
/**
* 顶栏中间的一条信息,形如「[标签] 文本」。
* 标签带浅色底,用于区分「本地 / 状态」这类类型前缀。
*/
type HeaderInfoItemProps = {
/** 可选的前缀标签 */
tag?: string
/** 标签配色:默认蓝色,状态类用绿色 */
tone?: 'blue' | 'green'
children: ReactNode
}
export function HeaderInfoItem({ tag, tone = 'blue', children }: HeaderInfoItemProps) {
return (
<div className="header-info-item">
{tag !== undefined && (
<span className={`header-info-tag${tone === 'green' ? ' is-green' : ''}`}>{tag}</span>
)}
<span className="header-info-text">{children}</span>
</div>
)
}
+135 -43
View File
@@ -1,58 +1,150 @@
import { SearchIcon, UserIcon } from '../common/Icon'
import { HeaderInfoItem } from './HeaderInfoItem'
import { Brand, Icon, IconButton, Tag, Ticker } from '@stash/ui'
import {
fillHeaderText,
type HeaderItem,
type SettingsTone,
type SiteMetaSettings,
} from '../../settings'
/**
* 顶部栏:左品牌、中间运行信息、右侧操作图标。
* 信息区展示「共 N 个本地应用 / 第一个项目 / 连接状态」,
* 对应模板里的 header-center-info。
* 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
* 品牌与操作图标是否出现,都来自「页头」Tab 的站点设置。
* 外壳(品牌、跑马灯)都用组件库,这里只拼内容。
*/
type SiteHeaderProps = {
projectCount: number
/** 用于展示的第一个项目名,没有项目时留空 */
sampleProject?: string
live: boolean
onFocusSearch: () => void
/** 页头滚动信息;为空时用默认三条 */
headerItems: HeaderItem[]
headerEnabled: boolean
/** 滚动一轮的秒数 */
headerSpeed: number
/** 信息条目之间的间距(px) */
headerGap?: number
/** 品牌与操作工具配置 */
site: SiteMetaSettings
/** 站点设置是否已加载;未加载时不渲染依赖配置的图标,避免先显示再消失 */
ready?: boolean
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */
authenticated: boolean
username?: string
/** 刷新中:刷新按钮转圈 */
refreshing?: boolean
/** 当前是否为深色,决定明暗按钮的图标与文案 */
dark?: boolean
onRefresh: () => void
onToggleTheme: () => void
onLogin: () => void
onLogout: () => void
onSettings: () => void
/** 小屏打开分组导航抽屉;不传则不显示菜单按钮 */
onOpenNav?: () => void
}
export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }: SiteHeaderProps) {
const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = {
plain: 'neutral',
neutral: 'neutral',
primary: 'primary',
success: 'success',
}
export function SiteHeader({
projectCount,
sampleProject,
live,
headerItems,
headerEnabled,
headerSpeed,
headerGap,
site,
ready = true,
authenticated,
username,
refreshing = false,
dark = false,
onRefresh,
onToggleTheme,
onLogin,
onLogout,
onSettings,
onOpenNav,
}: SiteHeaderProps) {
const context = { count: projectCount, project: sampleProject, live }
return (
<header className="site-header">
<div className="layout-container 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>
<div className="site-header-inner">
<Brand
href="/"
name={site.brand.name}
mark={site.brand.mark}
logo={site.brand.logo}
/>
<div className="header-info">
<HeaderInfoItem>共 {projectCount} 个本地应用</HeaderInfoItem>
{sampleProject !== undefined && (
<HeaderInfoItem tag="本地">{sampleProject}</HeaderInfoItem>
)}
<HeaderInfoItem tag="状态" tone={live ? 'green' : 'blue'}>
{live ? '实时同步已连接' : '等待服务连接'}
</HeaderInfoItem>
</div>
<div className="header-actions">
<button
type="button"
className="header-icon-button"
title="搜索(Ctrl+K)"
aria-label="聚焦搜索框"
onClick={onFocusSearch}
>
<SearchIcon />
</button>
{/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */}
<button type="button" className="header-icon-button" title="登录" aria-label="登录">
<UserIcon />
</button>
</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>
</header>
<div className="header-actions">
{onOpenNav !== undefined && (
<IconButton
className="header-nav-toggle"
label="分组导航"
icon={<Icon name="menu_line" />}
onClick={onOpenNav}
/>
)}
{ready && (
<>
{site.actions.theme && (
<IconButton
label={dark ? '切换到浅色' : '切换到深色'}
icon={<Icon name={dark ? '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,58 +0,0 @@
import { useEffect, type RefObject } from 'react'
import { CloseIcon, SearchIcon } from '../common/Icon'
/**
* 全局搜索框:固定悬浮在顶栏下方居中。
* 支持 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-bar card-box">
<SearchIcon className="search-icon" />
<input
ref={inputRef}
className="search-input"
type="text"
value={value}
placeholder="搜索本地项目…"
aria-label="搜索本地项目"
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') onChange('')
}}
/>
{value !== '' && (
<button
type="button"
className="search-clear"
aria-label="清空搜索"
onClick={() => onChange('')}
>
<CloseIcon />
</button>
)}
</div>
)
}
@@ -1,98 +0,0 @@
import { useState } from 'react'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
/**
* 分类导航树:直接由内容树派生,不再读独立的导航数据。
*
* - 有分组的分类(apps)渲染成可折叠节点,子项是分组(分组可用 show_in_sidebar 单独隐藏);
* - 没有分组、或分组全被隐藏的分类,直接是链接。
*/
type CategoryNavProps = {
/** 内容目录;顺序即侧栏顺序 */
categories: Category[]
/** 当前明细视图的分类 / 分组,用于高亮 */
activeCategoryId?: string
activeGroupId?: string
onNavigate: (target: NavTarget) => void
}
export function CategoryNav({
categories,
activeCategoryId,
activeGroupId,
onNavigate,
}: CategoryNavProps) {
const [openGroups, setOpenGroups] = useState<string[]>([])
const toggle = (id: string) => {
setOpenGroups((previous) =>
previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id],
)
}
const isOpen = (id: string) => openGroups.includes(id)
return (
<nav className="nav-card card-box" aria-label="站点分类">
<ul className="nav-group">
{categories.map((category) => {
// 只显示允许出现在侧栏的分组
const groups =
category.kind === 'apps'
? category.groups.filter((group) => group.showInSidebar !== false)
: []
// 没有分组的分类:整行就是一个链接
if (groups.length === 0) {
return (
<li className="nav-item" key={category.id}>
<button
type="button"
className={`nav-link${
activeCategoryId === category.id && activeGroupId === undefined ? ' active' : ''
}`}
onClick={() => onNavigate({ categoryId: category.id })}
>
<span>{category.title}</span>
</button>
</li>
)
}
// 有分组:分区可折叠,分组是真正的链接
return (
<li className={`nav-item${isOpen(category.id) ? ' open' : ''}`} key={category.id}>
<button
type="button"
className="nav-link has-children"
aria-expanded={isOpen(category.id)}
onClick={() => toggle(category.id)}
>
<span>{category.title}</span>
</button>
{/* 用 grid-template-rows 做高度动画:不需要猜 max-height,
也不会在过渡期间出现滚动条 */}
<div className={`submenu-wrap${isOpen(category.id) ? ' is-open' : ''}`}>
<ul className="submenu">
{groups.map((group) => (
<li key={group.id}>
<button
type="button"
className={`nav-link${activeGroupId === group.id ? ' active' : ''}`}
onClick={() => onNavigate({ categoryId: category.id, groupId: group.id })}
>
<span>{group.name}</span>
</button>
</li>
))}
</ul>
</div>
</li>
)
})}
</ul>
</nav>
)
}
@@ -1,24 +1,34 @@
import { PLACEHOLDER_PROFILE } from '../../profile'
import { UserIcon } from '../common/Icon'
import { Avatar, Card, Icon, Text, Title } from '@stash/ui'
import type { ProfileSettings } from '../../settings'
/**
* 用户资料卡:头像、认证徽标、昵称、角色、简介、IP 属地。
* 数据来自 profile.ts 的占位对象,后端接入鉴权后替换。
* 内容来自站点设置(/admin → 资料),未设置时用默认文案。
*/
export function ProfileCard() {
const { name, role, bio, location } = PLACEHOLDER_PROFILE
type ProfileCardProps = {
profile: ProfileSettings
}
export function ProfileCard({ profile }: ProfileCardProps) {
return (
<section className="profile-card card-box" aria-label="用户信息">
<div className="profile-avatar" aria-hidden="true">
<UserIcon />
<span className="profile-badge">✓</span>
</div>
<Card padding="none" className="profile-card">
{profile.avatar !== '' ? (
<Avatar size="xl" src={profile.avatar} alt={profile.name} verified />
) : (
<Avatar size="xl" tone="primary" icon={<Icon name="user_3_line" />} verified />
)}
<div className="profile-name">{name}</div>
<span className="profile-role">{role}</span>
<p className="profile-bio">{bio}</p>
<div className="profile-location">IP属地:{location}</div>
</section>
<Title level={4} weight="semibold" className="profile-name">
{profile.name}
</Title>
<span className="profile-role">{profile.role}</span>
<Text size="xs" tone="sub" className="profile-bio">
{profile.description}
</Text>
<span className="profile-location">{profile.location}</span>
</Card>
)
}
@@ -1,5 +1,5 @@
import { Button, Card, Icon, Space, Tag, Text, Title } from '@stash/ui'
import type { Project } from '../../api'
import { LinkIcon, RefreshIcon } from '../common/Icon'
/**
* 本地服务面板:连接状态、项目数、访问地址,以及两个操作入口。
@@ -10,56 +10,78 @@ 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 (
<section className="service-panel card-box" aria-label="本地服务状态">
<header className="service-head">
<h2 className="service-title">本地服务</h2>
<span className={`service-pill${live ? ' is-on' : ''}`}>
<span className={`status-dot${live ? ' is-on' : ''}`} aria-hidden="true" />
<Card padding="none" className="service-panel">
<Space justify="between">
<Title level={4} as="h2" weight="semibold">
本地服务
</Title>
<Tag tone={live ? 'success' : 'neutral'} dot className="service-status">
{live ? '已连接' : '未连接'}
</span>
</header>
</Tag>
</Space>
<dl className="service-stats">
<div>
<dt>项目数</dt>
<dd>{projects.length}</dd>
</div>
<div>
<dt>访问地址</dt>
<dd title={host}>{host}</dd>
</div>
</dl>
<div className="service-stats">
<Space justify="between">
<Text size="xs" tone="sub">
项目数
</Text>
<Text size="xs" strong>{projects.length}</Text>
</Space>
<Space justify="between">
<Text size="xs" tone="sub">
访问地址
</Text>
<Text size="xs" truncate title={host} className="service-host">
{host}
</Text>
</Space>
</div>
<div className="service-actions">
<button
type="button"
<Button
size="sm"
variant="secondary"
className="service-action"
title="重新扫描项目"
data-spinning={refreshing}
disabled={refreshing}
spinning={refreshing}
icon={<Icon name="refresh_3_line" />}
onClick={onRefresh}
title="重新扫描项目"
>
<RefreshIcon />
<span>重新扫描</span>
</button>
重新扫描
</Button>
<a
<Button
size="sm"
variant="secondary"
className="service-action"
href="/api/projects"
target="_blank"
rel="noreferrer"
title="查看项目接口"
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="查看接口一览"
>
<LinkIcon />
<span>查看接口</span>
</a>
查看接口
</Button>
</div>
</section>
</Card>
)
}
@@ -1,53 +0,0 @@
import type { Project } from '../../api'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
import { CategoryNav } from './CategoryNav'
import { ProfileCard } from './ProfileCard'
import { ServicePanel } from './ServicePanel'
/**
* 左侧栏容器:用户资料 + 本地服务面板 + 分类导航。
* 只负责布局与数据透传,具体内容由三个子组件分别渲染。
*/
type SidebarProps = {
projects: Project[]
/** 内容目录;侧栏直接由它派生 */
categories: Category[]
live: boolean
refreshing: boolean
onRefresh: () => void
activeCategoryId?: string
activeGroupId?: string
onNavigate: (target: NavTarget) => void
}
export function Sidebar({
projects,
categories,
live,
refreshing,
onRefresh,
activeCategoryId,
activeGroupId,
onNavigate,
}: SidebarProps) {
return (
<aside className="sidebar">
<ProfileCard />
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={onRefresh}
/>
<CategoryNav
categories={categories}
activeCategoryId={activeCategoryId}
activeGroupId={activeGroupId}
onNavigate={onNavigate}
/>
</aside>
)
}
+36 -2
View File
@@ -28,8 +28,7 @@ export type ItemDetailModel = {
}
/** 把应用条目转成详情模型 */
export function appDetailModel(item: AppItem): ItemDetailModel {
return {
export function appDetailModel(item: AppItem): ItemDetailModel { return {
title: item.name,
cover: item.cover,
shape: 'square',
@@ -73,3 +72,38 @@ export function posterToRelatedItem(item: PosterItem): RelatedItem {
cover: item.cover,
}
}
/**
* 把库里的 `href` 解析成真正可跳转的地址。
*
* 兼容历史数据里没写协议的裸域名(`www.zhihu.com` → `https://…`);
* 空值、`#` 与不安全/看不懂的协议返回 `undefined`,由页面把按钮置灰。
*/
export function resolveHref(raw: string | undefined): string | undefined {
const value = raw?.trim() ?? ''
if (value === '' || value === '#') return undefined
// 站内路径;`//host` 是协议相对地址,按外链补 https
if (value.startsWith('/') && !value.startsWith('//')) return value
if (/^https?:\/\//i.test(value)) return value
if (value.startsWith('//')) return `https:${value}`
// 其他协议(javascript: / data: / mailto: …)一律不当作跳转
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return undefined
return looksLikeDomain(value) ? `https://${value}` : undefined
}
function looksLikeDomain(value: string): boolean {
const [hostPort = ''] = value.split('/')
const [host = ''] = hostPort.split(':')
if (!host.includes('.')) return false
const parts = host.split('.')
const tld = parts.pop() ?? ''
return (
tld.length >= 2 &&
/^[a-z]+$/i.test(tld) &&
parts.every((part) => /^[a-z0-9-]+$/i.test(part))
)
}
-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 -142
View File
@@ -1,8 +1,5 @@
/**
* 内容数据模型。
*
* 首页把每个「分类」渲染成一张卡片,明细页按分类的 kind 渲染不同网格,
* 因此这里用可辨识联合把三种展示形态分开。
* 内容数据模型:应用 / 海报 / 图文三种条目,以及详情页的推荐条目。
*/
/** 一个可点击的应用 / 站点条目 */
@@ -30,15 +27,6 @@ export type AppItem = {
likes?: number
}
/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */
export type AppGroup = {
id: string
name: string
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
items: AppItem[]
}
/** 竖版海报条目(电影) */
export type PosterItem = {
id: string
@@ -77,132 +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[]
/** 首页预览条数,默认 4 */
previewLimit?: number
/** 首页预览为空时的提示文案 */
emptyText?: string
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
/** 海报类分类:竖版海报网格 */
export type PosterCategory = {
id: string
kind: 'posters'
title: string
barColor: string
icon: string
items: PosterItem[]
/** 首页预览条数,默认 6 */
previewLimit?: number
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
/** 图文类分类:上图下文卡片网格 */
export type ArticleCategory = {
id: string
kind: 'articles'
title: string
barColor: string
icon: string
items: ArticleItem[]
/** 首页预览条数,默认 4 */
previewLimit?: number
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
export type Category = AppCategory | PosterCategory | ArticleCategory
/** 分类内的条目总数,用于角标与明细页计数 */
export function categoryCount(category: Category): number {
if (category.kind === 'apps') {
return category.groups.reduce((total, group) => total + group.items.length, 0)
}
return category.items.length
}
/** 把应用类分类的分组拍平,首页预览用 */
export function flattenAppItems(category: AppCategory): AppItem[] {
return category.groups.flatMap((group) => group.items)
}
/** 按 id 在分类里查找条目;详情页用 */
export function findAppItem(category: Category, itemId: string): AppItem | undefined {
if (category.kind !== 'apps') return undefined
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
return category.items.find((item) => item.id === itemId)
}
/** 取同分类下的其他海报,用于详情页的「推荐阅读」 */
export function relatedPosterItems(
category: Category,
excludeId: string | undefined,
limit = 4,
): PosterItem[] {
if (category.kind !== 'posters') return []
return category.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
return category.items.find((item) => item.id === itemId)
}
+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
}
}
-74
View File
@@ -1,74 +0,0 @@
/**
* 图标字体:取自 Alright 主题自带的 iconfont(Project id 4809463)。
* 字体文件放在 public/fonts/iconfont.woff2,这里只保留项目用到的字形,
* 避免把整份 500 多个图标的样式表打进包里。
*/
@font-face {
font-family: 'iconfont';
src: url('./assets/iconfont.woff2') format('woff2');
font-display: swap;
}
.iconfont {
display: inline-block;
font-family: 'iconfont' !important;
font-style: normal;
font-weight: normal;
line-height: 1;
text-rendering: auto;
speak: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-search::before {
content: '\e942';
}
.icon-user::before {
content: '\e966';
}
.icon-refresh::before {
content: '\e93d';
}
.icon-link::before {
content: '\ea37';
}
.icon-external::before {
content: '\e8be';
}
.icon-eye::before {
content: '\e8fd';
}
.icon-thumb-up::before {
content: '\e957';
}
.icon-share::before {
content: '\e946';
}
.icon-close::before {
content: '\e8e4';
}
.icon-more::before {
content: '\e91f';
}
.icon-arrow-up::before {
content: '\e8c1';
}
.icon-arrow-left::before {
content: '\e8b5';
}
.icon-arrow-down::before {
content: '\e8b7';
}
+28 -62
View File
@@ -1,49 +1,20 @@
/* 页面级基础:字号 / 字体 / 重置。颜色与间距全部来自 @stash/ui 的令牌。 */
:root {
/* ---------- 颜色:取自 Alright 主题的设计变量 ---------- */
--bg-color: #f7f8fa;
--card-bg: #ffffff;
--primary-color: #3858f6;
--primary-soft: rgba(56, 88, 246, 0.1);
--text-main: #1d2129;
--text-sub: #4e5969;
--text-muted: #86909c;
--border-color: #f6f6f6;
--border-strong: #eeeeee;
--danger: #ff3e3e;
--orange: #ffba3a;
--green: #4abd86;
--radius-sm: 6px;
--radius-main: 12px;
--radius-lg: 24px;
/* 主题的阴影很轻:0 6px 16px rgba(27,26,49,2%) */
--shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
--shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06);
--sidebar-width: 240px;
--top-nav-height: 60px;
/* 内容区最大宽度,宽屏时整体居中 */
--layout-max-width: 1380px;
/* 内容列顶部让位高度(给悬浮搜索框),侧栏首卡与内容首卡都按它对齐 */
--content-top-offset: 52px;
/* 主题正文用霞鹜文楷(与演示站同一份 webfont),缺失时回退系统字体 */
--font-sans:
'LXGW WenKai', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans CN', sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace;
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
color: var(--text-main);
background-color: var(--bg-color);
font-family: var(--su-font-sans);
font-size: var(--su-font-size-md);
line-height: var(--su-line-height-md);
color: var(--su-text-main);
background-color: var(--su-bg-page);
color-scheme: light;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html[data-scheme='dark'] {
color-scheme: dark;
}
* {
box-sizing: border-box;
margin: 0;
@@ -53,15 +24,7 @@
html,
body,
#root {
height: 100%;
}
/* 桌面端锁死视口,滚动交给右侧内容区 */
body {
height: 100vh;
overflow: hidden;
background-color: var(--bg-color);
color: var(--text-main);
min-height: 100%;
}
a {
@@ -76,20 +39,23 @@ button {
background: none;
}
:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
ul,
ol {
list-style: none;
}
@media (max-width: 900px) {
html,
body,
#root {
height: auto;
}
/* 键盘焦点环由组件库统一提供;这里只给「非表单控件」补兜底。
表单控件不能加全局 outline:输入框点击时也会命中 :focus-visible,
会和组件自己的边框态叠成「双框」(用户实际报过的 bug)。 */
a:focus-visible,
button:focus-visible,
[tabindex]:not(input):focus-visible {
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
body {
height: auto;
overflow: auto;
}
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: none;
}
+126
View File
@@ -0,0 +1,126 @@
/**
* 条目编辑的前端接口。
*
* 读取是公开的(编辑表单要能预览),写入必须登录,并用 `updatedAt` 做乐观锁。
*/
export type EntryFormat = 'apps' | 'posters' | 'articles'
export type EntryRecord = {
key: string
format: EntryFormat
name: string
description: string
icon: string
href: string
cover: string
banner: string
badge: string
comment: string
summary: string
body: string
tags: string[]
updated_at: number
}
export type EntryInput = {
name: string
/** 条目的展示格式;不传由后端按 apps 处理 */
format?: EntryFormat
description: string
icon: string
tags: string[]
badge: string
comment: string
summary: string
body: string
cover: string
banner: string
href: string
/** 编辑时传目标分区/分组,可移动条目 */
nodeKey?: string
}
export type ItemResult =
| { ok: true; entry: EntryRecord }
| { ok: false; status: number; error: string }
export async function fetchItem(key: string): Promise<ItemResult> {
try {
const response = await fetch(`/api/items/${encodeURIComponent(key)}`, {
credentials: 'same-origin',
})
if (response.ok) {
return { ok: true, entry: (await response.json()) as EntryRecord }
}
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, status: response.status, error: payload?.error ?? '读取失败' }
} catch (error) {
return { ok: false, status: 0, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function saveItem(
key: string,
updatedAt: number,
item: EntryInput,
): Promise<ItemResult> {
try {
const response = await fetch(`/api/items/${encodeURIComponent(key)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ updatedAt, item }),
})
if (response.ok) {
return { ok: true, entry: (await response.json()) as EntryRecord }
}
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return {
ok: false,
status: response.status,
error: payload?.error ?? `保存失败(${response.status})`,
}
} catch (error) {
return { ok: false, status: 0, error: error instanceof Error ? error.message : '网络错误' }
}
}
/** 新建条目:指定分区 / 分组与字段 */
export async function createItem(input: {
key: string
nodeKey: string
item: EntryInput
}): Promise<SimpleResult> {
try {
const response = await fetch('/api/items', {
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 deleteItem(key: string): Promise<SimpleResult> {
try {
const response = await fetch(`/api/items/${encodeURIComponent(key)}`, {
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 SimpleResult = { ok: true } | { ok: false; error: string }
+22 -6
View File
@@ -1,11 +1,27 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './iconfont.css'
import '@stash/ui/style.css'
import './index.css'
import App from './App.tsx'
import AppNext from './AppNext.tsx'
import { AdminNext } from './components/admin/AdminNext'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
/**
* 入口分流:
* - `/admin`:设置页独立壳;
* - `/login`:老地址,直接回首页(登录已改为页内弹窗);
* - 其它:正常站点。
*
* 判定放在这里而不是 App 里,是因为 App 的 `useViewRoute` 会把地址规范化,
* 组件内部再读 pathname 已经晚了。
*/
const path = window.location.pathname
if (path === '/login') {
window.location.replace('/')
}
const root = createRoot(document.getElementById('root')!)
root.render(
<StrictMode>{path === '/admin' ? <AdminNext /> : <AppNext />}</StrictMode>,
)
+201
View File
@@ -0,0 +1,201 @@
/**
* 原子模型的后台接口:分组树 + 卡片。
*
* 分组是唯一容器,卡片只是「勾选分组」的展示组合;两者都通过业务键关联。
*/
export type GroupFormat = 'apps' | 'posters' | 'articles'
export type GroupEntry = {
id: string
format?: GroupFormat
name?: string
title?: string
href?: string
description?: string
desc?: string
}
export type GroupNode = {
key: string
parentKey?: string | null
title: string
icon: string
barColor: string
previewLimit?: number | null
sort: number
/** 是否启用;禁用后前台不展示 */
enabled: boolean
entries: GroupEntry[]
children: GroupNode[]
}
export type CardGroupRef = {
groupKey: string
previewLimit?: number | null
}
export type CardItem = {
id: number
key: string
title: string
sort: number
groups: CardGroupRef[]
}
export type ManageData = {
groups: GroupNode[]
cards: CardItem[]
}
export type SimpleResult = { ok: true } | { ok: false; error: string }
async function errorMessage(response: Response, fallback: string): Promise<string> {
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return payload?.error ?? `${fallback}(${response.status})`
}
export async function fetchManage(): Promise<ManageData> {
try {
const response = await fetch('/api/catalog2', { credentials: 'same-origin' })
if (!response.ok) return { groups: [], cards: [] }
return (await response.json()) as ManageData
} catch {
return { groups: [], cards: [] }
}
}
export async function createNode(input: {
key: string
title: string
parentKey?: string
format?: GroupFormat
icon?: string
sort?: number
enabled?: boolean
}): Promise<SimpleResult> {
try {
const response = await fetch('/api/nodes', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '创建失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function updateNode(
key: string,
patch: {
title?: string
parentKey?: string
sort?: number
format?: GroupFormat
icon?: string
enabled?: boolean
},
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/nodes/${encodeURIComponent(key)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '保存失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function deleteNode(key: string): Promise<SimpleResult> {
try {
const response = await fetch(`/api/nodes/${encodeURIComponent(key)}`, {
method: 'DELETE',
credentials: 'same-origin',
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '删除失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function createCard(title: string): Promise<{ ok: true; id: number } | { ok: false; error: string }> {
try {
const response = await fetch('/api/cards', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!response.ok) return { ok: false, error: await errorMessage(response, '创建失败') }
const payload = (await response.json()) as { id: number }
return { ok: true, id: payload.id }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function updateCard(
id: number,
patch: { title?: string; sort?: number },
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/cards/${id}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '保存失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function deleteCard(id: number): Promise<SimpleResult> {
try {
const response = await fetch(`/api/cards/${id}`, {
method: 'DELETE',
credentials: 'same-origin',
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '删除失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function setCardGroups(
id: number,
groups: CardGroupRef[],
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/cards/${id}/groups`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(groups),
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '保存失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
/** 把分组树拍平成下拉选项 */
export function flattenGroups(
groups: GroupNode[],
depth = 0,
): { key: string; title: string; depth: number; enabled: boolean }[] {
return groups.flatMap((group) => [
{
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: '局域网',
}
+314
View File
@@ -0,0 +1,314 @@
/**
* 站点设置:页头滚动信息 + 侧栏卡片显隐。
*
* 接口是公开读、登录后写;写的时候带 `version` 做乐观锁,
* 冲突(多标签页同时改)时返回 409,页面提示刷新。
*/
export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral'
export type HeaderItem = {
text: string
tag?: string | null
tone: SettingsTone
}
export type ProfileSettings = {
name: string
role: string
description: string
/** 完整文案,如「IP属地:局域网」 */
location: string
/** 头像地址;空表示用图标占位 */
avatar: string
}
export type BackgroundSettings = {
/** 亮色背景图地址;空表示使用内置图 */
light: string
/** 暗色背景图地址;空表示使用内置图 */
dark: string
}
export type FontSource = 'builtin' | 'custom'
/** 一条存下来的字体配置:名字只用于后台下拉,url 才是真正加载的东西 */
export type FontLink = {
key: string
name: string
url: string
/** 字体族名:CSS 链接时填 CSS 里声明的名字;字体文件留空则用 Stash Font */
family: string
}
export type FontSettings = {
/** 字体来源:内置霞鹜文楷,或下面保存的字体链接 */
source: FontSource
/** 当前启用的 FontLink.key;为空时用第一条 */
current: string
/** 存下来的字体:常用字体放这里,切换时不用重新填 */
items: FontLink[]
}
const BACKGROUND_CACHE_KEY = 'stash-background'
/**
* 上次拿到的背景地址。
*
* 设置要等 `/api/settings` 回来,刷新时首帧只能先画内置背景,自定义背景随后才换上来,
* 会闪一下。先用上次缓存上屏,等接口回来再校准。
*/
export function readCachedBackground(): BackgroundSettings | null {
try {
const raw = window.localStorage.getItem(BACKGROUND_CACHE_KEY)
if (raw === null) return null
const value = JSON.parse(raw) as Partial<BackgroundSettings>
if (typeof value.light !== 'string' || typeof value.dark !== 'string') return null
return { light: value.light, dark: value.dark }
} catch {
return null
}
}
export function writeCachedBackground(background: BackgroundSettings): void {
try {
window.localStorage.setItem(BACKGROUND_CACHE_KEY, JSON.stringify(background))
} catch {
// 隐私模式下 localStorage 不可写,忽略即可
}
}
export type SearchEngineSettings = {
/** 内置引擎用固定 id(对应品牌图标);自定义引擎由后台生成 custom-xxx */
id: string
name: string
/** 搜索地址模板,用 {query} 表示关键词 */
url: string
/** 自定义图标地址(png / svg);空表示用品牌图标或通用地球 */
icon: string
enabled: boolean
}
export type SearchSettings = {
/** `internal` 或某个引擎 id */
default: string
engines: SearchEngineSettings[]
}
/** 首次启动 / 恢复默认时的搜索引擎清单 */
export const DEFAULT_SEARCH_ENGINES: SearchEngineSettings[] = [
{
id: 'google',
name: 'Google',
url: 'https://www.google.com/search?q={query}',
icon: '',
enabled: true,
},
{
id: 'bing',
name: 'Bing',
url: 'https://www.bing.com/search?q={query}',
icon: '',
enabled: true,
},
{
id: 'baidu',
name: '百度',
url: 'https://www.baidu.com/s?wd={query}',
icon: '',
enabled: true,
},
{
id: 'sogou',
name: '搜狗',
url: 'https://www.sogou.com/web?query={query}',
icon: '',
enabled: true,
},
{
id: '360',
name: '360',
url: 'https://www.so.com/s?q={query}',
icon: '',
enabled: true,
},
{
id: 'duckduckgo',
name: 'DuckDuckGo',
url: 'https://duckduckgo.com/?q={query}',
icon: '',
enabled: true,
},
]
/** 内置引擎:有品牌图标、后台不能删除(只能改名 / 改地址 / 停用) */
export const BUILTIN_SEARCH_ENGINE_IDS = [
'google',
'bing',
'baidu',
'sogou',
'360',
'duckduckgo',
] as const
export type SiteSettings = {
header: {
enabled: boolean
/** 滚动一轮的秒数(8–120) */
speed: number
/** 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽 */
gap: number
items: HeaderItem[]
}
sidebar: {
/** 是否显示「本地管理员」资料卡 */
show_profile: boolean
/** 是否显示「本地服务」面板 */
show_service: boolean
/** 是否显示「分组导航」卡片(首页 / 分组树) */
show_nav: boolean
}
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 = {
version: number
settings: SiteSettings
}
export type SettingsResult =
| { ok: true; document: SettingsDocument }
| { ok: false; status: number; error: string }
export const TONE_OPTIONS: { value: SettingsTone; label: string }[] = [
{ value: 'plain', label: '无底色' },
{ value: 'neutral', label: '灰色' },
{ value: 'primary', label: '品牌色' },
{ value: 'success', label: '成功绿' },
]
export function defaultSettings(): SiteSettings {
return {
header: {
enabled: true,
speed: 24,
gap: 26,
items: [
{ text: '共 {count} 个本地应用', tone: 'plain' },
{ text: '{project}', tag: '本地', tone: 'primary' },
{ text: '{status}', tag: '状态', tone: 'success' },
],
},
sidebar: {
show_profile: true,
show_service: true,
show_nav: true,
},
background: { light: '', dark: '' },
font: { source: 'builtin', current: '', items: [] },
search: {
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
},
profile: {
name: '本地管理员',
role: '管理员',
description: '本地静态站点托管与预览,所有项目都运行在这台机器上。',
location: 'IP属地:局域网',
avatar: '',
},
site: {
title: 'Stash · 本地应用工作区',
favicon: '',
theme: 'auto',
brand: { name: 'STASH', mark: 'S', logo: '' },
actions: { theme: true, refresh: true, settings: true, session: true },
},
}
}
/** 占位符替换:`{count}` / `{project}` / `{status}` */
export function fillHeaderText(
text: string,
context: { count: number; project?: string; live: boolean },
): string {
return text
.replaceAll('{count}', String(context.count))
.replaceAll('{project}', context.project ?? '—')
.replaceAll('{status}', context.live ? '实时同步已连接' : '等待服务连接')
}
export async function fetchSettings(): Promise<SettingsDocument> {
try {
const response = await fetch('/api/settings', { credentials: 'same-origin' })
if (!response.ok) throw new Error(String(response.status))
return (await response.json()) as SettingsDocument
} catch {
return { version: 0, settings: defaultSettings() }
}
}
export async function saveSettings(
version: number,
settings: SiteSettings,
): Promise<SettingsResult> {
try {
const response = await fetch('/api/settings', {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ version, settings }),
})
if (response.ok) {
return { ok: true, document: (await response.json()) as SettingsDocument }
}
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return {
ok: false,
status: response.status,
error: payload?.error ?? `保存失败(${response.status})`,
}
} catch (error) {
return {
ok: false,
status: 0,
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 }
}
+4
View File
@@ -6,6 +6,10 @@ const BACKEND = 'http://127.0.0.1:8080'
// 前后端分离开发:dev / preview 都把 /api 透传给本地 Rust 服务
export default defineConfig({
plugins: [react()],
// @stash/ui 是本地 file 依赖(软链),去重 React,避免加载两份运行时
resolve: {
dedupe: ['react', 'react-dom'],
},
server: {
port: 5173,
proxy: {
-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>