55 Commits
Author SHA1 Message Date
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
CNWei 19ead7b716 feat(web): 地址栏驱动视图,刷新详情页不再回首页
- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退
- 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点
- 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404
- 明细页返回按钮去掉左侧箭头图标
2026-10-24 17:18:30 +08:00
CNWei 41fed5e64d feat(db): 内容库迁入 SQLite,分类与侧栏导航改为数据库驱动 2026-10-23 20:22:23 +08:00
CNWei 386c0f77c7 feat(web): 对齐 Alright 主题视觉规范(配色/字体/图标/背景/动效),并补充设计规范文档 2026-10-22 20:27:10 +08:00
85 changed files with 20817 additions and 1617 deletions
+27 -1
View File
@@ -1,2 +1,28 @@
# ---------- Rust ----------
/target
.idea
# ---------- 前端 ----------
# 依赖与构建产物:dist 由 npm run build 生成,再被 build.rs 内嵌进二进制
node_modules/
stash-web/dist/
stash-web/dist-ssr/
*.tsbuildinfo
.vite/
# ---------- 生成的代码快照 ----------
repomix-output.xml
# ---------- IDE / 编辑器 ----------
/.idea
*.iml
.vscode/
# ---------- 运行时数据 ----------
# 内容库属于用户数据,不进版本库(默认生成在二进制同级,即 target/ 下)
/data
# ---------- 日志与系统文件 ----------
*.log
.DS_Store
Thumbs.db
*.local
+328
View File
@@ -0,0 +1,328 @@
# Alright 主题设计规范(Stash 前端)
本文件记录 Stash 控制台在视觉上对齐 [Alright 主题](https://alright.demo.nicetheme.xyz/) 的各项标准,
用于后续新增组件、调整样式时保持一致。
- 参考站点:<https://alright.demo.nicetheme.xyz/>
- 生效范围:`stash-web/`(React 控制台)
- 令牌定义处:[`stash-web/src/index.css`](stash-web/src/index.css)
- 组件样式:[`stash-web/src/App.css`](stash-web/src/App.css)
> 所有色值、字号、圆角均取自主题的 `:root` 设计变量,不是肉眼取色。
---
## 1. 设计令牌
### 1.1 灰阶
| 令牌 | 值 | 用途 |
| --- | --- | --- |
| `--gray-1` | `#f7f8fa` | 页面背景、浅底块 |
| `--gray-2` | `#f6f6f6` | 边框、次级浅底 |
| `--gray-3` | `#eeeeee` | hover 底色 |
| `--gray-4` | `#c9cdd4` | 分隔线、禁用态 |
| `--gray-5` | `#86909c` | 说明文字 |
| `--gray-6` | `#4e5969` | 次级文字 |
| `--gray-7` | `#2c343c` | 强调文字 |
| `--gray-8` | `#1d2129` | 主文字 |
### 1.2 语义色
| 令牌 | 值 | 用途 |
| --- | --- | --- |
| `--color-primary` | `#3858f6` | 主色:链接、选中态、色条、图标 |
| `--color-primary` 10% | `rgba(56, 88, 246, 0.1)` | 主色浅底(`--primary-soft`) |
| `--color-danger` | `#ff3e3e` | 数量角标、错误提示 |
| `--color-warning` | `#ffba3a` | 橙色强调(CES 色条) |
| `--color-success` | `#4abd86` | 在线状态 |
| 渐变红 | `linear-gradient(to right, #ff6034, #ff3e3e)` | 热门标记 |
| HOT 渐变 | `#ff7b51 → #ff0e0e` | HOT 图形徽标 |
| 认证渐变 | `#ffb74a → #fd900f` | 头像认证角标 |
### 1.3 圆角
| 令牌 | 值 | 用途 |
| --- | --- | --- |
| `--radius-xs` | `4px` | 小标签、徽标 |
| `--radius-sm` | `6px` | 小组件 |
| `--radius-main`(md) | `12px` | 卡片、面板 |
| `--radius-lg` | `24px` | 大容器 |
| `max` | `999px` | 胶囊、圆形 |
### 1.4 阴影
主题的阴影非常克制,只有两层:
```css
--shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02); /* 常态 */
--shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06); /* 悬停 */
```
**注意**:应用条目、作者列表这类「行」不应该有阴影或描边。
### 1.5 字号阶梯
| 令牌 | 字号 | 行高 | 用途 |
| --- | --- | --- | --- |
| `xs` | 10px | 14px | 角标 |
| `sm` | 12px | 16px | 描述、元信息、HOT 旁注 |
| `md` | 14px | 20px | 正文、按钮、导航 |
| `lg` | 16px | 24px | 分区标题、条目标题 |
| `xl` | 24px | — | 详情页大标题 |
字重:主题的「加粗」是 `500`,不是 700。
### 1.6 动效时长
| 令牌 | 值 | 用途 |
| --- | --- | --- |
| `base` | 0.3s | 展开/收起、淡入淡出 |
| `fast` | 0.2s | 颜色、背景变化 |
| enter | `ease-out` | 出现 |
| leave | `ease-in` | 消失 |
---
## 2. 字体
| 角色 | 字体 | 说明 |
| --- | --- | --- |
| 正文 | **LXGW WenKai(霞鹜文楷)** | 与演示站同一份 webfont,`cdn.staticfile.net` |
| 回退 | `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif` | 字体加载失败时 |
| 数字(主题) | `Outfit-Medium` | 仅主题的 `font-price` / `font-number` 使用,Stash 未引入 |
| 等宽 | `ui-monospace, Consolas, monospace` | 路由地址等路径文本 |
引入位置:[`stash-web/index.html`](stash-web/index.html) 的 `<link>`,
CSS 变量 `--font-sans` / `--font-mono` 定义在 `index.css`。
---
## 3. 图标
### 3.1 图标字体
主题使用自建 iconfont(线性风格,Project id 4809463)。
Stash 自托管了一份 **只含所需字形** 的子集:
- 字体文件:[`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;
}
```
---
## 4. 布局
### 4.1 整体结构
```
┌──────────────────────────────────────────────┐
│ SiteHeader(固定 60px,白底、底部 1px 分隔) │
├──────────────────────────────────────────────┤
│ 搜索框(固定,480×44,居中) │
│ ┌──────────┬─────────────────────────────┐ │
│ │ Sidebar │ 内容区(内部滚动) │ │
│ │ 240px │ 分区卡片 / 明细页 │ │
│ └──────────┴─────────────────────────────┘ │
└──────────────────────────────────────────────┘
```
| 项 | 值 | 说明 |
| --- | --- | --- |
| 版心宽度 | `1380px` | `--layout-max-width`,超宽屏居中 |
| 顶栏高度 | `60px` | `--top-nav-height` |
| 侧栏宽度 | `240px` | `--sidebar-width`(主题为 260px,Stash 收窄) |
| 栅格间距 | `20px` | 侧栏与内容区之间 |
| 内容区顶部让位 | `52px` | `--content-top-offset`,给悬浮搜索框 |
| 搜索框 | `480 × 44`,距顶 `76px` | 相对整页居中 |
### 4.2 页面背景
主题在 body 上叠了一层固定背景图:
```css
.site-background {
position: fixed; inset: 0 auto auto 0;
width: 100%; height: 65vh;
background: url('./assets/site-bg.jpg') top center / cover no-repeat;
pointer-events: none;
}
.site-background::after { /* 底部 80% 渐隐到页面底色 */
content: ''; position: absolute; inset: auto 0 0 0; height: 80%;
background-image: linear-gradient(0deg, var(--bg-color) 0%, transparent 100%);
}
```
滚动时背景不动,形成轻微视差。
---
## 5. 组件规范
### 5.1 分区卡片(首页每一块)
| 属性 | 值 |
| --- | --- |
| 容器 | 白底、`1px solid var(--border-color)`、圆角 12、内边距 20、`var(--shadow-card)` |
| 标题色条 | `4 × 16px`,圆角 2,分类色(本地/推荐 `#3858f6`、电影 `#ff6034`、CES `#ffba3a`) |
| 标题 | 16px / 500 |
| 数量角标 | 红色 `#ff3e3e`、10px 字号、圆角 8、贴在标题右上角(`top:-8px; right:-18px`) |
| 更多按钮 | `.icon-more`,16px,右对齐 |
### 5.2 应用条目(对齐主题「推荐作者」)
对应标记:
```html
<a class="item">
<div class="flex-avatar">
<img class="avatar-48">
<span class="avatar-badge badge_5 w-10"></span>
</div>
<div class="item-content">
<h3 class="font-title text-md">名字 <span class="item-badge hot"></span></h3>
<div class="text-xs text-muted h-1x">描述</div>
</div>
</a>
```
| 元素 | 规格 |
| --- | --- |
| 行 | `display:flex; gap:15px`,**无底色、无描边、无阴影** |
| 头像 | 48×48,`border-radius:100%`,图片 `object-fit:cover` |
| 认证角标 | 10×10,绝对定位右下角,内联 `badge_5` 的 SVG |
| 名字 | 16px / 行高 24px / 500 |
| HOT 徽标 | 24×16 的**图片**(内联主题 `--hot-icon` SVG),不是文字 |
| 描述 | 12px / 行高 16px / `--text-muted`,单行省略 |
| 悬停 | 名字变主色,不加阴影 |
> HOT 与认证角标只出现在 `hot: true` 的条目上,不是每个都有。
### 5.3 海报 / 图文
| 网格 | 列数 | 间距 | 比例 |
| --- | --- | --- | --- |
| 应用 | 4(≤1100px 变 2) | 12px | — |
| 电影海报 | 6(≤1100px 变 3) | 12px | 2:3 |
| 首页图文 | 4(≤1100px 变 2) | 12px | 4:3 |
| 明细页图文白卡 | 4(≤1100px 变 2) | 16px | 封面 16:10 |
封面下方统一用「黑色渐变 → 透明」遮罩压住白色标题:
```css
background: linear-gradient(to top, rgba(0,0,0,.85) 0%, rgba(0,0,0,.4) 60%, transparent 100%);
```
### 5.4 侧栏
| 模块 | 规格 |
| --- | --- |
| 用户卡 | 64px 圆形头像 + 认证角标、14px 名字、11–12px 角色/简介/属地 |
| 本地服务面板 | 标题 14px、状态胶囊、两行统计(`dt` 12px / `dd` 12px 等宽)、两个操作按钮 |
| 分类导航 | 行高 `9px 12px` 内边距、圆角 8、14px;子项 12px、缩进 12px |
### 5.5 返回顶部
对应主题 `.scroll-toolbar .totop-button`:
```css
.back-to-top {
position: fixed; top: 50%; right: 30px;
width: 42px; height: 42px;
border-radius: 50%;
background-color: var(--border-color);
color: var(--text-sub);
opacity: 0; visibility: hidden; /* 默认隐藏 */
transform: translateY(-50%) translateY(15px);
transition: opacity .3s, visibility .3s, transform .3s;
}
.back-to-top.is-visible {
opacity: 1; visibility: visible;
transform: translateY(-50%);
}
```
滚动超过 4px 才出现,回到顶部后自动隐藏;窄屏改为 `right:15px; bottom:30%`。
---
## 6. 动效
| 效果 | 实现 |
| --- | --- |
| 视图切换(首页 ↔ 明细 ↔ 条目) | `view-in`:0.22s 淡入 + 上移 6px |
| 侧栏子菜单展开 | `grid-template-rows: 0fr → 1fr`,0.28s,全程 `overflow:hidden`(不产生滚动条) |
| 导航箭头 | `rotate(315deg) → 135deg`,0.3s |
| 分区卡片 / 海报 / 图文悬停 | `translateY(-2px)`,0.2s |
| 应用条目悬停 | 仅名字改为主色 |
| 回到顶部 | 淡入 + 上移 15px,0.3s |
| 刷新按钮加载 | `spin 0.9s linear infinite` |
| 内容区顶部遮罩 | 滚动后 0.2s 淡入 |
---
## 7. 与演示站的有意差异
| 项 | 主题 | Stash | 原因 |
| --- | --- | --- | --- |
| 侧栏宽度 | 260px | 240px | 控制台信息密度略高 |
| 深色模式 | 支持 | 未实现 | 当前只做浅色主题 |
| 数字字体 | Outfit-Medium | 未引入 | 少一个外部字体请求 |
| 图标集 | 全量 446 个 | 13 个子集 | 控制体积(woff2 52KB) |
| 导航箭头位置 | 紧贴文字右侧 | 行末右对齐 | 视觉上更整齐 |
---
## 8. 扩展指引
| 需求 | 改动位置 |
| --- | --- |
| 改主色 / 灰阶 / 圆角 | [`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-web/src/App.css`](stash-web/src/App.css) 的「分区卡片」段落 |
| 换背景图 | 替换 `stash-web/src/assets/site-bg.jpg` |
---
## 9. 变更记录
- 2026-10-22:按本规范完成整体换肤(配色、字体、图标、背景、应用条目、返回顶部),
并将侧栏导航箭头改为与主题一致的 CSS 箭头。
- 2026-10-23:分类内容与侧栏导航迁入 SQLite 内容库(`data/stash.db`),
前端静态数据下线;视觉与交互未变。
Generated
+291
View File
@@ -2,6 +2,18 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -67,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"
@@ -147,18 +171,52 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -220,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"
@@ -230,6 +318,24 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -278,6 +384,45 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -452,6 +597,17 @@ version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -587,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"
@@ -619,6 +786,21 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
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"
@@ -637,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"
@@ -663,6 +875,20 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -750,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"
@@ -759,6 +996,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -796,6 +1039,7 @@ name = "stash"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"async-stream",
"axum",
"clap",
@@ -803,8 +1047,11 @@ dependencies = [
"include_dir",
"mime_guess",
"notify",
"rand",
"rusqlite",
"serde",
"serde_json",
"sha2",
"thiserror",
"tokio",
"tower 0.4.13",
@@ -820,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"
@@ -1049,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"
@@ -1073,6 +1332,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -1187,6 +1458,26 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "zerocopy"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
+15
View File
@@ -25,6 +25,21 @@ include_dir = "0.7"
async-stream = "0.3"
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
walkdir = "2.4"
# MSVC 的 /Z7(旧式调试信息)在 cc-rs 生成的命令行下会触发
# "D8050: 无法执行 c1.dll" —— SQLite 是纯 C 依赖,调试它没有意义,
# 这里关掉该 crate 的调试信息,cc-rs 就不会再加 -Z7。
[profile.dev.package.libsqlite3-sys]
debug = false
[profile.test.package.libsqlite3-sys]
debug = false
+232
View File
@@ -0,0 +1,232 @@
# Stash 交接文档
> 写于 2026-10-29。新会话第一件事:读这份 + `docs/ADMIN-PLAN.md` + `stash-ui/README.md`。
## 0. 一句话现状
Stash 已从「只读本地导航站」升级为**可登录、可配置、可编辑内容的本地发布台**:
- 登录(单用户,argon2id + Cookie 会话);
- `/admin` 设置页(页头滚动信息 / 首页卡片 / 归类 / 侧栏 / 资料 / 系统);
- 条目就地编辑(Drawer + 乐观锁);
- 分区新增/删除、**条目新增/删除(可选分区与分组)**、分组(子分类)新建/改名/删除、侧栏子分类配置、浏览/点赞/分享/跳转;
- 数据库自动迁移到 **v18**;
- 前端 100% 使用组件库 `@stash/ui`(v0.3.1,42 个组件)。
当前数据库**已设置管理员密码**(2026-10-30 由使用者设置);忘记密码按 §6 删除 `admin` 行即可回到首次设置。
## 1. 仓库与运行环境
| 项目 | 路径 | 分支 | 端口 | 说明 |
| --- | --- | --- | --- | --- |
| 新版(组件重构) | `D:\CNWei\CNW\Rust\stash` | `feat/use-stash-ui` | **8080** | 所有新功能都在这里 |
| 旧版(1:1 基线) | `D:\CNWei\CNW\Rust\stash-old`(worktree) | `develop` | **8081** | 只用于视觉对照,不要改 |
| 组件库 | `D:\CNWei\CNW\Rust\stash-ui` | `master` | — | tag `v0.1.0` / `v0.2.0` / `v0.3.0` / `v0.3.1` |
后端:Rust 2024 + Axum 0.8 + rusqlite(bundled)。前端:React 19 + Vite + TS + CSS Modules,`@stash/ui` 通过 `file:` 依赖引入。
### 启动 / 构建(顺序重要)
```powershell
# 1) 前端(产物会被 build.rs 内嵌,必须先构建)
cd D:\CNWei\CNW\Rust\stash\stash-web
npm run build
# 2) 先杀掉正在运行的 stash.exe,否则 cargo 会报「拒绝访问」
Get-Process stash -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*stash\target*' } | Stop-Process -Force
# 3) 后端
cd ..
cargo build
Start-Process .\target\debug\stash.exe -RedirectStandardOutput "$env:TEMP\stash.log" -RedirectStandardError "$env:TEMP\stash-err.log" -WindowStyle Hidden
```
旧版(8081,仅在需要对照时):
```powershell
D:\CNWei\CNW\Rust\stash-old\target\debug\stash.exe --port 8081
```
### 测试
Windows 下 `TEMP` 权限有问题,跑测试要覆盖临时目录:
```powershell
$env:TEMP = "D:\CNWei\CNW\Rust\stash\target\tmp"; $env:TMP = $env:TEMP
cargo test # 当前 48 passed
```
## 2. 目录 / 文件地图
```
src/
main.rs / lib.rs 启动、日志
config.rs 端口 / 数据目录(默认 target/debug/data)
server.rs 全部路由(API + 内嵌前端 + SPA 回落)
error.rs StashError(含 Conflict → 409)
db/
mod.rs Db 门面(所有方法)+ 迁移测试
schema.rs 建表、种子、版本迁移(0/15→16→17)
auth.rs 管理员、密码哈希、会话(token 只存 sha256)
settings.rs settings JSON(header/sidebar/sections/profile)
entries.rs 条目读取/编辑、浏览数、点赞、审计
categories.rs 分区新增/删除
catalog.rs 查询组装(前端三种视图)
stash-web/src/
App.tsx 主页壳、登录弹窗、编辑器、设置应用
main.tsx 入口分流(/admin 与 /login)
auth.ts / settings.ts / items.ts 前端 API 客户端
components/
header/SiteHeader.tsx 品牌 + 滚动信息 + 操作图标
admin/AdminPage.tsx /admin 全部 Tab
auth/LoginDialog.tsx Modal 登录
auth/PasswordInput.tsx 带显隐切换的密码框
content/ 首页、分区、条目、文章、编辑器
sidebar/ 资料卡、服务面板、导航
docs/ADMIN-PLAN.md 后台方案 + P0–P4 实施记录 + 未做项
README.md 运行、路由、库结构、登录说明
```
## 3. 功能清单(已完成)
### 登录
- 单用户 `admin`,密码 argon2id;
- 会话:随机 32 字节 token,HttpOnly + SameSite=Strict + Path=/,7 天,库里存 sha256;
- 登录失败 5 次锁 60 秒;写接口校验 Origin;写接口需要会话。
### `/admin`(新原子模型)
| Tab | 能力 |
| --- | --- |
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/图标/排序/状态)、删除;格式已下沉到条目,不再在分组上设置 |
| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 格式 / 跳转链接 / 所属分组 / 操作;每条内容单独选格式(应用/海报/图文),同一分组可混合 |
| 侧边栏 | 顶部「侧栏卡片」控制本地管理员 / 本地服务显隐;下方「分组导航」逐项勾选;开关即保存 |
| 首页卡片 | 左侧列表含「本地服务」只读表(不可删除,但可拖动排序)+ 自建卡片;右侧分组表格;表格内开关/条数即时保存,标题用「保存」按钮 |
| 页头 | 启用开关、速度、条目、标签与色调 |
| 资料 | 头像 URL、名称、角色、简介、位置 |
| 系统 | 修改密码、下载备份 |
保存:分组/内容/卡片逐项保存;侧边栏、页头、资料走 `PATCH /api/settings`(`version` 乐观锁)。
### 内容编辑
- 详情页 `[编辑]` 只在登录后显示;
- 打开右侧 Drawer:标题、简介、标签、一句话评价、正文 Markdown、封面、跳转地址;
- `PATCH /api/items/{key}`,`updatedAt` 乐观锁;
- 保存后 `catalog.reload()` 局部刷新,不整页刷新。
### 详情操作条
- 浏览:进入页面 `POST /api/items/{key}/view`,数字 +1;
- 点赞:可切换,`POST /like`,localStorage 记住状态;
- 分享:复制当前链接,Toast 反馈;
- 跳转:保存时规范化 `href`(`www.` 自动补 `https://`,拒绝 `javascript:` 等协议);为空或 `#` 时按钮置灰。
## 4. HTTP API
```
GET /api/health
GET /api/projects GET /api/projects/events (SSE)
GET /api/catalog GET /api/catalog/events (SSE) 旧结构(兼容)
GET /api/catalog2 分组树 + 卡片(新前台/后台)
GET /api/cards 卡片列表
POST /api/nodes {key,title,parentKey?,format?} 需登录
PATCH /api/nodes/{key} {title,parentKey?,sort?,format?} 需登录
DELETE /api/nodes/{key} 需登录,级联
POST /api/cards {title} 需登录
PATCH /api/cards/{id} {title?,sort?} 需登录
DELETE /api/cards/{id} 需登录
PUT /api/cards/{id}/groups [{groupKey,previewLimit}] 需登录
GET /api/auth/session -> {authenticated, has_admin, username}
POST /api/auth/setup {password} 仅当无管理员
POST /api/auth/login {password}
POST /api/auth/logout
PATCH /api/auth/password {current, next}
GET /api/settings -> {version, settings}
PATCH /api/settings {version, settings} 需登录
GET /api/items/{key} -> 原始字段(编辑表单)
POST /api/items {key,nodeKey,item} 需登录
PATCH /api/items/{key} {updatedAt, item, nodeKey?} 需登录,nodeKey 可移动条目
DELETE /api/items/{key} 需登录
POST /api/items/{key}/view
POST /api/items/{key}/like {liked}
POST /api/categories {id,title,kind} 需登录
PATCH /api/categories/{id} {title,icon,barColor,kind,previewLimit,emptyText,blockNodeKey,allowGroups} 需登录
POST /api/categories/{id}/groups {id,title} 需登录,仅应用分区
DELETE /api/categories/{id} 需登录,级联,local 不可删
PATCH /api/groups/{id} {title,showInSidebar} 需登录
DELETE /api/groups/{id} 需登录,级联组内条目
GET /api/backup SQLite 快照附件 需登录
```
## 5. 数据库与迁移
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。
- 当前 `PRAGMA user_version = 22`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21→22、更高版本拒绝启动。
- 格式在 `entry.format`(apps / posters / articles),同组可混合;`node` 的旧 `view_mode` 仅保留兼容,不再决定渲染。
- `settings.sidebar.show_profile / show_service` 控制侧栏两张卡片显隐(默认 true)。
- 首页滚动:`#page-scroll`(Layout 的 body)是唯一滚动容器,搜索框固定在其上方;左右两栏放在同一容器内,位移天然一致。
- 本地服务是运行时卡片,但 `card` 表里有一行 `local-service` 只用于排序;内容来自 `/api/projects`,后端禁止删除/改名/配置分组。
- `node` 是统一分组树(根节点 `kind='section'`,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。
- 主要表:`node`、`entry`、`entry_tag`、`section_block`、`admin`、`session`、`setting`、`audit`。
- `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。
- **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。
## 6. 认证运维
- 忘记密码:删除 `admin` 行(或整库)后重启 → 回到首次设置。
```powershell
node -e "const {DatabaseSync}=require('node:sqlite');const d=new DatabaseSync('target/debug/data/stash.db');d.exec('DELETE FROM admin; DELETE FROM session;')"
```
- 改密码:`/admin → 系统`,成功后该账号所有会话失效,需要重新登录。
- 首个管理员由 `/api/auth/setup` 创建,密码至少 8 位。
## 7. 组件库 `@stash/ui`
- 42 个组件,文档站:`cd stash-ui && npm run dev`(也可 `npm run build:site` 后看 `dist-site`)。
- 关键命令:`npm run typecheck && npm run lint && npm run build && npm run build:site`。
- 组件契约:`docs/COMPONENTS.md`;设计规范:`DESIGN.md`;外部风格接入流程:`docs/STYLE-INTAKE.md`(闸门:截图 → 映射 → 决策 → 评审 → 再写代码)。
- 主题:`themes/example.css` 只改变量;页面禁止写组件内部选择器。
- **判断归属**:全局配置放 `/admin`;页面版式(间距、布局容器)写在页面 CSS;只有「第二个页面也复用」的才抽组件。
## 8. 验证与截图
- 后端逻辑:`cargo test`(48 passed,覆盖迁移/登录/设置/条目编辑/分区)。
- 前端构建:`stash-web npm run build`(tsc -b + vite)。
- 视觉/交互:Playwright 脚本在 `stash-ui/.ref/*.mjs`(不提交 git),截图在 `stash-ui/shots/`。
- `measure-final.mjs` **已过时**(旧 1:1 对照用),选择器 `.app header` 仍可用;
- `c3-check.mjs`、`d-check.mjs`、`p2-check.mjs` 是后期功能检查脚本。
- 常用冒烟:首页卡片、`/admin` 各 Tab、详情编辑、登录弹窗。
## 9. 已知限制 / 未做
| 项 | 状态 |
| --- | --- |
| 备份恢复/导入 | 只提供下载快照;恢复需停机替换 `stash.db` |
| 回收站/软删除 | 未做,删除分组会级联删子分组与条目(有二次确认) |
| 图片上传 | 只支持 URL;上传需存储目录、大小/类型校验 |
| 拖拽排序 | 分组/卡片顺序暂用创建顺序;上移下移/拖拽未做 |
| 多用户/角色 | 未做,当前单管理员 |
| 分组移动 | 编辑弹窗里可改「上级分组」(防环);根分组不能移到子级 |
| 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) |
| 分组排序 | 可编辑「排序权重」数值;拖拽排序未做 |
| 旧接口 | `/api/catalog`、`/api/categories/*`、`/api/groups/*` 仍保留给旧组件/兼容,后续可删 |
| 登录限流 | 进程内计数,重启清空 |
## 10. 约定与坑
- 回复用中文,表格化优先;视觉工作要截图验证。
- **任何共享组件改动**都要在 `docs/COMPONENTS.md` 补契约 + changelog,并说明兼容性。
- PowerShell 写文件注意 UTF-8;优先用编辑工具,别用 `Set-Content` 写带中文的源码。
- 改前端后必须 `npm run build` 再 `cargo build`,否则内嵌资源是旧的。
- 重启前先杀 `stash.exe`,否则 cargo 报「拒绝访问」。
- `/login` 是旧地址,`main.tsx` 会重定向到 `/`;登录已改为弹窗。
- 前端路由:`/`、`/g/<分组 key>`、`/g/<分组 key>/i/<条目>`;旧 `/c/...` 地址按第一个路径段当分组 key 兼容,`/admin` 独立壳。
## 11. 新会话建议的下一步
1. 先跑 `cargo test` + 打开 8080 冒烟,确认基线;
2. 若要继续产品化:条目移动 UI、回收站、图片上传、拖拽排序;
3. 若要发布:确认 `stash-ui` 以 `file:` 引入的方式在发布构建里如何锁定版本(建议改为 git tag 依赖或构建前 `npm pack`)。
+449 -178
View File
@@ -1,151 +1,400 @@
# Stash
Stash 是一个基于 Rust、Tokio 和 Axum 实现的本地静态页面托管工具。它既可以直接托管一个静态站点,也可以扫描托管目录下的多个前端项目,并通过内置导航页统一访问。
Stash 是一个用 Rust 编写的本地静态页面托管工具。编译后的 React 控制台会被内嵌进二进制,
所以发布物只有一个 `stash` 可执行文件,不需要额外部署前端静态资源。
## 核心功能
### 单站点托管
当托管根目录中存在 `index.html` 时,访问 `/` 会直接返回该页面。页面依赖的 JavaScript、CSS、图片、字体等资源继续通过静态文件服务加载,因此可以直接运行常见的纯静态前端项目。
### 多项目导航
当根目录没有 `index.html` 时,Stash 会扫描根目录下的一级子目录,并将包含 `index.html` 或 `index.htm` 的目录识别为前端项目。
- 自动排除以 `.` 开头的隐藏目录。
- 项目按名称进行不区分大小写的升序排列。
- 每个项目生成一个卡片链接,地址格式为 `/<项目目录名>/`。
- 项目名称首字符会作为圆形标记显示。
- 没有识别到项目时显示空状态提示。
- 导航页采用 4 列、2 列、1 列的响应式布局,分别适配桌面、平板和手机。
- 项目和目录列表在每次请求时重新扫描,新增项目通常无需重启服务。
- 文件监听会在项目快照发生变化后主动通知已打开的导航页,无需手动刷新。
### 静态文件服务
Stash 使用 `tower-http` 的 `ServeDir` 托管根目录内容:
- 自动为目录追加 `index.html`。
- 根据文件扩展名返回对应的 MIME 类型。
- 未找到的路径会返回内置的中文 404 页面,并显示请求路径。
- 每次 HTTP 请求都会经过 Trace 层,可通过调整日志级别观察请求日志。
- 内置控制台资源通过保留路径 `/__stash__/` 提供,避免与用户托管的项目文件冲突。
### 内置控制台
- 使用 Askama 在编译期渲染项目导航页和 404 页面。
- 页面 HTML、CSS 和 JavaScript 分别维护,不再使用字符串替换拼接卡片。
- 项目名称和请求路径由模板引擎自动进行 HTML 转义。
- 项目目录名会进行 URL 路径段编码,再生成项目访问地址。
- 少量 JavaScript 通过 SSE 订阅项目变化,并在不刷新整个页面的情况下更新卡片;脚本不可用时,服务端渲染的导航和手动 API 仍可正常使用。
### 同源 API
服务提供两个只读 JSON 接口和一个 SSE 推送接口,可供导航页或同源前端调用。
### CORS
默认允许任意来源、任意请求方法和任意请求头访问服务,适合本地联调。通过 `--no-cors` 可以关闭 CORS 标头注入。
### 服务管理
- 默认监听 `127.0.0.1:8080`。
- 支持自定义绑定地址、端口和托管目录。
- 默认托管目录 `./public` 不存在时会自动创建。
- 用户显式指定的托管目录不存在时,会输出错误并退出。
- 端口绑定失败、目录无效等错误会以清晰的错误信息返回。
- 支持分别通过 `Ctrl+C` 以及 Unix 系统的 `SIGTERM` 触发优雅停机。
### 可复用核心库
`stash-core` 同时作为库导出 `Config`、`StashError`、`start`、`ServerHandle`、`scan_projects` 和 `AppProject`,既可由 `stash-cli` 使用,也可以被其他 Rust 程序嵌入或复用。
它能做两件事:托管目录下的多个静态前端项目、给控制台提供同源 JSON / SSE 接口。
## 快速开始
### 环境要求
```bash
# 1. 先构建前端(dist 会被内嵌,缺了它 cargo build 会直接失败)
cd stash-web
npm install
npm run build
cd ..
- Rust 工具链,需支持 Rust 2024 Edition。
- Cargo。
# 2. 编译并运行
cargo run # 托管 ./public,监听 127.0.0.1:8080
### 启动示例
# 自定义目录 / 端口 / 对外暴露
cargo run -- --root ./sites --port 3000
cargo run -- --root ./sites --host 0.0.0.0 --port 3000
```
启动后打开终端里打印的地址即可,例如 `http://127.0.0.1:8080/`。
默认托管目录 `./public` 不存在时会自动创建;首次启动还会与二进制同级创建 `data/`,
并在其中生成内容库 `stash.db`(已内置示例内容,可以直接用 SQLite 工具修改)。
环境要求:
- Rust 工具链(2024 Edition)
- Node.js 24,推荐用 [Volta](https://volta.sh/) 管理;`stash-web/package.json` 里已用 `volta` 字段固定版本
### 登录与设置(v0.1.1 起)
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组以列表查看/编辑条目,每条可单独设格式,主分组含子分组)、侧边栏(侧栏卡片显隐 + 分组导航勾选)、首页卡片(本地服务只读表 + 左侧卡片列表 + 分组表格,可拖动排序),以及页头滚动信息、资料与系统设置;
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
- 读接口公开(本地工具可只读使用),写接口(设置、改密等)要求登录;
- 忘记密码:删除 `admin` 表里的那一行(或整个库)后重启,会回到「设置密码」;
- 方案与后续阶段见 [`docs/ADMIN-PLAN.md`](docs/ADMIN-PLAN.md)。
## 工作方式
请求按下面的顺序命中:
| 路径 | 处理方式 |
| --- | --- |
| `/api/*` | JSON / SSE 接口 |
| `/`、`/index.html` | 内嵌前端的 `index.html`(`no-cache`) |
| `/c/**` | 前端路由地址(分类明细 / 条目详情),没有实体文件时同样返回 `index.html` |
| `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) |
| `/favicon.svg` | 内嵌前端根目录文件 |
| 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` |
| 仍未命中 | 内置 404 页面(显示请求路径) |
托管目录下的每个一级子目录,只要包含 `index.html` 或 `index.htm`,就会被识别为一个项目,
通过 `/<目录名>/` 访问。目录名会做 URL 路径段编码。
## 前端路由
控制台是单页应用,但「当前在哪一页」写在地址栏里,所以刷新、收藏、前进后退都不会丢上下文:
| 地址 | 页面 |
| --- | --- |
| `/` | 首页卡片(按后台勾选的分组组合) |
| `/g/<分组>` | 分组页(自身内容 + 子分组) |
| `/g/<分组>/i/<条目>` | 条目详情 |
地址里的每一段都是**业务键**(也就是接口 JSON 里的 `id`):库里是 `node.key` / `entry.key`,
本地项目则是目录名(访问地址仍然是 `route_path`,两者不再混用)。
几条约定:
- 条目地址带上它自己所在的分组,所以**一个条目只有一个地址**;刷新之后点「返回」
仍然落回条目所属的分组,而不是去猜一个返回目标;
- 「返回」按钮在应用内有上一页时交给浏览器(`history.back()`),
直接打开地址(刷新 / 收藏,应用内没有上一页)时退到上面的推导结果;
- 服务端只把 `/c/**` 回落到入口页(`src/server.rs` 的 `SPA_PREFIXES`),
托管目录里写错的地址仍然是 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`。
## 目录结构
```text
.
├── Cargo.toml # 单 crate(lib + bin),依赖清单
├── Cargo.lock
├── build.rs # 把 stash-web/dist 同步到 $OUT_DIR/web
├── src/ # Rust 后端
│ ├── main.rs # CLI 参数、启动横幅、停机信号
│ ├── lib.rs # 库导出
│ ├── config.rs # 服务配置、数据目录与路径校验
│ ├── error.rs # 错误类型与 JSON 错误响应
│ ├── server.rs # 路由、内嵌静态资源、SSE、生命周期
│ ├── listing.rs # 托管目录扫描
│ ├── watcher.rs # 文件监听、防抖、快照广播
│ └── db/ # SQLite 内容库
│ ├── mod.rs # 打开、PRAGMA、种子导入时机
│ ├── schema.rs # 建表语句与种子导入
│ ├── catalog.rs # 查询结果组装成前端 Category[]
│ └── seed.json # 内嵌种子数据(编译进二进制)
├── stash-web/ # React + Vite + TypeScript 控制台
│ ├── package.json # 含 volta 版本固定
│ ├── vite.config.ts # 开发期 /api 代理到 127.0.0.1:8080
│ └── src/
├── data/ # 内容库目录(与二进制同级,运行时生成)
│ └── stash.db # SQLite 内容库
└── public/ # 默认托管目录(可放多个前端项目)
```
`templates/` 与 `assets/` 是上一版 Askama 控制台留下的文件,当前已无任何代码引用,
可以直接删除。
## 前端怎么被内嵌
1. `npm run build` 产出 `stash-web/dist`;
2. `build.rs` 在编译 Rust 之前把 `dist` 复制到 `$OUT_DIR/web`(先清空,避免旧 hash 文件残留);
3. `src/server.rs` 用 `include_dir!("$OUT_DIR/web")` 在编译期把整棵目录树嵌进二进制;
4. 二进制运行时不再读取任何前端文件。
构建顺序不能颠倒:`dist` 不存在时 `build.rs` 会带着提示信息直接失败,而不是产出一个没有界面的二进制。
`build.rs` 声明了 `rerun-if-changed`,改动 `dist` 后重新 `cargo build` 会自动重新嵌入。
## 内容库(data/stash.db)
控制台的分类 / 分组 / 条目不写死在前端代码里,而是存在一个 SQLite 文件中,
默认位置是**二进制同级**的 `data/stash.db`。首次启动时若文件不存在,
会用内嵌的 `src/db/seed.json` 建库并灌入示例内容;之后一直沿用该文件(不覆盖已有数据)。
用 `--data <DIR>` 可以把数据目录指到别处。
**内容库不能放在 `--root` 托管目录内部**——那里的文件会被 `ServeDir` 当静态资源对外提供,
启动时会直接报错拒绝。
### 表结构
三张表 + 一张展示配置表:
- **分区与分组是同一张自引用树**——它们都是「装条目的容器」,只是层级不同;
- **侧栏直接由这棵树派生**(分区下挂分组),不再有独立的导航表;
- **首页分区展示什么由 `section_block` 决定**,卡片不再混排所有分组。
文章正文是一整段 Markdown,直接存在 `entry.body` 里(一篇文章一行),不再拆成正文块表。
主键统一是 `id INTEGER PRIMARY KEY`(SQLite rowid,自动分配,不用 `AUTOINCREMENT`),
另有一列 `key` 作为**唯一业务编码**;外键一律写成 `<目标>_key`,指向对方的 `key`。
#### node —— 分区与分组(一棵容器树)
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
| `key` | TEXT | 业务编码,全局唯一(如 `recommend` / `ui`) |
| `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 表示只当首页卡片/内容,不进导航 |
| `sort` | INTEGER | 同级顺序,越小越靠前 |
两条约束写在表上:
- `CHECK`:`section` 必须 `parent_key IS NULL` 且有 `view_mode`;`group` 必须有父节点且无 `view_mode`;
- `key` 唯一(UNIQUE),全局不重复。
#### entry —— 内容条目(网站 / 电影 / 文章共用)
| 字段 | 类型 | 说明 / 前端用途 |
| --- | --- | --- |
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
| `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 |
| `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 |
| `name` | TEXT | 名称:网站名 / 电影名 / 文章标题 |
| `description` | TEXT? | 一句话简介,列表卡片的副标题 |
| `icon` | TEXT? | 字符图标,封面缺失时的占位 |
| `href` | TEXT? | 点击后打开的页面;空或 `#` 表示暂无可跳转地址 |
| `cover` | TEXT? | 封面图:应用头像 / 电影海报 / 文章配图 |
| `banner` | TEXT? | 详情页横幅图 |
| `badge` | TEXT? | 详情页标签行上的一句话标记(如「推荐好站」) |
| `comment` | TEXT? | 详情页的一句话评价(引用块) |
| `summary` | TEXT? | 详情页正文段落(电影简介这类长文本) |
| `body` | TEXT? | 文章正文,一整段 Markdown;一篇一行,不拆块 |
| `hot` | INTEGER | 是否热门,`0` / `1`;为 1 时列表显示 HOT 徽标 |
| `views` | INTEGER | 浏览量,详情页操作条「眼睛」后的数字 |
| `likes` | INTEGER | 点赞数,详情页操作条「赞」后的数字 |
| `sort` | INTEGER | 条目顺序(组内或分区内),越小越靠前 |
条目只存 `node_key`,**所属分区由它推导**:父节点是分组就取该分组的 `parent_key`,
父节点是分区就取它自己。查询「整个分区」时用:
```sql
SELECT e.*
FROM entry e
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = 'recommend';
```
好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况;
`node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。
三种内容共用一张表,只是**用到的字段不同**,形态由所属分区的 `view_mode` 决定:
| 类型 | 常用字段 |
| --- | --- |
| 应用 | `name` `description` `icon` `href` `cover` `badge` `banner` `hot` |
| 电影 | `name`(前端叫 `title`)`cover` `summary` `comment` `href` + `entry_tag` |
| 文章 | `name`(前端叫 `title`)`description` `cover` `body`(Markdown)+ `entry_tag` |
#### 正文格式
`entry.body` 存的是 Markdown,渲染层只支持正文实际用到的子集:
| 写法 | 效果 |
| --- | --- |
| 空行分段 | 一个段落 |
| `## 小标题` | 正文小标题(`#`~`######` 都渲染成同级) |
| 单独一行 `![说明](url)` | 配图 |
| `**粗体**`、`` `代码` ``、`[文字](链接)` | 行内格式 |
列表、表格、引用块暂不支持;需要完整语法时把 [`ArticleBody.tsx`](stash-web/src/components/content/ArticleBody.tsx)
换成 react-markdown 即可,渲染层是隔离的。
#### entry_tag —— 行内文本列表(标签 / 元信息)
| 字段 | 类型 | 说明 / 前端用途 |
| --- | --- | --- |
| `entry_key` | TEXT | 所属条目编码,外键 → `entry.key` |
| `sort` | INTEGER | 展示顺序 |
| `label` | TEXT | 标签文字(如「推荐电影」「2 年前」) |
主键 `(entry_key, sort)`。海报的 `tags` 与文章的 `meta` 都存这里——它们都是「有序字符串列表」,
前端只是在不同版式里渲染。
#### section_block —— 首页卡片的展示配置
| 字段 | 类型 | 说明 / 前端用途 |
| --- | --- | --- |
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
| `node_key` | TEXT | 要展示的节点编码,外键 → `node.key`;通常是分组,电影/文章填分区自己 |
| `title` | TEXT? | 块标题;为空时用节点 `title` |
| `preview_limit` | INTEGER? | 卡片显示几条;为空时用分类默认值 |
| `sort` | INTEGER | 卡片顺序 |
**不存所属分区**:一张卡片属于哪个分区,由 `node_key` 推导——节点是分组就取 `parent_key`,
节点是分区就取它自己。所以这一列没必要重复存。
一张卡片展示「你选定的那个分组」,而不是把分区下所有分组混在一起;
点卡片标题或「···」都打开这个块指向的节点。
### 表关系
```text
node (kind=section) 1 ──── n node (kind=group)
│ │
└────────── n entry ───────┘
│
├──── n entry_tag (标签 / 元信息)
└──── n section_block (首页卡片,指向节点)
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` | 删被展示的节点连带删该卡片 |
几条约束值得记住:
- **API 只暴露业务编码**:响应里的 `id` 都是 `key`,数字主键只在库内使用;
- `entry.node_key`:应用指向分组,海报与图文直接指向分区;
- 条目 `key` 是主键,全局唯一;
- **侧栏就是内容树**:分区展开是它的分组,名称取自 `node.title`,不再有别名或跨区编排;
- 首页卡片展示哪个分组由 `section_block.node_key` 决定,改一行即可换内容。
### 前端怎么用这些表
| 界面 | 用到的表 | 关键字段 |
| --- | --- | --- |
| 首页分区卡片 | `node`(section) + `section_block` + `entry` | 卡片标题用展示块标题(默认被展示节点的 `title`),色条用分区 `bar_color`;预览条目取该节点下的前 N 条 |
| 分类明细页 | `node`(section/group) + `entry` | `icon`(hero 图标)、分区 `title` 或分组 `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` 的节点跳过;「本地服务」由前端排除 |
| 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 |
### 侧栏导航
侧栏不再是单独一层数据,**直接由内容树派生**:
```text
常用推荐(section,可折叠)
├── 常用站点(group → 打开该分组)
├── UI 设计资源
├── 矢量图标
├── 免费壁纸
└── 开发工具
推荐电影(section,无分组 → 直接打开)
CES 现场报道(section,无分组 → 直接打开)
```
规则很简单:
- 分区有分组时渲染成可折叠节点,子项就是分组;
- 分区没有分组时(电影 / 文章)整行是链接;
- 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层;
- `show_in_sidebar = 0` 的节点不出现(种子把「常用站点」设成了 0,因为它已经是首页卡片);
- 「本地服务」是前端拼出来的分类,侧栏按 id 直接排除。
### 首页展示块
首页卡片展示什么由 `section_block` 决定,不再把分区下所有分组混排:
```sql
-- 常用推荐卡片改成展示「UI 设计资源」分组,最多 6 条
UPDATE section_block
SET node_key = 'ui', preview_limit = 6
WHERE node_key = 'common';
```
每张卡片一行,`node_key` 指向要展示的节点;它属于哪个分区由节点自己决定。
点卡片标题或「···」都会打开这个节点;电影 / 文章这类没有分组的,`node_key` 填分区自己的编码。
**卡片标题跟着展示内容走**:配了展示块时用块标题(没配则用节点 `title`),
所以「常用推荐」这张卡片会显示成「常用站点」——它实际展示的就是这个分组。
需要单独起名时,写 `section_block.title` 即可。
### 自己改内容
1. 用任何 SQLite 工具(DB Browser for SQLite、`sqlite3` CLI、DBeaver…)打开 `data/stash.db`;
2. 内容改 `node`(分区与分组)与 `entry`(文章正文就是 `entry.body` 里的 Markdown),标签改 `entry_tag`,首页展示改 `section_block`;
3. 提交修改后,页面会在 **5 秒内**自动刷新——服务端轮询 `PRAGMA data_version` 检测外部写入;
4. 也可以点侧栏的「重新扫描」或直接刷新浏览器。
新增条目时 `id` 不用填,`key` 必须写(全局唯一);`node_key` 填直接所在的分组编码
(没有分组就填分区编码),所属分区由它推导。
先 `SELECT key, kind, parent_key FROM node` 看一眼现有编码即可。
### 重置
开发阶段表结构还会调整,**没有实现迁移**。要重置内容:关掉服务后删掉 `data/stash.db`,
下次启动会重新建表并灌入内嵌种子。
```bash
# 托管默认目录 ./public,监听 127.0.0.1:8080
cargo run -p stash-cli
# 托管指定目录并修改端口
cargo run -p stash-cli -- --root ./sites --port 3000
# 允许局域网设备访问
cargo run -p stash-cli -- --root ./sites --host 0.0.0.0 --port 3000
# 关闭 CORS
cargo run -p stash-cli -- --root ./sites --no-cors
rm data/stash.db # Linux / macOS
Remove-Item data\stash.db # Windows PowerShell
```
启动成功后访问终端中显示的地址即可,例如:
库结构版本记在 `PRAGMA user_version` 里(当前为 `15`)。新版本二进制遇到旧库时会**直接拒绝启动**
并提示删库,而不是拿旧表执行新查询:
```text
http://127.0.0.1:8080/
[FATAL] 启动失败: 数据库错误: 内容库结构版本为 14,当前程序需要 15。
开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。
```
### 构建发布版本
`SCHEMA_VERSION` 定义在 [`src/db/schema.rs`](src/db/schema.rs),改动表结构时把它 +1。
## 前端开发
```bash
cargo build --release -p stash-cli
cd stash-web
npm install
npm run dev # http://127.0.0.1:5173,/api 自动代理到后端 8080
npm run build # 产物输出到 stash-web/dist
npm run lint # oxlint
```
构建产物为 `target/release/stash`;Windows 下为 `target/release/stash.exe`。
## 托管目录示例
### 单个静态站点
```text
public/
├── index.html
├── assets/
│ ├── app.js
│ └── style.css
└── images/
└── logo.png
```
此时访问 `/` 会直接展示 `public/index.html`。
### 多个前端项目
```text
public/
├── admin-console/
│ └── index.html
├── docs/
│ └── index.html
└── landing-page/
└── index.html
```
由于根目录没有 `index.html`,访问 `/` 时会展示项目导航页。对应访问地址为:
```text
/admin-console/
/docs/
/landing-page/
```
开发时前后端分开跑:一个终端 `cargo run`,另一个终端 `npm run dev`。
控制台的用户资料目前是占位数据(见 `stash-web/src/profile.ts`),后端尚未提供鉴权接口。
## 命令行参数
| 参数 | 默认值 | 说明 |
| --- | --- | --- |
| `-r, --root <DIR>` | `./public` | 静态页面托管根目录,支持相对路径和绝对路径 |
| `-p, --port <PORT>` | `8080` | HTTP 服务监听端口 |
| `--host <HOST>` | `127.0.0.1` | 监听地址;使用 `0.0.0.0` 可供局域网访问 |
| `--no-listing` | 关闭 | 设计用于关闭根目录项目导航页 |
| `-r, --root <DIR>` | `./public` | 静态页面托管根目录 |
| `--data <DIR>` | 二进制同级 `data/` | 内容库所在目录(存放 `stash.db`) |
| `-p, --port <PORT>` | `8080` | 监听端口 |
| `--host <HOST>` | `127.0.0.1` | 监听地址,`0.0.0.0` 供局域网访问 |
| `--no-cors` | 关闭 | 关闭全局 CORS 标头注入 |
| `-h, --help` | - | 查看命令行帮助 |
| `-V, --version` | - | 查看程序版本 |
| `-h, --help` | - | 帮助 |
| `-V, --version` | - | 版本 |
注意:`--no-listing` 当前已由命令行解析并写入 `Config.directory_listing`,但根路径处理器尚未读取该配置。因此根目录没有 `index.html` 时,导航页目前仍会显示。
`--no-listing` 参数目前仍能被解析,但由于控制台已改为内嵌前端,它不再影响任何行为。
## HTTP API
@@ -155,40 +404,25 @@ public/
GET /api/health
```
响应:
```json
{
"status": "ok",
"service": "stash"
}
{ "status": "ok", "service": "stash" }
```
### 获取项目列表
### 项目列表
```http
GET /api/projects
```
响应示例:
```json
{
"projects": [
{
"name": "admin-console",
"route_path": "/admin-console/"
},
{
"name": "docs",
"route_path": "/docs/"
}
{ "name": "admin-console", "route_path": "/admin-console/" },
{ "name": "docs", "route_path": "/docs/" }
]
}
```
该接口与导航页使用相同的项目扫描规则。即使托管根目录包含 `index.html`,接口仍会返回当前根目录下可识别的项目列表。
### 订阅项目变化
```http
@@ -196,60 +430,97 @@ GET /api/projects/events
Accept: text/event-stream
```
连接建立后,服务会立即发送当前项目快照,随后在文件监听检测到项目列表变化时推送新快照:
连接建立后立即推送一次当前快照,之后在项目列表变化时推送:
```text
event: projects
data: {"projects":[{"name":"docs","route_path":"/docs/"}]}
```
服务端会递归监听托管目录,将短时间内的连续文件事件合并为一次扫描,并且只在项目名称或访问路径实际变化时广播。SSE 连接每 15 秒发送一次保活消息,浏览器断开后会自动重连。
服务端递归监听托管目录,把短时间内的连续事件合并成一次扫描,只在项目名称或访问路径真正变化时广播;
每 15 秒发送一次保活消息,浏览器断开后自动重连。
## 项目结构
### 内容目录
```http
GET /api/catalog
```
返回控制台需要的全部分类(结构见 [`types.ts`](stash-web/src/data/types.ts)),
数据来自 `data/stash.db`。一次全量下发,前端搜索在客户端完成:
```json
{
"categories": [
{
"id": "recommend",
"kind": "apps",
"title": "常用推荐",
"barColor": "#3858f6",
"icon": "🔖",
"block": { "nodeKey": "common", "previewLimit": 4 },
"groups": [
{
"id": "common",
"name": "常用站点",
"items": [
{ "id": "github", "name": "GitHub", "desc": "代码托管与开源社区", "views": 429 }
]
}
]
}
]
}
```
「本地服务」分类**不在**这个接口里,它由 `/api/projects` 扫描托管目录后在前端拼接。
每个分类上的 `block` 就是首页卡片的展示配置:`nodeKey` 指向要展示的节点(通常是分组),
`previewLimit` 是卡片里显示几条。侧栏不再单独下发,前端直接用 `categories` 里的
分区与分组渲染。
### 订阅内容库变化
```http
GET /api/catalog/events
Accept: text/event-stream
```
当内容库被**其它连接**(例如用户用 DB Browser 改库)提交后推送一次,事件本身不带数据:
```text
.
├── Cargo.toml # Rust workspace 配置
├── Cargo.lock # 依赖锁定文件
├── README.md
├── stash-cli/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs # CLI 参数解析、启动信息和停机信号
└── stash-core/
├── Cargo.toml
├── assets/
│ ├── dashboard.css # 控制台与 404 页面样式
│ └── dashboard.js # 项目列表刷新增强
├── src/
│ ├── config.rs # 服务配置
│ ├── error.rs # 错误类型与 HTTP 错误响应
│ ├── lib.rs # 核心库导出
│ ├── listing.rs # 项目扫描与页面渲染
│ ├── server.rs # Axum 路由、SSE、静态托管与服务器生命周期
│ └── watcher.rs # 文件监听、事件防抖和项目快照广播
└── templates/
├── index.html # Askama 多项目导航页模板
└── 404.html # Askama 404 页面模板
event: catalog
data: {"changed":true}
```
客户端收到后重新 `GET /api/catalog`。检测基于 SQLite 的 `PRAGMA data_version`,每 5 秒轮询一次。
## 测试
运行核心库测试:
```bash
cargo test -p stash-core
cargo test # 目录扫描、隐藏目录过滤、排序、路径编码、文件监听广播、内容库、SPA 回落
cd stash-web && npm run build # tsc -b + vite build,同时做类型检查
```
当前测试覆盖项目目录扫描、隐藏目录过滤、一级目录限制、项目排序、项目路径编码、空状态渲染、项目内容 HTML 转义、404 页面渲染与路径转义,以及新增项目后的文件监听广播。
内容库相关测试全部使用**内存库**(`:memory:`),不写磁盘、不污染 `data/`。
如果运行环境限制了系统临时目录(部分沙箱会这样),前几个测试会报 `PermissionDenied`,
把 `TEMP` / `TMP` 指到工作区内再跑即可:
```powershell
$env:TEMP = "$PWD\target\test-tmp"; $env:TMP = $env:TEMP; cargo test
```
## 已知限制
- 项目扫描只检查托管根目录的一级子目录,不递归查找更深层项目。
- 根目录存在 `index.html` 时会优先进入单站点模式,不再展示项目导航页。
- 静态服务只为目录自动追加 `index.html`;导航扫描虽然也识别 `index.htm`,但建议项目统一使用 `index.html`。
- 未配置 SPA History API 回退。刷新前端路由等不存在的物理路径时,会返回 404 页面。
- SSE 仅用于服务端向浏览器推送项目快照,不提供浏览器向服务端发送命令的双向通道。
- 文件事件会经过约 150ms 防抖后再扫描,因此项目出现在导航页中可能有短暂延迟。
- 启动横幅中显示的接口地址为 `/api/pages`,实际实现的项目列表接口是 `/api/projects`。
- 服务没有身份认证机制,默认仅建议绑定 `127.0.0.1`。使用 `0.0.0.0` 暴露到局域网前,应确认目录内容适合被其他设备访问。
- 前端路由只有 `/c/**` 一个前缀;新增一类页面时,`stash-web/src/data/view.ts` 与
`src/server.rs` 的 `SPA_PREFIXES` 必须一起改,否则刷新那条地址会拿到 404 页面。
- 前进 / 后退能回到对应页面,但不恢复滚动位置:切换视图统一滚回内容区顶部。
- 内嵌资源占用了 `/assets/` 前缀,托管目录下如果存在同名目录会被遮蔽。
- `/` 固定由控制台占用,上一版「根目录有 `index.html` 就直接当单站点展示」的行为已取消;
托管目录里的项目统一走 `/<目录名>/`。
- 项目扫描只检查一级子目录,不递归查找更深层项目。
- 文件事件经过约 150ms 防抖,新项目出现在控制台可能有短暂延迟。
- 内容库没有迁移机制:表结构变化时需要删掉 `data/stash.db` 重建(当前属于开发阶段)。
- 内容库被外部工具修改后,页面最长有 5 秒延迟才自动刷新。
- 内容库按单实例使用设计:多个进程同时写同一个库会在 `busy_timeout`(5 秒)后报错。
- 没有身份认证,默认只建议绑定 `127.0.0.1`;使用 `0.0.0.0` 暴露到局域网前请确认目录内容适合被其他设备访问。
+319
View File
@@ -0,0 +1,319 @@
# 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` 只是标记;加表必须写迁移 + 升级测试。
8. **默认值与校验**:设置 JSON 的 schema 校验、缺字段回退默认、坏 JSON 不能白屏。
9. **备份/恢复**:编辑前自动备份(`stash.db.bak`)、一键导出/导入。
10. **审计**:谁改了什么(单用户也需要排查)。
11. **乐观锁/冲突**:多标签页同时编辑。
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. 实施记录
### 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:修改密码(成功后所有会话失效)、下载备份;
- **未做**:恢复/导入(需要停机替换文件或在线校验导入流程)、回收站/软删除、图片上传、拖拽排序(当前用上移/下移按钮替代)。
+202 -1
View File
@@ -1,9 +1,20 @@
use std::path::PathBuf;
use std::path::{Component, Path, PathBuf};
use crate::error::StashError;
/// 数据目录名:与二进制同级,存放可被用户直接修改的内容库。
pub const DATA_DIR_NAME: &str = "data";
/// 内容库文件名:固定为这个名字,一个实例一个库。
pub const DB_FILE_NAME: &str = "stash.db";
#[derive(Debug, Clone)]
pub struct Config {
/// 静态页面托管根目录(存放一个或多个前端项目)
pub root: PathBuf,
/// 数据目录:存放内容库与将来的备份 / 导出文件
pub data_dir: PathBuf,
/// 内容库路径,固定为 `data/stash.db`
pub db_path: PathBuf,
/// 监听端口
pub port: u16,
/// 监听地址,默认 127.0.0.1
@@ -16,8 +27,13 @@ pub struct Config {
impl Default for Config {
fn default() -> Self {
// 默认值只在测试与兜底路径上使用,真实启动路径由 main.rs 解析
let data_dir = PathBuf::from(DATA_DIR_NAME);
Self {
root: PathBuf::from("../public"),
db_path: resolve_db_path(&data_dir),
data_dir,
port: 8080,
host: "127.0.0.1".to_string(),
directory_listing: true,
@@ -25,3 +41,188 @@ impl Default for Config {
}
}
}
/// 数据目录的默认位置:**二进制所在目录**下的 `data/`。
///
/// 之所以不用当前工作目录,是因为双击启动、快捷方式启动时 CWD 并不可控
/// (可能落在 System32 这种没写权限的位置),而数据库属于程序自身状态,
/// 跟着 exe 走最稳。托管目录 `--root` 仍然保持 CWD 相对,行为不变。
pub fn default_data_dir() -> PathBuf {
default_data_dir_from(std::env::current_exe().ok().as_deref())
}
/// `default_data_dir` 的纯函数版本,便于在测试里用假路径验证。
fn default_data_dir_from(exe: Option<&Path>) -> PathBuf {
match exe.and_then(Path::parent) {
// 拿不到 exe 路径时退化成 CWD 相对目录,至少不会 panic
Some(dir) => dir.join(DATA_DIR_NAME),
None => PathBuf::from(DATA_DIR_NAME),
}
}
/// 由数据目录推导出内容库路径。
pub fn resolve_db_path(data_dir: &Path) -> PathBuf {
data_dir.join(DB_FILE_NAME)
}
/// 校验内容库没有落在静态托管目录里。
///
/// `ServeDir` 会把 `--root` 下的所有文件当静态资源对外提供,
/// 一旦库文件在其中,任何人都能直接下载整个数据库,属于必须拦截的配置错误。
pub fn validate_db_location(root: &Path, db_path: &Path) -> Result<(), StashError> {
let root = normalize(root);
let db = normalize(db_path);
if db.starts_with(&root) {
return Err(StashError::InvalidDirectory(format!(
"内容库不能放在托管目录内部:库={},托管目录={}。请用 --data 指定其它位置。",
db.display(),
root.display()
)));
}
Ok(())
}
/// 去掉 Windows `canonicalize` 加上的 `\\?\` 前缀。
///
/// 该前缀本身合法,但会让 `Path::starts_with` 把它们当成不同的路径前缀:
/// `\\?\D:\sites` 与 `D:\sites` 的 `Prefix` 组件不相等,
/// 于是「库是否在托管目录内」的校验会被绕过。比较之前必须统一。
pub fn strip_verbatim(path: &Path) -> PathBuf {
match path.as_os_str().to_string_lossy().strip_prefix(r"\\?\") {
Some(stripped) => PathBuf::from(stripped),
None => path.to_path_buf(),
}
}
/// 展示用路径:去掉 `\\?\` 前缀后再转成字符串。
///
/// 前缀对程序无害,但打印在启动横幅和日志里很碍眼。
pub fn display_path(path: &Path) -> String {
strip_verbatim(path).display().to_string()
}
/// 把路径转成绝对路径,并消掉 `.` / `..` 与重复分隔符。///
/// 这里刻意不做 `canonicalize`:校验发生在目录创建之前,
/// 目标文件往往还不存在,而 `canonicalize` 要求路径真实存在。
/// 纯字符串层面的归一化已经足够判断「谁在谁内部」。
fn normalize(path: &Path) -> PathBuf {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
};
// `canonicalize` 产出的 `\\?\` 前缀会让后续 starts_with 比较失效,先统一掉
let absolute = strip_verbatim(&absolute);
let mut normalized = PathBuf::new();
for component in absolute.components() {
match component {
// 当前目录不产生任何变化
Component::CurDir => {}
// 父目录回退一级;已经到根时 pop 是空操作,正好不会越界
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn data_dir_follows_the_binary_directory() {
let dir = default_data_dir_from(Some(Path::new(r"D:\apps\stash\stash.exe")));
assert_eq!(dir, PathBuf::from(r"D:\apps\stash\data"));
}
#[test]
fn data_dir_falls_back_to_relative_when_exe_path_is_unknown() {
assert_eq!(default_data_dir_from(None), PathBuf::from(DATA_DIR_NAME));
}
#[test]
fn db_path_is_fixed_inside_the_data_dir() {
assert_eq!(
resolve_db_path(Path::new(r"D:\apps\stash\data")),
PathBuf::from(r"D:\apps\stash\data\stash.db")
);
}
#[test]
fn rejects_database_nested_inside_the_hosted_root() {
let err = validate_db_location(
Path::new(r"D:\sites"),
Path::new(r"D:\sites\data\stash.db"),
);
assert!(err.is_err(), "库在托管目录内时必须拒绝启动");
}
#[test]
fn accepts_database_beside_the_hosted_root() {
assert!(
validate_db_location(
Path::new(r"D:\stash\public"),
Path::new(r"D:\stash\data\stash.db"),
)
.is_ok()
);
}
#[test]
fn location_check_survives_dot_segments() {
// `..` 归一化之后应认出这是同一个目录,而不是被绕过
assert!(
validate_db_location(
Path::new(r"D:\stash\sites"),
Path::new(r"D:\stash\sites\..\sites\stash.db"),
)
.is_err()
);
}
/// 回归测试:`canonicalize` 会给路径加 `\\?\` 前缀,
/// 若比较前不统一,库落在托管目录内这种配置错误会被漏过。
#[test]
fn location_check_ignores_verbatim_prefix() {
assert!(
validate_db_location(
Path::new(r"\\?\D:\stash\sites"),
Path::new(r"D:\stash\sites\data\stash.db"),
)
.is_err(),
"带 \\\\?\\ 前缀的托管目录也必须能拦住库"
);
assert!(
validate_db_location(
Path::new(r"D:\stash\sites"),
Path::new(r"\\?\D:\stash\sites\data\stash.db"),
)
.is_err(),
"带 \\\\?\\ 前缀的库路径也必须能被识别"
);
}
#[test]
fn verbatim_prefix_is_stripped() {
assert_eq!(
strip_verbatim(Path::new(r"\\?\D:\apps\stash")),
PathBuf::from(r"D:\apps\stash")
);
assert_eq!(
strip_verbatim(Path::new(r"D:\apps\stash")),
PathBuf::from(r"D:\apps\stash")
);
}
}
+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(())
}
+620
View File
@@ -0,0 +1,620 @@
//! 内容库的查询组装:把统一的 node 树还原成前端 `types.ts` 想要的三种形态。
//!
//! 库里只有一套容器树(分区 → 分组),前端需要 `apps / posters / articles`
//! 的可辨识联合,那是**查询层按 `node.view_mode` 组装**的事。
//!
//! 所有关联都走业务键(`key`),SQL 里能直接看懂指向的是哪个分区 / 分组。
use std::collections::HashMap;
use rusqlite::Connection;
use serde_json::{Map, Value, json};
use crate::{
db::{Catalog, map_error},
error::StashError,
};
/// 容器节点行:分区与分组共用
struct NodeRow {
key: String,
title: String,
view_mode: Option<String>,
bar_color: Option<String>,
icon: Option<String>,
preview_limit: Option<i64>,
empty_text: Option<String>,
show_in_sidebar: bool,
allow_groups: bool,
}
/// 条目行:与 `entry` 表的列一一对应
struct EntryRow {
key: String,
format: String,
name: String,
description: Option<String>,
icon: Option<String>,
href: Option<String>,
cover: Option<String>,
banner: Option<String>,
badge: Option<String>,
comment: Option<String>,
summary: Option<String>,
body: Option<String>,
hot: bool,
views: i64,
likes: i64,
}
/// 读出整个内容目录。
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
Ok(Catalog {
categories: load_sections(conn)?,
})
}
/// 分组树节点(新视图用)
struct TreeNode {
key: String,
parent: Option<String>,
title: String,
view_mode: Option<String>,
icon: Option<String>,
bar_color: Option<String>,
preview_limit: Option<i64>,
sort: i64,
enabled: bool,
}
/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。
pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
let mut stmt = conn
.prepare(
"SELECT key, parent_key, title, view_mode, icon, bar_color, preview_limit, sort, enabled
FROM node ORDER BY sort, key",
)
.map_err(map_error)?;
let nodes = stmt
.query_map([], |row| {
Ok(TreeNode {
key: row.get(0)?,
parent: row.get(1)?,
title: row.get(2)?,
view_mode: row.get(3)?,
icon: row.get(4)?,
bar_color: row.get(5)?,
preview_limit: row.get(6)?,
sort: row.get(7)?,
enabled: row.get(8)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
let index: HashMap<String, usize> = nodes
.iter()
.enumerate()
.map(|(idx, node)| (node.key.clone(), idx))
.collect();
// 子节点索引(按 sort 已排好序)
let mut children: HashMap<Option<String>, Vec<usize>> = HashMap::new();
for (idx, node) in nodes.iter().enumerate() {
children.entry(node.parent.clone()).or_default().push(idx);
}
// 每个节点继承最近根分组的格式
let mut formats: Vec<String> = vec![String::new(); nodes.len()];
for idx in 0..nodes.len() {
let mut cursor = Some(idx);
let mut format = "apps".to_string();
while let Some(current) = cursor {
if let Some(mode) = &nodes[current].view_mode {
format = mode.clone();
break;
}
cursor = nodes[current]
.parent
.as_ref()
.and_then(|parent| index.get(parent).copied());
}
formats[idx] = format;
}
let tags = load_all_tags(conn)?;
fn render(
conn: &Connection,
nodes: &[TreeNode],
children: &HashMap<Option<String>, Vec<usize>>,
formats: &[String],
tags: &HashMap<String, Vec<String>>,
idx: usize,
) -> Result<Value, StashError> {
let node = &nodes[idx];
let format = &formats[idx];
let entries = load_node_entries(conn, &node.key)?;
let items = entries
.iter()
.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, formats, tags, *child))
.collect::<Result<Vec<_>, _>>()
})
.transpose()?
.unwrap_or_default();
Ok(json!({
"key": node.key,
"parentKey": node.parent,
"title": node.title,
"format": format,
"icon": node.icon.clone().unwrap_or_default(),
"barColor": node.bar_color.clone().unwrap_or_default(),
"previewLimit": node.preview_limit,
"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, &formats, &tags, *idx))
.collect::<Result<Vec<_>, _>>()
})
.transpose()?
.unwrap_or_default();
Ok(Value::Array(roots))
}
/// 全部条目标签(一次查询,树组装时用)
fn load_all_tags(conn: &Connection) -> Result<HashMap<String, Vec<String>>, StashError> {
let mut stmt = conn
.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)?))
})
.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)
}
/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。
fn load_sections(conn: &Connection) -> Result<Vec<Value>, StashError> {
let mut stmt = conn
.prepare(
"SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups
FROM node
WHERE kind = 'section'
ORDER BY sort, key",
)
.map_err(map_error)?;
let sections = stmt
.query_map([], |row| {
Ok(NodeRow {
key: row.get(0)?,
title: row.get(1)?,
view_mode: row.get(2)?,
bar_color: row.get(3)?,
icon: row.get(4)?,
preview_limit: row.get(5)?,
empty_text: row.get(6)?,
show_in_sidebar: row.get(7)?,
allow_groups: row.get(8)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
// 先释放语句再发后续查询,避免同一连接上的借用冲突
drop(stmt);
// 每个分区对应的首页卡片:展示哪个节点、几条
let blocks = load_blocks(conn)?;
sections
.iter()
.map(|section| build_section(conn, section, blocks.get(&section.key)))
.collect()
}
/// 读出所有卡片配置,按推导出的分区业务键索引。
///
/// 分区不单独存:节点是分组就取它的 `parent_key`,节点是分区就取它自己。
/// `title` 同样在 SQL 里解析:卡片自己配了标题就用它,否则用被展示节点的标题。
fn load_blocks(conn: &Connection) -> Result<HashMap<String, Value>, StashError> {
let mut stmt = conn
.prepare(
"SELECT COALESCE(p.key, n.key) AS section_key,
b.node_key,
COALESCE(b.title, n.title),
b.preview_limit
FROM section_block b
JOIN node n ON n.key = b.node_key
LEFT JOIN node p ON p.key = n.parent_key
ORDER BY b.sort, b.node_key",
)
.map_err(map_error)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<i64>>(3)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
let mut blocks = HashMap::new();
for (section_key, node_key, title, preview_limit) in rows {
let mut block = Map::new();
block.insert("nodeKey".into(), json!(node_key));
block.insert("title".into(), json!(title));
if let Some(limit) = preview_limit {
block.insert("previewLimit".into(), json!(limit));
}
// 同一分区配了多个卡片时只保留第一个:当前 UI 一张卡片展示一个节点
blocks.entry(section_key).or_insert(Value::Object(block));
}
Ok(blocks)
}
/// 按 `view_mode` 分派到不同的组装方式。
fn build_section(
conn: &Connection,
section: &NodeRow,
block: Option<&Value>,
) -> Result<Value, StashError> {
let view_mode = section
.view_mode
.as_deref()
.ok_or_else(|| StashError::Database(format!("分区 {} 缺少 view_mode", section.key)))?;
let mut object = Map::new();
object.insert("id".into(), json!(section.key));
object.insert("kind".into(), json!(view_mode));
object.insert("title".into(), json!(section.title));
object.insert(
"barColor".into(),
json!(section.bar_color.clone().unwrap_or_default()),
);
object.insert("icon".into(), json!(section.icon.clone().unwrap_or_default()));
object.insert("showInSidebar".into(), json!(section.show_in_sidebar));
object.insert("allowGroups".into(), json!(section.allow_groups));
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
if let Some(limit) = section.preview_limit {
object.insert("previewLimit".into(), json!(limit));
}
if let Some(empty_text) = &section.empty_text {
object.insert("emptyText".into(), json!(empty_text));
}
if let Some(block) = block {
object.insert("block".into(), block.clone());
}
match view_mode {
"apps" | "posters" | "articles" => {}
other => {
return Err(StashError::Database(format!(
"分区 {} 的 view_mode 非法: {other}",
section.key
)));
}
}
// 「允许子分组」开启:分区 → 分组 → 条目;关闭:全部条目平铺
let tags = load_tags(conn, &section.key)?;
if section.allow_groups {
object.insert(
"groups".into(),
build_groups(conn, section, &tags)?,
);
let direct = load_node_entries(conn, &section.key)?;
let items = direct
.iter()
.map(|entry| item_for(entry, &tags))
.collect::<Vec<_>>();
object.insert("items".into(), json!(items));
} else {
object.insert("groups".into(), json!([]));
// 平铺:把(可能存在的)分组里的条目一起取出来,关闭开关时不丢内容
let entries = load_section_entries(conn, &section.key)?;
let items = entries
.iter()
.map(|entry| item_for(entry, &tags))
.collect::<Vec<_>>();
object.insert("items".into(), json!(items));
}
Ok(Value::Object(object))
}
/// 按条目自身的格式组装列表项。
fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value {
let mut value = match entry.format.as_str() {
"posters" => poster_item(entry, tags.get(&entry.key)),
"articles" => article_item(entry, tags.get(&entry.key)),
_ => app_item(entry),
};
if let Some(object) = value.as_object_mut() {
object.insert("format".into(), json!(entry.format));
}
value
}
/// 取出某个分区下的分组,并把条目按 `node_key` 分派进去。
fn build_groups(
conn: &Connection,
section: &NodeRow,
tags: &HashMap<String, Vec<String>>,
) -> Result<Value, StashError> {
let mut stmt = conn
.prepare(
"SELECT key, title, show_in_sidebar FROM node
WHERE kind = 'group' AND parent_key = ?1
ORDER BY sort, key",
)
.map_err(map_error)?;
let groups = stmt
.query_map([&section.key], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, bool>(2)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
let mut rendered_groups = Vec::with_capacity(groups.len());
for (group_key, group_title, show_in_sidebar) in groups {
let entries = load_node_entries(conn, &group_key)?;
let items = entries
.iter()
.map(|entry| item_for(entry, tags))
.collect::<Vec<_>>();
rendered_groups.push(json!({
// 对外用业务键,不用数字主键
"id": group_key,
"name": group_title,
"showInSidebar": show_in_sidebar,
"items": items,
}));
}
Ok(Value::Array(rendered_groups))
}
/// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。
fn app_item(entry: &EntryRow) -> Value {
let mut object = Map::new();
object.insert("id".into(), json!(entry.key));
object.insert("name".into(), json!(entry.name));
// 列表卡片必须有副标题,空字符串比缺字段安全
object.insert(
"desc".into(),
json!(entry.description.clone().unwrap_or_default()),
);
object.insert("icon".into(), json!(entry.icon.clone().unwrap_or_default()));
object.insert(
"href".into(),
json!(entry.href.clone().unwrap_or_else(|| "#".to_string())),
);
insert_optional(&mut object, "cover", &entry.cover);
insert_optional(&mut object, "banner", &entry.banner);
insert_optional(&mut object, "badge", &entry.badge);
insert_optional(&mut object, "comment", &entry.comment);
// hot 只在为真时下发,前端判 `item.hot === true`
if entry.hot {
object.insert("hot".into(), json!(true));
}
object.insert("views".into(), json!(entry.views));
object.insert("likes".into(), json!(entry.likes));
Value::Object(object)
}
/// 海报条目:前端用 `title` 而不是 `name`。
fn poster_item(entry: &EntryRow, tags: Option<&Vec<String>>) -> Value {
let mut object = Map::new();
object.insert("id".into(), json!(entry.key));
object.insert("title".into(), json!(entry.name));
object.insert("cover".into(), json!(entry.cover.clone().unwrap_or_default()));
if let Some(tags) = tags.filter(|tags| !tags.is_empty()) {
object.insert("tags".into(), json!(tags));
}
insert_optional(&mut object, "comment", &entry.comment);
insert_optional(&mut object, "summary", &entry.summary);
insert_optional(&mut object, "href", &entry.href);
object.insert("views".into(), json!(entry.views));
object.insert("likes".into(), json!(entry.likes));
Value::Object(object)
}
/// 图文条目:`meta` 来自 entry_tag,`body` 是 Markdown 正文。
fn article_item(entry: &EntryRow, meta: Option<&Vec<String>>) -> Value {
let mut object = Map::new();
object.insert("id".into(), json!(entry.key));
object.insert("title".into(), json!(entry.name));
object.insert(
"desc".into(),
json!(entry.description.clone().unwrap_or_default()),
);
object.insert("cover".into(), json!(entry.cover.clone().unwrap_or_default()));
if let Some(meta) = meta.filter(|meta| !meta.is_empty()) {
object.insert("meta".into(), json!(meta));
}
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)
}
/// 可选字段:`None` 就不下发,前端用 `??` 兜底。
fn insert_optional(object: &mut Map<String, Value>, key: &str, value: &Option<String>) {
if let Some(value) = value {
object.insert(key.into(), json!(value));
}
}
/// 取某个分区下的全部条目,按 `sort` 升序。
///
/// 条目只存 `node_key`,所属分区由父节点推导:
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
/// 取某个分区下的全部条目(含分组内的),海报 / 图文平铺用。
fn load_section_entries(
conn: &Connection,
section_key: &str,
) -> Result<Vec<EntryRow>, StashError> {
let mut stmt = conn
.prepare(
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
COALESCE(e.format, 'apps')
FROM entry e
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = ?1
ORDER BY e.sort, e.key",
)
.map_err(map_error)?;
let entries = stmt
.query_map([section_key], |row| {
Ok(EntryRow {
key: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
icon: row.get(3)?,
href: row.get(4)?,
cover: row.get(5)?,
banner: row.get(6)?,
badge: row.get(7)?,
comment: row.get(8)?,
summary: row.get(9)?,
body: row.get(10)?,
hot: row.get(11)?,
views: row.get(12)?,
likes: row.get(13)?,
format: row.get(14)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
Ok(entries)
}
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> { let mut stmt = conn
.prepare(
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
COALESCE(e.format, 'apps')
FROM entry e
WHERE e.node_key = ?1
ORDER BY e.sort, e.key",
)
.map_err(map_error)?;
let entries = stmt
.query_map([node_key], |row| {
Ok(EntryRow {
key: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
icon: row.get(3)?,
href: row.get(4)?,
cover: row.get(5)?,
banner: row.get(6)?,
badge: row.get(7)?,
comment: row.get(8)?,
summary: row.get(9)?,
body: row.get(10)?,
hot: row.get(11)?,
views: row.get(12)?,
likes: row.get(13)?,
format: row.get(14)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
Ok(entries)
}
/// 取某个分区下所有条目的标签,按条目业务键聚合;一次查询避免 N+1。
fn load_tags(conn: &Connection, section_key: &str) -> Result<HashMap<String, Vec<String>>, StashError> {
let mut stmt = conn
.prepare(
"SELECT t.entry_key, t.label
FROM entry_tag t
JOIN entry e ON e.key = t.entry_key
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = ?1
ORDER BY t.entry_key, t.sort",
)
.map_err(map_error)?;
let rows = stmt
.query_map([section_key], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
for (entry_key, label) in rows {
grouped.entry(entry_key).or_default().push(label);
}
Ok(grouped)
}
+827
View File
@@ -0,0 +1,827 @@
//! 分区(首页卡片 / 侧栏项)的新增与删除。
//!
//! 分区就是 `node` 表里 `kind='section'` 的行;删除会级联分组、条目与展示块。
//! 新增时顺手建一条首页展示块,保证它立刻出现在首页。
use rusqlite::{Connection, OptionalExtension, params};
use serde::Deserialize;
use crate::{db::map_error, error::StashError, db::entries};
const KINDS: [&str; 3] = ["apps", "posters", "articles"];
#[derive(Debug, Clone, Deserialize)]
pub struct NewCategory {
/// 业务键,只允许小写字母、数字与连字符
pub id: String,
pub title: String,
/// apps / posters / articles
pub kind: String,
}
/// 修改分区:字段都可选,只改传进来的。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCategory {
pub title: Option<String>,
pub icon: Option<String>,
pub bar_color: Option<String>,
/// 改类型(应用 / 海报 / 图文)时会顺带迁移条目归属
pub kind: Option<String>,
pub preview_limit: Option<i64>,
pub empty_text: Option<String>,
/// 首页卡片预览哪个节点(分区自己或它下面的分组)
pub block_node_key: Option<String>,
/// 是否允许子分组(与类型无关,后台开关)
pub allow_groups: Option<bool>,
}
/// 新建分组(子分类)
#[derive(Debug, Clone, Deserialize)]
pub struct NewGroup {
pub id: String,
pub title: String,
}
/// 修改分组:改名 / 侧栏显隐
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateGroup {
pub title: Option<String>,
pub show_in_sidebar: Option<bool>,
}
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 32
&& id
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
}
pub fn create(conn: &mut Connection, new: NewCategory) -> Result<(), StashError> {
let id = new.id.trim();
let title = new.title.trim();
if !valid_id(id) {
return Err(StashError::InvalidDirectory(
"标识只能用小写字母、数字与连字符(≤32)".into(),
));
}
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into()));
}
if !KINDS.contains(&new.kind.as_str()) {
return Err(StashError::InvalidDirectory("类型不合法".into()));
}
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| row.get(0))
.map_err(map_error)?;
if exists > 0 {
return Err(StashError::Conflict(format!("分区已存在: {id}")));
}
let tx = conn.transaction().map_err(map_error)?;
let sort: i64 = tx
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE kind = 'section'",
[],
|row| row.get(0),
)
.map_err(map_error)?;
tx.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, allow_groups, sort, show_in_sidebar)
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, 1)",
params![id, title, new.kind, i64::from(new.kind == "apps"), sort],
)
.map_err(map_error)?;
let block_sort: i64 = tx
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block",
[],
|row| row.get(0),
)
.map_err(map_error)?;
tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort) VALUES (?1, NULL, NULL, ?2)",
params![id, block_sort],
)
.map_err(map_error)?;
entries::audit(&tx, "category.create", id, title)?;
tx.commit().map_err(map_error)?;
Ok(())
}
struct SectionRow {
title: String,
icon: Option<String>,
bar_color: Option<String>,
view_mode: Option<String>,
preview_limit: Option<i64>,
empty_text: Option<String>,
allow_groups: bool,
}
fn valid_color(color: &str) -> bool {
if color.is_empty() {
return true;
}
let hex = color.strip_prefix('#').unwrap_or("");
(hex.len() == 3 || hex.len() == 6) && hex.chars().all(|ch| ch.is_ascii_hexdigit())
}
/// 修改分区:标题 / 图标 / 色条 / 预览条数 / 空态文案,以及「归类」类型。
///
/// 改类型时迁移数据,保证切换后条目不丢:
/// - 海报 / 图文 → 应用:确保有一个分组,把直接挂在分区上的条目挪进去,
/// 首页卡片也改指向该分组;
/// - 应用 → 海报 / 图文:卡片从分组挪回分区,分组保留(不影响条目读取)。
pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<(), StashError> {
if id == "local" {
return Err(StashError::InvalidDirectory(
"「本地服务」是运行时分类,不能编辑".into(),
));
}
let tx = conn.transaction().map_err(map_error)?;
let current = tx
.query_row(
"SELECT title, icon, bar_color, view_mode, preview_limit, empty_text, allow_groups
FROM node WHERE key = ?1 AND kind = 'section'",
[id],
|row| {
Ok(SectionRow {
title: row.get(0)?,
icon: row.get(1)?,
bar_color: row.get(2)?,
view_mode: row.get(3)?,
preview_limit: row.get(4)?,
empty_text: row.get(5)?,
allow_groups: row.get(6)?,
})
},
)
.optional()
.map_err(map_error)?;
let Some(current) = current else {
return Err(StashError::NotFound(format!("分区不存在: {id}")));
};
let title = match patch.title {
Some(title) => {
let title = title.trim().to_string();
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into()));
}
title
}
None => current.title,
};
let icon = match patch.icon {
Some(icon) => {
let icon = icon.trim().to_string();
if icon.chars().count() > 8 {
return Err(StashError::InvalidDirectory("图标最多 8 个字符".into()));
}
(!icon.is_empty()).then_some(icon)
}
None => current.icon,
};
let bar_color = match patch.bar_color {
Some(color) => {
let color = color.trim().to_ascii_lowercase();
if !valid_color(&color) {
return Err(StashError::InvalidDirectory(
"色条需为 #rgb 或 #rrggbb".into(),
));
}
(!color.is_empty()).then_some(color)
}
None => current.bar_color,
};
let empty_text = match patch.empty_text {
Some(text) => {
let text = text.trim().to_string();
(!text.is_empty()).then_some(text)
}
None => current.empty_text,
};
let preview_limit = match patch.preview_limit {
Some(limit) => {
if !(1..=60).contains(&limit) {
return Err(StashError::InvalidDirectory("预览条数需在 1–60 之间".into()));
}
Some(limit)
}
None => current.preview_limit,
};
let kind = match patch.kind {
Some(kind) => {
if !KINDS.contains(&kind.as_str()) {
return Err(StashError::InvalidDirectory("类型不合法".into()));
}
kind
}
None => current
.view_mode
.clone()
.ok_or_else(|| StashError::Database(format!("分区 {id} 缺少 view_mode")))?,
};
let was_apps = current.view_mode.as_deref() == Some("apps");
let is_apps = kind == "apps";
let allow_groups = patch.allow_groups.unwrap_or(current.allow_groups);
if is_apps && !was_apps && allow_groups {
// 切到应用且开启子分组:条目必须有分组可挂;没有就建一个「全部」
let group_count: i64 = tx
.query_row(
"SELECT COUNT(*) FROM node WHERE kind = 'group' AND parent_key = ?1",
[id],
|row| row.get(0),
)
.map_err(map_error)?;
let group_key = if group_count == 0 {
let group_key = format!("{id}-all");
tx.execute(
"INSERT INTO node (key, parent_key, title, kind, sort, show_in_sidebar)
VALUES (?1, ?2, '全部', 'group', 1, 1)",
params![group_key, id],
)
.map_err(map_error)?;
group_key
} else {
tx.query_row(
"SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1
ORDER BY sort, key LIMIT 1",
[id],
|row| row.get(0),
)
.map_err(map_error)?
};
tx.execute(
"UPDATE entry SET node_key = ?1 WHERE node_key = ?2",
params![group_key, id],
)
.map_err(map_error)?;
tx.execute(
"UPDATE section_block SET node_key = ?1 WHERE node_key = ?2",
params![group_key, id],
)
.map_err(map_error)?;
}
// 关闭子分组:首页卡片如果指着分组,挪回分区,避免标题/预览错位
if !allow_groups {
tx.execute(
"UPDATE section_block SET node_key = ?1, title = NULL
WHERE node_key IN (
SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1
)",
[id],
)
.map_err(map_error)?;
}
tx.execute(
"UPDATE node SET title = ?1, icon = ?2, bar_color = ?3, view_mode = ?4,
preview_limit = ?5, empty_text = ?6, allow_groups = ?7
WHERE key = ?8",
params![
title,
icon,
bar_color,
kind,
preview_limit,
empty_text,
allow_groups,
id
],
)
.map_err(map_error)?;
// 首页卡片预览哪个节点:分区自己或它下面的分组
if let Some(block_node_key) = patch.block_node_key {
let block_node_key = block_node_key.trim().to_string();
let valid: i64 = tx
.query_row(
"SELECT COUNT(*) FROM node n LEFT JOIN node p ON p.key = n.parent_key
WHERE n.key = ?1 AND (n.key = ?2 OR p.key = ?2)",
params![block_node_key, id],
|row| row.get(0),
)
.map_err(map_error)?;
if valid == 0 {
return Err(StashError::InvalidDirectory(
"首页卡片只能预览本分区或它的分组".into(),
));
}
let updated = tx
.execute(
"UPDATE section_block SET node_key = ?1, title = NULL
WHERE id IN (
SELECT b.id FROM section_block b
JOIN node n ON n.key = b.node_key
LEFT JOIN node p ON p.key = n.parent_key
WHERE COALESCE(p.key, n.key) = ?2
)",
params![block_node_key, id],
)
.map_err(map_error)?;
if updated == 0 {
let sort: i64 = tx
.query_row("SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block", [], |row| {
row.get(0)
})
.map_err(map_error)?;
tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort)
VALUES (?1, NULL, NULL, ?2)",
params![block_node_key, sort],
)
.map_err(map_error)?;
}
}
entries::audit(&tx, "category.update", id, &title)?;
tx.commit().map_err(map_error)?;
Ok(())
}
/// 在应用类分区下新建分组(子分类)。
pub fn create_group(conn: &mut Connection, section_id: &str, new: NewGroup) -> Result<(), StashError> {
let id = new.id.trim();
let title = new.title.trim();
if !valid_id(id) {
return Err(StashError::InvalidDirectory(
"分组标识只能用小写字母、数字与连字符(≤32)".into(),
));
}
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
}
let allow_groups: Option<bool> = conn
.query_row(
"SELECT allow_groups FROM node WHERE key = ?1 AND kind = 'section'",
[section_id],
|row| row.get(0),
)
.optional()
.map_err(map_error)?;
let Some(allow_groups) = allow_groups else {
return Err(StashError::NotFound(format!("分区不存在: {section_id}")));
};
if !allow_groups {
return Err(StashError::InvalidDirectory(
"该分区未开启「允许子分组」".into(),
));
}
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| {
row.get(0)
})
.map_err(map_error)?;
if exists > 0 {
return Err(StashError::Conflict(format!("分组已存在: {id}")));
}
let sort: i64 = conn
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1",
[section_id],
|row| row.get(0),
)
.map_err(map_error)?;
conn.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar)
VALUES (?1, ?2, ?3, 'group', NULL, ?4, 1)",
params![id, section_id, title, sort],
)
.map_err(map_error)?;
entries::audit(conn, "group.create", id, title)?;
Ok(())
}
/// 修改分组:改名 / 侧栏显隐。
pub fn update_group(conn: &Connection, id: &str, patch: UpdateGroup) -> Result<(), StashError> {
let current: Option<String> = conn
.query_row(
"SELECT title FROM node WHERE key = ?1 AND kind = 'group'",
[id],
|row| row.get(0),
)
.optional()
.map_err(map_error)?;
let Some(current) = current else {
return Err(StashError::NotFound(format!("分组不存在: {id}")));
};
let title = match patch.title {
Some(title) => {
let title = title.trim().to_string();
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
}
title
}
None => current,
};
match patch.show_in_sidebar {
Some(show) => conn.execute(
"UPDATE node SET title = ?1, show_in_sidebar = ?2 WHERE key = ?3",
params![title, show, id],
),
None => conn.execute(
"UPDATE node SET title = ?1 WHERE key = ?2",
params![title, id],
),
}
.map_err(map_error)?;
entries::audit(conn, "group.update", id, &title)?;
Ok(())
}
/// 删除分组:组内条目会一起删除(前端需二次确认)。
pub fn remove_group(conn: &Connection, id: &str) -> Result<(), StashError> {
let removed = conn
.execute("DELETE FROM node WHERE key = ?1 AND kind = 'group'", [id])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("分组不存在: {id}")));
}
entries::audit(conn, "group.delete", id, "")?;
Ok(())
}
pub fn remove(conn: &mut Connection, id: &str) -> Result<(), StashError> {
if id == "local" {
return Err(StashError::InvalidDirectory("本地服务不能删除".into()));
}
let removed = conn
.execute("DELETE FROM node WHERE key = ?1 AND kind = 'section'", [id])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("分区不存在: {id}")));
}
entries::audit(conn, "category.delete", id, "")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE node (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
title TEXT NOT NULL,
kind TEXT NOT NULL,
view_mode TEXT,
icon TEXT,
bar_color TEXT,
preview_limit INTEGER,
empty_text TEXT,
sort INTEGER NOT NULL DEFAULT 0,
show_in_sidebar INTEGER NOT NULL DEFAULT 1,
allow_groups INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE entry (
key TEXT PRIMARY KEY,
node_key TEXT NOT NULL
);
CREATE TABLE section_block (
id INTEGER PRIMARY KEY,
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
title TEXT,
preview_limit INTEGER,
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at INTEGER NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT
);",
)
.expect("应能建表");
conn
}
#[test]
fn create_then_delete() {
let mut conn = db();
create(
&mut conn,
NewCategory {
id: "books".into(),
title: "书单".into(),
kind: "posters".into(),
},
)
.expect("应能创建");
let blocks: i64 = conn
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
.expect("应能查");
assert_eq!(blocks, 1);
remove(&mut conn, "books").expect("应能删除");
let nodes: i64 = conn
.query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0))
.expect("应能查");
assert_eq!(nodes, 0);
}
#[test]
fn rejects_bad_id_and_duplicate() {
let mut conn = db();
assert!(
create(
&mut conn,
NewCategory {
id: "Bad Id".into(),
title: "x".into(),
kind: "apps".into()
}
)
.is_err()
);
create(
&mut conn,
NewCategory {
id: "books".into(),
title: "书单".into(),
kind: "apps".into(),
},
)
.expect("应能创建");
let error = create(
&mut conn,
NewCategory {
id: "books".into(),
title: "书单".into(),
kind: "apps".into(),
},
)
.expect_err("重复应失败");
assert!(matches!(error, StashError::Conflict(_)));
}
fn section(id: &str, title: &str, kind: &str) -> NewCategory {
NewCategory {
id: id.into(),
title: title.into(),
kind: kind.into(),
}
}
#[test]
fn updates_fields_and_rejects_bad_values() {
let mut conn = db();
create(&mut conn, section("books", "书单", "posters")).expect("应能创建");
update(
&mut conn,
"books",
UpdateCategory {
title: Some("读过的书".into()),
icon: Some("📚".into()),
bar_color: Some("#ff6034".into()),
preview_limit: Some(8),
..Default::default()
},
)
.expect("应能更新");
let (title, icon, color, limit): (String, Option<String>, Option<String>, Option<i64>) =
conn.query_row(
"SELECT title, icon, bar_color, preview_limit FROM node WHERE key='books'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.expect("应能查");
assert_eq!(title, "读过的书");
assert_eq!(icon.as_deref(), Some("📚"));
assert_eq!(color.as_deref(), Some("#ff6034"));
assert_eq!(limit, Some(8));
assert!(update(&mut conn, "books", UpdateCategory { bar_color: Some("red".into()), ..Default::default() }).is_err());
assert!(update(&mut conn, "books", UpdateCategory { kind: Some("videos".into()), ..Default::default() }).is_err());
assert!(update(&mut conn, "local", UpdateCategory { title: Some("x".into()), ..Default::default() }).is_err());
assert!(matches!(
update(&mut conn, "none", UpdateCategory::default()).expect_err("应不存在"),
StashError::NotFound(_)
));
}
#[test]
fn switching_apps_to_posters_keeps_groups_until_disabled() {
let mut conn = db();
create(&mut conn, section("books", "书单", "apps")).expect("应能创建");
conn.execute(
"INSERT INTO node (key, parent_key, title, kind, sort) VALUES ('books-g', 'books', '分组', 'group', 1)",
[],
)
.expect("应能建分组");
conn.execute("INSERT INTO entry (key, node_key) VALUES ('b1', 'books-g')", [])
.expect("应能建条目");
// create() 会顺手建一张分区卡片;这里只保留分组卡片,模拟真实配置
conn.execute("DELETE FROM section_block", []).expect("应能清卡片");
conn.execute(
"INSERT INTO section_block (node_key, title, sort) VALUES ('books-g', '分组', 1)",
[],
)
.expect("应能建卡片");
// 类型切到海报但子分组仍开启:分组和卡片都保留
update(
&mut conn,
"books",
UpdateCategory {
kind: Some("posters".into()),
..Default::default()
},
)
.expect("应能改类型");
let block: String = conn
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
.expect("应能查");
assert_eq!(block, "books-g");
// 关闭子分组:卡片挪回分区
update(
&mut conn,
"books",
UpdateCategory {
allow_groups: Some(false),
..Default::default()
},
)
.expect("应能关闭");
let block: String = conn
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
.expect("应能查");
assert_eq!(block, "books", "关闭子分组后卡片应指回分区");
}
#[test]
fn creates_updates_and_removes_group() {
let mut conn = db();
create(&mut conn, section("apps1", "应用", "apps")).expect("应能创建");
create_group(
&mut conn,
"apps1",
NewGroup {
id: "g1".into(),
title: "常用".into(),
},
)
.expect("应能建分组");
update_group(
&conn,
"g1",
UpdateGroup {
title: Some("改名".into()),
show_in_sidebar: Some(false),
},
)
.expect("应能改分组");
let (title, show): (String, i64) = conn
.query_row(
"SELECT title, show_in_sidebar FROM node WHERE key='g1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("应能查");
assert_eq!(title, "改名");
assert_eq!(show, 0);
remove_group(&conn, "g1").expect("应能删分组");
let left: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key='g1'", [], |row| {
row.get(0)
})
.expect("应能查");
assert_eq!(left, 0);
}
#[test]
fn groups_only_under_apps_sections() {
let mut conn = db();
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
assert!(
create_group(
&mut conn,
"films",
NewGroup {
id: "g1".into(),
title: "片单".into()
}
)
.is_err(),
"未开启子分组的分区不能建组"
);
// 打开「允许子分组」后,海报分区也可以建组
update(
&mut conn,
"films",
UpdateCategory {
allow_groups: Some(true),
..Default::default()
},
)
.expect("应能开启");
create_group(
&mut conn,
"films",
NewGroup {
id: "g1".into(),
title: "片单".into(),
},
)
.expect("开启后应能建组");
}
#[test]
fn switching_posters_to_apps_creates_group_and_moves_entries() { let mut conn = db();
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
conn.execute("INSERT INTO entry (key, node_key) VALUES ('f1', 'films')", [])
.expect("应能建条目");
conn.execute(
"INSERT INTO section_block (node_key, title, sort) VALUES ('films', '电影', 1)",
[],
)
.expect("应能建卡片");
update(
&mut conn,
"films",
UpdateCategory {
kind: Some("apps".into()),
allow_groups: Some(true),
..Default::default()
},
)
.expect("应能改类型");
let (entry_node, block_node): (String, String) = conn
.query_row(
"SELECT (SELECT node_key FROM entry WHERE key='f1'),
(SELECT node_key FROM section_block)",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("应能查");
assert_eq!(entry_node, "films-all", "条目应挂到默认分组");
assert_eq!(block_node, "films-all", "首页卡片应指向默认分组");
let groups: i64 = conn
.query_row(
"SELECT COUNT(*) FROM node WHERE kind='group' AND parent_key='films'",
[],
|row| row.get(0),
)
.expect("应能查");
assert_eq!(groups, 1);
}
}
+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(), "重复删除应失败");
}
}
+291
View File
@@ -0,0 +1,291 @@
//! 分组树的管理:多层分组、格式、父级移动与排序。
//!
//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`,带
//! `view_mode` 格式),下面的节点都叫分组(`kind='group'`),可以继续嵌套。
//! 条目挂在任意节点上,格式由最近的根分组决定。
use rusqlite::{Connection, OptionalExtension, params};
use serde::Deserialize;
use crate::{db::entries, db::map_error, error::StashError};
const FORMATS: [&str; 3] = ["apps", "posters", "articles"];
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewNode {
/// 业务键,只允许小写字母、数字与连字符
pub key: String,
pub title: String,
/// 为空表示新建一个根分组
#[serde(default)]
pub parent_key: Option<String>,
/// 根分组的格式:apps / posters / articles(子分组继承,不需要传)
#[serde(default)]
pub format: 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 format: Option<String>,
/// 图标:空字符串表示清除图标
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, view_mode) = 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)
}
None => {
let format = new.format.unwrap_or_else(|| "apps".into());
if !FORMATS.contains(&format.as_str()) {
return Err(StashError::InvalidDirectory("格式不合法".into()));
}
("section", Some(format))
}
};
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, view_mode, icon, enabled, sort, show_in_sidebar, allow_groups)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, 1)",
params![
key,
new.parent_key,
title,
kind,
view_mode,
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>, Option<String>, i64, Option<String>, bool)> = conn
.query_row(
"SELECT title, parent_key, view_mode, 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)?,
row.get(5)?,
))
},
)
.optional()
.map_err(map_error)?;
let Some((current_title, current_parent, current_format, 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 is_root = parent_key.is_none();
let format = if is_root {
match patch.format {
Some(format) => {
if !FORMATS.contains(&format.as_str()) {
return Err(StashError::InvalidDirectory("格式不合法".into()));
}
Some(format)
}
None => current_format,
}
} else {
// 子分组没有自己的格式,跟随根分组
None
};
if is_root && format.is_none() {
return Err(StashError::InvalidDirectory("根分组缺少格式".into()));
}
let icon = match patch.icon {
Some(icon) => {
let icon = icon.trim().to_string();
(!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, view_mode = ?3, sort = ?4,
icon = ?5, enabled = ?6
WHERE key = ?7",
params![title, parent_key, format, 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(())
}
+733
View File
@@ -0,0 +1,733 @@
//! 内容库(SQLite)。
//!
//! 三条设计约束:
//!
//! 1. **单文件**:库固定是 `data/stash.db`,用 `journal_mode=DELETE`,
//! 不会像 WAL 那样在磁盘上多出 `-wal` / `-shm` 两个旁文件。
//! 2. **可编辑**:用户可以用任何 SQLite 工具直接改库;服务只读,不独占文件。
//! 3. **开发期不做迁移**:表结构还在变,改结构就删掉 `data/stash.db` 重启,
//! 启动时会重新建表并灌入内嵌种子。
pub mod auth;
pub mod cards;
pub mod categories;
mod catalog;
pub mod entries;
pub mod groups;
mod schema;
pub mod settings;
use std::sync::Mutex;
use rusqlite::Connection;
use serde_json::Value;
use tracing::{info, warn};
use crate::{config::Config, error::StashError};
pub use schema::SeedReport;
/// 登录/会话的对外状态(server 层直接用)。
pub use auth::SessionState;
/// 站点设置:页头滚动信息 + 侧栏顺序/显隐。
pub use settings::{Settings, SettingsDocument};
/// 条目编辑(P2)。
pub use entries::{EntryPatch, EntryRecord};
/// 一次查询得到的完整内容目录。
///
/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。
#[derive(Debug)]
pub struct Catalog {
/// 分类树,结构对应前端 `types.ts` 的 `Category[]`
pub categories: Vec<Value>,
}
/// 内容库句柄。
///
/// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来:
/// 本地工具并发极低,单连接足够,没必要引入连接池。
/// 所有耗时操作都应放在 `spawn_blocking` 里调用(见 server.rs)。
pub struct Db {
conn: Mutex<Connection>,
}
impl Db {
/// 打开(必要时创建)内容库。
///
/// 顺序很重要:先建目录,再连库,再设 PRAGMA,最后建表 / 判断是否需要灌种子。
pub fn open(config: &Config) -> Result<Self, StashError> {
std::fs::create_dir_all(&config.data_dir)?;
let mut conn = Connection::open(&config.db_path).map_err(map_error)?;
// busy_timeout 让用户用外部工具改库时的短暂写锁变成等待而不是立刻失败
conn.execute_batch(
"PRAGMA journal_mode = DELETE;
PRAGMA synchronous = FULL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;",
)
.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
.query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0))
.map_err(map_error)?;
if existing == 0 {
// 全新库是常态;版本号为 0 但库里已经有数据属于异常(比如手工清表),给条提示
if version != 0 {
warn!("结构版本为 {version} 但节点表为空,将按空库重新导入种子");
}
let report = schema::seed(&mut conn)?;
info!(
"内容库为空,已导入内嵌种子:{} 分区 / {} 分组 / {} 条目 / {} 标签 / {} 展示块",
report.sections, report.groups, report.entries, report.tags, report.blocks
);
} else {
info!(
"内容库已存在,沿用现有数据:{}",
crate::config::display_path(&config.db_path)
);
}
Ok(Self {
conn: Mutex::new(conn),
})
}
/// 读出完整的内容目录,供 `/api/catalog` 直接序列化。
pub fn load_catalog(&self) -> Result<Catalog, StashError> {
let conn = self.lock()?;
catalog::load(&conn)
}
/// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。
///
/// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。
pub fn data_version(&self) -> Result<i64, StashError> {
let conn = self.lock()?;
conn.pragma_query_value(None, "data_version", |row| row.get::<_, i64>(0))
.map_err(map_error)
}
/// 取连接;锁中毒说明此前某个线程 panic 过,此时继续用库并不安全。
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>, StashError> {
self.conn
.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 create_category(&self, new: categories::NewCategory) -> Result<(), StashError> {
let mut conn = self.lock()?;
categories::create(&mut conn, new)
}
pub fn update_category(
&self,
id: &str,
patch: categories::UpdateCategory,
) -> Result<(), StashError> {
let mut conn = self.lock()?;
categories::update(&mut conn, id, patch)
}
pub fn create_group(
&self,
section_id: &str,
new: categories::NewGroup,
) -> Result<(), StashError> {
let mut conn = self.lock()?;
categories::create_group(&mut conn, section_id, new)
}
pub fn update_group(&self, id: &str, patch: categories::UpdateGroup) -> Result<(), StashError> {
let conn = self.lock()?;
categories::update_group(&conn, id, patch)
}
pub fn remove_group(&self, id: &str) -> Result<(), StashError> {
let conn = self.lock()?;
categories::remove_group(&conn, id)
}
// ---------- 原子模型:分组树 / 卡片 ----------
/// 完整分组树(多层,带条目)
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)
}
pub fn remove_category(&self, id: &str) -> Result<(), StashError> {
let mut conn = self.lock()?;
categories::remove(&mut conn, id)
}
}
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
pub(crate) fn map_error(error: rusqlite::Error) -> StashError {
StashError::Database(error.to_string())
}
#[cfg(test)]
impl Db {
/// 测试用:包一个空的内存库。
///
/// 路由测试只关心静态资源与回落规则,不查内容,所以既不建表也不灌种子。
pub(crate) fn in_memory() -> Self {
Self {
conn: Mutex::new(Connection::open_in_memory().expect("内存库应能打开")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 建一个内存库并灌种子;内存库不落盘,测试不受临时目录权限影响。
fn seeded() -> Connection {
let mut conn = Connection::open_in_memory().expect("内存库应能打开");
conn.execute_batch("PRAGMA foreign_keys = ON;")
.expect("PRAGMA 应能设置");
schema::apply(&conn).expect("建表应成功");
schema::seed(&mut conn).expect("种子导入应成功");
conn
}
#[test]
fn seed_fills_every_section_group_and_entry() {
let conn = seeded();
let sections: i64 = conn
.query_row(
"SELECT COUNT(*) FROM node WHERE kind = 'section'",
[],
|row| row.get(0),
)
.expect("应能统计分区");
let groups: i64 = conn
.query_row(
"SELECT COUNT(*) FROM node WHERE kind = 'group'",
[],
|row| row.get(0),
)
.expect("应能统计分组");
let entries: i64 = conn
.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("应能统计展示块");
let tags: i64 = conn
.query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0))
.expect("应能统计标签");
let articles_with_body: i64 = conn
.query_row(
"SELECT COUNT(*) FROM entry WHERE body IS NOT NULL",
[],
|row| row.get(0),
)
.expect("应能统计带正文的条目");
assert_eq!(sections, 3, "种子应含 常用推荐 / 推荐电影 / CES 三个分区");
assert_eq!(groups, 5, "常用推荐应有 5 个分组");
assert_eq!(entries, 42, "18 应用 + 12 电影 + 12 图文");
// 12 部电影各 2 个 tags,12 篇图文各 2 个 meta
assert_eq!(tags, 48, "标签与元信息共用 entry_tag");
// 正文是一篇一段 Markdown,只有 12 篇文章有
assert_eq!(articles_with_body, 12, "只有图文条目带正文");
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
.query_row(
"SELECT p.key, g.view_mode
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)?)),
)
.expect("应能查到分组及其父节点");
assert_eq!(parent_key, "recommend");
assert!(view_mode.is_none(), "分组不应带 view_mode");
// 分区一定是顶层节点
let root_sections: i64 = conn
.query_row(
"SELECT COUNT(*) FROM node WHERE kind = 'section' AND parent_key IS NULL",
[],
|row| row.get(0),
)
.expect("应能统计数据");
assert_eq!(root_sections, 3);
}
#[test]
fn catalog_matches_the_frontend_model() {
let conn = seeded();
let catalog = catalog::load(&conn).expect("应能组装内容目录");
let categories = &catalog.categories;
assert_eq!(categories.len(), 3);
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];
assert_eq!(first_app["name"], "GitHub");
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,
"海报不应同时下发 name 字段"
);
assert!(posters["items"][0]["title"].is_string());
// tags 来自 entry_tag 子表
assert_eq!(
posters["items"][0]["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("图文应有正文");
assert!(body.contains("## 设计细节"), "正文应含小标题: {body}");
assert!(body.contains("!["), "正文应含配图语法: {body}");
assert!(body.contains("\n\n"), "段落之间用空行分隔");
assert!(
articles["items"][0].get("blocks").is_none(),
"不应再下发 blocks 数组"
);
// meta 与海报的 tags 共用 entry_tag 表
assert_eq!(
articles["items"][0]["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");
}
/// 删除分区时,它的展示块一起消失(外键级联),不会留下指向空分区的卡片。
#[test]
fn section_blocks_cascade_with_their_section() {
let conn = seeded();
conn.execute("DELETE FROM node WHERE key = 'recommend' AND kind = 'section'", [])
.expect("应能删除分区");
let remaining: i64 = conn
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
.expect("应能统计展示块");
assert_eq!(remaining, 2, "三个展示块应随分区删除只剩两个");
}
/// 删除分组节点时,组内条目也要一起删掉。
#[test]
fn entries_cascade_when_their_group_is_removed() {
let conn = seeded();
let before: i64 = conn
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
.expect("应能统计条目");
conn.execute(
"DELETE FROM node WHERE kind = 'group' AND key = 'ui' AND parent_key = 'recommend'",
[],
)
.expect("应能删除分组");
let after: i64 = conn
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
.expect("应能统计条目");
assert_eq!(before - after, 5, "ui 分组原有 5 个应用,应被级联删除");
}
/// 主键是 `id`(SQLite rowid),`key` 是唯一业务键;外键仍指向 key。
#[test]
fn primary_key_is_id_and_key_is_unique() {
let conn = seeded();
for table in ["node", "entry", "section_block"] {
let pk: String = conn
.query_row(
&format!("SELECT name FROM pragma_table_info('{table}') WHERE pk = 1"),
[],
|row| row.get(0),
)
.expect("应能查到主键列");
assert_eq!(pk, "id", "{table} 的主键应为 id");
}
let duplicate = conn.execute(
"INSERT INTO entry (key, node_key, name)
VALUES ('github', 'common', '重复')",
[],
);
assert!(duplicate.is_err(), "key 必须唯一");
}
/// `key` 是业务键:不允许重复,否则详情页按它查找会命中错行。
#[test]
fn entry_key_is_unique() {
let conn = seeded();
let duplicate = conn.execute(
"INSERT INTO entry (key, node_key, name)
SELECT key, node_key, '重复条目'
FROM entry WHERE key = 'github'",
[],
);
assert!(duplicate.is_err(), "重复的条目 key 必须被拒绝");
}
/// 条目不再存所属分区,分区由 `node_key` 推导:
/// 改分区业务键后,它的分组与条目应当自动归到新键下。
#[test]
fn section_membership_is_derived_from_parent() {
let conn = seeded();
conn.execute("UPDATE node SET key = 'recommend-v2' WHERE key = 'recommend'", [])
.expect("应能改分区业务键");
let catalog = catalog::load(&conn).expect("应能组装内容目录");
let apps = catalog
.categories
.iter()
.find(|category| category["id"] == "recommend-v2")
.expect("应能找到改名后的分区");
let total: usize = apps["groups"]
.as_array()
.expect("应有分组")
.iter()
.map(|group| group["items"].as_array().expect("应有条目").len())
.sum();
assert_eq!(total, 18, "改名后条目仍应归在这个分区下");
}
/// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。
#[test]
fn entry_fields_are_columns_not_json_blobs() {
let conn = seeded();
let (name, href, cover, hot, views): (String, String, String, i64, i64) = conn
.query_row(
"SELECT name, href, cover, hot, views FROM entry WHERE key = 'github'",
[],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
},
)
.expect("应能按列读取条目");
assert_eq!(name, "GitHub");
assert_eq!(href, "#");
assert!(cover.starts_with("https://"));
assert_eq!(hot, 1, "布尔字段以 0/1 存储");
assert_eq!(views, 429);
// 旧结构里那个 data 列已经不存在了
let has_data_column: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('entry') WHERE name = 'data'",
[],
|row| row.get(0),
)
.expect("应能查询表结构");
assert_eq!(has_data_column, 0, "entry 不应再有 data JSON 列");
}
#[test]
fn data_version_is_readable() {
let db = Db {
conn: Mutex::new(seeded()),
};
assert!(db.data_version().expect("应能读取 data_version") >= 0);
}
/// 全新库(user_version = 0)建全量结构;当前版本直接返回;
/// 15 会迁移到 16;比程序新的版本拒绝。
#[test]
fn migrates_fresh_previous_and_current_schema() {
let conn = Connection::open_in_memory().expect("内存库应能打开");
assert_eq!(
schema::migrate(&conn).expect("全新库应能建表"),
schema::SCHEMA_VERSION,
"新库应直接建到当前版本"
);
let fresh = Connection::open_in_memory().expect("内存库应能打开");
fresh
.execute_batch(
"CREATE TABLE node (key TEXT PRIMARY KEY, parent_key TEXT, title TEXT, kind TEXT, view_mode TEXT);
CREATE TABLE 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::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_newer_schema() {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION + 1)
.expect("应能写入新版本号");
let error = schema::migrate(&conn).expect_err("更新的库必须被拒绝");
let message = error.to_string();
assert!(message.contains("结构版本"), "报错要说明原因: {message}");
assert!(message.contains("升级程序"), "报错要给出处理方式: {message}");
}
/// 回归测试:外部连接提交后,服务端连接读到的 data_version 必须变化。
///
/// 这是「用户用 DB Browser 改库,页面自动刷新」整条链路的地基;
/// 用文件库而不是内存库,因为内存库无法被第二个连接看到。
#[test]
fn data_version_changes_when_another_connection_commits() {
use crate::config::Config;
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/db-version");
std::fs::create_dir_all(&dir).expect("应能创建测试目录");
let path = dir.join("probe.db");
let _ = std::fs::remove_file(&path);
let config = Config {
root: dir.clone(),
data_dir: dir.clone(),
db_path: path.clone(),
..Config::default()
};
let db = Db::open(&config).expect("应能打开文件库");
let before = db.data_version().expect("应能读取初始版本");
// 模拟外部工具:另开一个连接提交一次写事务
let external = Connection::open(&path).expect("外部连接应能打开");
let changed = external
.execute("UPDATE entry SET name = '改过的名字' WHERE key = 'github'", [])
.expect("外部写入应成功");
drop(external);
// 没有实际改动就不会推进 data_version,这里先确认确实改到了行
assert_eq!(changed, 1, "外部更新应命中 github 条目");
let after = db.data_version().expect("应能读取新版本");
assert_ne!(before, after, "外部提交后 data_version 必须变化");
}
}
+653
View File
@@ -0,0 +1,653 @@
//! 内容库的表结构与种子导入。
//!
//! 两条设计原则:
//!
//! 1. **一个容器树**:分区(section)与分组(group)是同一种东西——「装条目的容器」,
//! 合并在 `node` 一张自引用表里,用 `kind` 区分层级。
//! 2. **内容与展示分离**:`nav_item` 不存内容,只存侧栏的排列(哪个节点、叫什么、放在哪)。
//!
//! 命名规则:
//!
//! - 所有表用**业务编码 `code`** 做主键(`recommend` / `ui` / `github`),不再有数字 id;
//! - 外键统一写成 `<目标>_code`:`parent_code`、`section_code`、`node_code`、`entry_code`;
//! - 改编码时下游由 `ON UPDATE CASCADE` 自动跟随。
use rusqlite::{Connection, params};
use serde::Deserialize;
use crate::{db::map_error, error::StashError};
/// 种子文件在编译期内嵌,分发时不需要额外带数据文件。
const SEED_JSON: &str = include_str!("seed.json");
/// 建表语句。
///
/// 开发阶段结构还会调整,所以这里只做 `IF NOT EXISTS` 式的«建库»,
/// 不实现版本迁移:需要改结构时删掉 `data/stash.db` 重启即可,
/// 种子会自动重新灌一份干净数据。
const SCHEMA_SQL: &str = r#"
-- 内容节点:分区与分组共用一张自引用树
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_code 必须为 NULL
-- kind = 'group' → 分区下的分组,parent_code 指向所属分区
-- view_mode 只对分区有意义,决定首页与明细页怎么渲染
CREATE TABLE IF NOT EXISTS node (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT)
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui)
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 父节点业务键:分组指向分区,分区为 NULL
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)
preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认
empty_text TEXT, -- 分区为空时的提示文案
show_in_sidebar INTEGER NOT NULL DEFAULT 1 CHECK (show_in_sidebar IN (0, 1)),
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
allow_groups INTEGER NOT NULL DEFAULT 0 CHECK (allow_groups IN (0, 1)),
-- 是否允许子分组(与类型无关,后台开关)
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
-- 是否启用(禁用后前台侧栏/卡片不展示)
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
CHECK (
(kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL)
OR (kind = 'group' AND parent_key IS NOT NULL AND view_mode IS NULL)
)
);
CREATE INDEX IF NOT EXISTS node_parent_idx ON node(parent_key, sort);
-- 条目:网站 / 电影 / 文章都在这张表里,基础字段各占一列
CREATE TABLE IF NOT EXISTS entry (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 github / movie-1)
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, -- 字符图标(封面缺失时的占位)
href TEXT, -- 点击后打开的页面;空表示暂无可跳转地址
cover TEXT, -- 封面图:应用头像 / 海报 / 文章配图
banner TEXT, -- 详情页横幅图
badge TEXT, -- 详情页标签行上的一句话标记(如「推荐好站」)
comment TEXT, -- 详情页的一句话评价(引用块)
summary TEXT, -- 详情页正文段落(电影简介这类长文本)
body TEXT, -- 文章正文(Markdown):一篇一段文本
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, -- 条目顺序(组内或分区内),越小越靠前
updated_at INTEGER NOT NULL DEFAULT 0 -- 最近一次编辑时间(unix 秒),0 = 从未编辑
);
CREATE INDEX IF NOT EXISTS entry_parent_idx ON entry(node_key, sort);
-- 条目的行内文本列表:海报的 tags、图文的 meta 都放这里(有序、一对多)
CREATE TABLE IF NOT EXISTS entry_tag (
entry_key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 所属条目业务键
sort INTEGER NOT NULL DEFAULT 0, -- 展示顺序
label TEXT NOT NULL, -- 标签文字(如「推荐电影」「2 年前」)
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);
"#;
/// 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
);
"#;
/// 种子导入结果,仅用于启动日志。
#[derive(Debug, Default, PartialEq, Eq)]
pub struct SeedReport {
pub sections: usize,
pub groups: usize,
pub entries: usize,
pub tags: usize,
/// 首页展示块数量(每个分区一个)
pub blocks: usize,
}
/// 当前表结构版本,写在 SQLite 的 `PRAGMA user_version` 里。
///
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
pub const SCHEMA_VERSION: i64 = 22;
fn current_version(conn: &Connection) -> Result<i64, StashError> {
conn.pragma_query_value(None, "user_version", |row| row.get(0))
.map_err(map_error)
}
/// 把库升到 `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})更新;\
请升级程序,或用旧版本打开。"
)));
}
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;
}
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)
}
/// 把内嵌种子写进空库。
///
/// 只用业务编码,不需要先插入再回查自增 id:父子关系在种子文件里已经写清楚,
/// 按「分区 → 分组 → 条目 → 侧栏」的顺序插入即可满足外键。
pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
let seed: SeedFile = serde_json::from_str(SEED_JSON)
.map_err(|error| StashError::Database(format!("种子文件不是合法 JSON: {error}")))?;
let mut report = SeedReport::default();
// 单事务写入:避免中途失败留下半个内容库
let tx = conn.transaction().map_err(map_error)?;
for section in &seed.sections {
let view = section.view.as_str();
// 只认识前端支持的三种形态,拼错时立刻报错而不是静默写脏数据
if !matches!(view, "apps" | "posters" | "articles") {
return Err(StashError::Database(format!(
"种子分区 {} 的 view 非法: {view}",
section.key
)));
}
// 只有 apps 分区才允许有分组:其余类型直接挂条目
if view != "apps" && !section.groups.is_empty() {
return Err(StashError::Database(format!(
"种子分区 {} 是 {view} 类型,不应包含 groups",
section.key
)));
}
tx.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups, sort)
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
section.key,
section.title,
view,
section.bar_color,
section.icon,
section.preview_limit,
section.empty_text,
i64::from(section.show_in_sidebar),
i64::from(view == "apps"),
section.sort,
],
)
.map_err(map_error)?;
report.sections += 1;
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
],
)
.map_err(map_error)?;
report.groups += 1;
for (index, entry) in group.entries.iter().enumerate() {
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, &section.view, index, entry, &mut report)?;
}
}
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身)
tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort)
VALUES (?1, ?2, ?3, ?4)",
params![
section.block.node_key,
section.block.title,
section.block.preview_limit,
section.sort,
],
)
.map_err(|error| {
StashError::Database(format!(
"分区 {} 的展示节点 {} 无效: {error}",
section.key, section.block.node_key
))
})?;
report.blocks += 1;
}
// 老结构(分区 → 展示块)同步生成卡片,保证新首页立刻有内容
tx.execute_batch(BACKFILL_CARDS_SQL).map_err(map_error)?;
tx.commit().map_err(map_error)?;
Ok(report)
}
/// 写入一个条目:基础字段进 entry,行内标签进 entry_tag。
///
/// `node_key` 是直接容器的业务键;所属分区由它推导,不单独存。
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, 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, ?17)",
params![
entry.key,
node_key,
format,
entry.name,
entry.description,
entry.icon,
entry.href,
entry.cover,
entry.banner,
entry.badge,
entry.comment,
entry.summary,
entry.body,
i64::from(entry.hot),
entry.views,
entry.likes,
index as i64,
],
)
.map_err(map_error)?;
report.entries += 1;
// 标签:海报的 tags 与图文的 meta 共用一张子表,按出现顺序编号
for (sort, label) in entry.tags.iter().chain(entry.meta.iter()).enumerate() {
tx.execute(
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
params![entry.key, sort as i64, label],
)
.map_err(map_error)?;
report.tags += 1;
}
Ok(())
}
// ---------------------------------------------------------------------------
// 种子文件的反序列化结构
// ---------------------------------------------------------------------------
/// 种子文件根节点
#[derive(Debug, Deserialize)]
struct SeedFile {
sections: Vec<SeedSection>,
}
/// 一个分区(顶层节点)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedSection {
key: String,
/// 渲染形态:apps / posters / articles
view: String,
title: String,
bar_color: String,
icon: String,
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>,
/// 非 apps 分区直接挂条目
#[serde(default)]
entries: Vec<SeedEntry>,
/// 首页卡片展示哪个节点(分组编码,或分区自己的编码)
block: SeedBlockConfig,
}
/// 首页分区的展示配置
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedBlockConfig {
/// 要展示的节点业务键;通常是分组,电影/文章填分区自己
node_key: String,
/// 块标题;空则用节点 title
#[serde(default)]
title: Option<String>,
/// 卡片显示几条;空则用前端默认
#[serde(default)]
preview_limit: Option<i64>,
}
/// 分区下的一个分组
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
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);
/// `body` 是文章的 Markdown 正文,一篇一段文本。
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedEntry {
key: String,
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
href: Option<String>,
#[serde(default)]
cover: Option<String>,
#[serde(default)]
banner: Option<String>,
#[serde(default)]
badge: Option<String>,
#[serde(default)]
comment: Option<String>,
#[serde(default)]
summary: Option<String>,
/// 文章正文(Markdown)
#[serde(default)]
body: Option<String>,
#[serde(default)]
hot: bool,
#[serde(default)]
views: i64,
#[serde(default)]
likes: i64,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
meta: Vec<String>,
}
+617
View File
@@ -0,0 +1,617 @@
{
"sections": [
{
"key": "recommend",
"view": "apps",
"title": "常用推荐",
"barColor": "#3858f6",
"icon": "🔖",
"sort": 1,
"block": {
"nodeKey": "common",
"previewLimit": 4
},
"groups": [
{
"key": "common",
"title": "常用站点",
"sort": 1,
"showInSidebar": false,
"entries": [
{
"key": "github",
"name": "GitHub",
"description": "代码托管与开源社区",
"icon": "G",
"href": "#",
"cover": "https://picsum.photos/seed/github/200/200",
"banner": "https://picsum.photos/seed/github-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"hot": true,
"views": 429,
"likes": 38
},
{
"key": "zhihu",
"name": "知乎",
"description": "深度内容问答社区",
"icon": "Z",
"href": "#",
"cover": "https://picsum.photos/seed/zhihu/200/200",
"banner": "https://picsum.photos/seed/zhihu-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 294,
"likes": 73
},
{
"key": "bilibili",
"name": "哔哩哔哩",
"description": "弹幕视频分享网站",
"icon": "B",
"href": "#",
"cover": "https://picsum.photos/seed/bilibili/200/200",
"banner": "https://picsum.photos/seed/bilibili-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"hot": true,
"views": 280,
"likes": 9
},
{
"key": "v2ex",
"name": "V2EX",
"description": "创意工作者讨论社区",
"icon": "V",
"href": "#",
"cover": "https://picsum.photos/seed/v2ex/200/200",
"banner": "https://picsum.photos/seed/v2ex-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 387,
"likes": 72
}
]
},
{
"key": "ui",
"title": "UI 设计资源",
"sort": 2,
"entries": [
{
"key": "wallhaven",
"name": "Wallhaven",
"description": "WallBase 创办人失踪后推出的精选壁纸站",
"icon": "W",
"href": "#",
"cover": "https://picsum.photos/seed/wallhaven/200/200",
"banner": "https://picsum.photos/seed/wallhaven-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"hot": true,
"views": 608,
"likes": 51
},
{
"key": "500px",
"name": "500px",
"description": "有着世界上最好的照片分享社区",
"icon": "5",
"href": "#",
"cover": "https://picsum.photos/seed/500px/200/200",
"banner": "https://picsum.photos/seed/500px-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 573,
"likes": 68
},
{
"key": "chuangyiren",
"name": "创意人",
"description": "去掉了形式主义描述的原创社区",
"icon": "C",
"href": "#",
"cover": "https://picsum.photos/seed/chuangyiren/200/200",
"banner": "https://picsum.photos/seed/chuangyiren-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 364,
"likes": 20
},
{
"key": "topys",
"name": "顶尖文案",
"description": "全球顶尖创意分享平台 OPEN YOUR EYES",
"icon": "T",
"href": "#",
"cover": "https://picsum.photos/seed/topys/200/200",
"banner": "https://picsum.photos/seed/topys-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 745,
"likes": 68
},
{
"key": "uichina",
"name": "UI中国",
"description": "前身为 iconfans,国内专业的 UI 设计平台",
"icon": "U",
"href": "#",
"cover": "https://picsum.photos/seed/uichina/200/200",
"banner": "https://picsum.photos/seed/uichina-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 743,
"likes": 4
}
]
},
{
"key": "vector",
"title": "矢量图标",
"sort": 3,
"entries": [
{
"key": "iconfont",
"name": "Iconfont",
"description": "阿里妈妈矢量图标库",
"icon": "I",
"href": "#",
"cover": "https://picsum.photos/seed/iconfont/200/200",
"banner": "https://picsum.photos/seed/iconfont-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 585,
"likes": 41
},
{
"key": "feather",
"name": "Feather Icons",
"description": "简单漂亮的开源图标库",
"icon": "F",
"href": "#",
"cover": "https://picsum.photos/seed/feather/200/200",
"banner": "https://picsum.photos/seed/feather-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 390,
"likes": 48
},
{
"key": "fontawesome",
"name": "FontAwesome",
"description": "图标字体与 CSS 框架",
"icon": "A",
"href": "#",
"cover": "https://picsum.photos/seed/fontawesome/200/200",
"banner": "https://picsum.photos/seed/fontawesome-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 317,
"likes": 17
}
]
},
{
"key": "wallpaper",
"title": "免费壁纸",
"sort": 4,
"entries": [
{
"key": "unsplash",
"name": "Unsplash",
"description": "免费高清无版权摄影图片",
"icon": "U",
"href": "#",
"cover": "https://picsum.photos/seed/unsplash/200/200",
"banner": "https://picsum.photos/seed/unsplash-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 807,
"likes": 5
},
{
"key": "pexels",
"name": "Pexels",
"description": "才华横溢的摄影师分享的素材",
"icon": "P",
"href": "#",
"cover": "https://picsum.photos/seed/pexels/200/200",
"banner": "https://picsum.photos/seed/pexels-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 380,
"likes": 12
},
{
"key": "pixabay",
"name": "Pixabay",
"description": "超过 2.7 百万张优质图片和视频",
"icon": "X",
"href": "#",
"cover": "https://picsum.photos/seed/pixabay/200/200",
"banner": "https://picsum.photos/seed/pixabay-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 771,
"likes": 83
}
]
},
{
"key": "dev",
"title": "开发工具",
"sort": 5,
"entries": [
{
"key": "codepen",
"name": "CodePen",
"description": "前端在线代码演示和练习场",
"icon": "C",
"href": "#",
"cover": "https://picsum.photos/seed/codepen/200/200",
"banner": "https://picsum.photos/seed/codepen-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 266,
"likes": 13
},
{
"key": "stackoverflow",
"name": "Stack Overflow",
"description": "全球程序员技术问答社区",
"icon": "S",
"href": "#",
"cover": "https://picsum.photos/seed/stackoverflow/200/200",
"banner": "https://picsum.photos/seed/stackoverflow-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 112,
"likes": 25
},
{
"key": "github-dev",
"name": "GitHub",
"description": "代码托管与开源协作社区",
"icon": "G",
"href": "#",
"cover": "https://picsum.photos/seed/github-dev/200/200",
"banner": "https://picsum.photos/seed/github-dev-banner/800/300",
"badge": "推荐好站",
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"views": 587,
"likes": 32
}
]
}
]
},
{
"key": "movie",
"view": "posters",
"title": "推荐电影",
"barColor": "#ff6034",
"icon": "🎬",
"sort": 2,
"block": {
"nodeKey": "movie"
},
"entries": [
{
"key": "movie-1",
"name": "千与千寻 千と千尋の神隠し (2001)",
"cover": "https://picsum.photos/seed/m1/300/450",
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "少女千寻误入神灵世界,为救回变成猪的父母,在汤屋打工并逐渐学会担当。",
"href": "#",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"views": 896,
"likes": 51
},
{
"key": "movie-2",
"name": "美丽人生 La vita è bella (1997)",
"cover": "https://picsum.photos/seed/m2/300/450",
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "二战集中营里,父亲用一场「游戏」保护儿子,让他相信世界仍然美好。",
"href": "#",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"views": 897,
"likes": 20
},
{
"key": "movie-3",
"name": "这个杀手不太冷 Léon (1994)",
"cover": "https://picsum.photos/seed/m3/300/450",
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "孤独的职业杀手莱昂收留了邻居少女玛蒂尔达,两个边缘人相互依靠。",
"href": "#",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"views": 898,
"likes": 55
},
{
"key": "movie-4",
"name": "阿甘正传 Forrest Gump (1994)",
"cover": "https://picsum.photos/seed/m4/300/450",
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "智商不高的阿甘凭着一股单纯的执着,意外见证并参与了几十年美国历史。",
"href": "#",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"views": 899,
"likes": 32
},
{
"key": "movie-5",
"name": "霸王别姬 (1993)",
"cover": "https://picsum.photos/seed/m5/300/450",
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "两位京剧伶人半个世纪的悲欢离合,与时代洪流交织在一起。",
"href": "#",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"views": 900,
"likes": 59
},
{
"key": "movie-6",
"name": "肖申克的救赎 The Shawshank Redemption",
"cover": "https://picsum.photos/seed/m6/300/450",
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "蒙冤入狱的银行家安迪,在漫长牢狱中守住希望并完成自救。",
"href": "#",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"views": 901,
"likes": 36
},
{
"key": "movie-7",
"name": "泰坦尼克号 Titanic (1997)",
"cover": "https://picsum.photos/seed/m7/300/450",
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "沉船之夜,来自不同阶层的杰克与露丝相爱,也被人性的选择所考验。",
"href": "#",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"views": 902,
"likes": 13
},
{
"key": "movie-8",
"name": "盗梦空间 Inception (2010)",
"cover": "https://picsum.photos/seed/m8/300/450",
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "盗梦团队潜入层层梦境,试图在目标的潜意识里植入一个念头。",
"href": "#",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"views": 903,
"likes": 48
},
{
"key": "movie-9",
"name": "星际穿越 Interstellar (2014)",
"cover": "https://picsum.photos/seed/m9/300/450",
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "地球濒临枯竭,父亲穿越虫洞寻找新家园,与女儿跨越时间重逢。",
"href": "#",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"views": 904,
"likes": 17
},
{
"key": "movie-10",
"name": "楚门的世界 The Truman Show (1998)",
"cover": "https://picsum.photos/seed/m10/300/450",
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "楚门渐渐发现,自己的整个人生其实是一档被全世界围观的真人秀。",
"href": "#",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"views": 608,
"likes": 45
},
{
"key": "movie-11",
"name": "疯狂动物城 Zootopia (2016)",
"cover": "https://picsum.photos/seed/m11/300/450",
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "兔子警官与狐狸搭档,在动物城里侦破一桩失踪案,也打破偏见。",
"href": "#",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"views": 609,
"likes": 22
},
{
"key": "movie-12",
"name": "大话西游之大圣娶亲 (1995)",
"cover": "https://picsum.photos/seed/m12/300/450",
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "至尊宝在爱情与宿命之间反复抉择,最终戴上金箍踏上取经路。",
"href": "#",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"views": 610,
"likes": 57
}
]
},
{
"key": "ces",
"view": "articles",
"title": "CES 现场报道",
"barColor": "#ffba3a",
"icon": "🏷️",
"sort": 3,
"block": {
"nodeKey": "ces"
},
"entries": [
{
"key": "ces-1",
"name": "可塑性极强的玻璃,在设计师手里有了更多可能 | 这个设计了不起",
"description": "前沿科技",
"cover": "https://picsum.photos/seed/a1/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「前沿科技」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《可塑性极强的玻璃,在设计师手里有了更多可能》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a1/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
},
{
"key": "ces-2",
"name": "废旧共享单车如何变成微型共享书屋? | 这个设计了不起",
"description": "环保设计",
"cover": "https://picsum.photos/seed/a2/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「环保设计」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《废旧共享单车如何变成微型共享书屋?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a2/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
},
{
"key": "ces-3",
"name": "荧光灯管与混凝土块的组合,可以玩出多少花样? | 这个设计了不起",
"description": "材料探索",
"cover": "https://picsum.photos/seed/a3/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「材料探索」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《荧光灯管与混凝土块的组合,可以玩出多少花样?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a3/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
},
{
"key": "ces-4",
"name": "混凝土做的模块化收纳框,还能改成搁架用 | 这个设计了不起",
"description": "家居模块",
"cover": "https://picsum.photos/seed/a4/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「家居模块」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《混凝土做的模块化收纳框,还能改成搁架用》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a4/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
},
{
"key": "ces-5",
"name": "这个用拉伸材料做的头盔,还能帮你呼叫救护车 | 这个设计了不起",
"description": "智能穿戴",
"cover": "https://picsum.photos/seed/a5/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「智能穿戴」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《这个用拉伸材料做的头盔,还能帮你呼叫救护车》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a5/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
},
{
"key": "ces-6",
"name": "你如何选择字体,简单易读还是好看有趣? | 这个设计了不起",
"description": "视觉体验",
"cover": "https://picsum.photos/seed/a6/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「视觉体验」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《你如何选择字体,简单易读还是好看有趣?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a6/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
},
{
"key": "ces-7",
"name": "家里空间小?你需要几把可以悬挂在墙上的椅子 | 这个设计了不起",
"description": "空间收纳",
"cover": "https://picsum.photos/seed/a7/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「空间收纳」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《家里空间小?你需要几把可以悬挂在墙上的椅子》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a7/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
},
{
"key": "ces-8",
"name": "宜家厨房家具又被改造了,色彩是亮点 | 这个设计了不起",
"description": "色彩灵感",
"cover": "https://picsum.photos/seed/a8/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「色彩灵感」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《宜家厨房家具又被改造了,色彩是亮点》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a8/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
},
{
"key": "ces-9",
"name": "这款诞生 50 年的扶手椅,推出了特别版配色 | 这个设计了不起",
"description": "经典产品",
"cover": "https://picsum.photos/seed/a9/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「经典产品」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《这款诞生 50 年的扶手椅,推出了特别版配色》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a9/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
},
{
"key": "ces-10",
"name": "让家和办公室界限变得模糊的一组家具 | 这个设计了不起",
"description": "办公家具",
"cover": "https://picsum.photos/seed/a10/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「办公家具」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《让家和办公室界限变得模糊的一组家具》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a10/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
},
{
"key": "ces-11",
"name": "这个酒店设计奖,挖掘过去一年体验最好的酒店 | 这个设计了不起",
"description": "空间建筑",
"cover": "https://picsum.photos/seed/a11/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「空间建筑」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《这个酒店设计奖,挖掘过去一年体验最好的酒店》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a11/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
},
{
"key": "ces-12",
"name": "一款可移动办公桌,挂墙上当作白板就能写写画画 | 这个设计了不起",
"description": "移动办公",
"cover": "https://picsum.photos/seed/a12/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「移动办公」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《一款可移动办公桌,挂墙上当作白板就能写写画画》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a12/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
}
]
}
+404
View File
@@ -0,0 +1,404 @@
//! 站点级设置(页头滚动信息、侧栏顺序/显隐)。
//!
//! 存 `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;
#[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,
pub items: Vec<HeaderItem>,
}
impl Default for HeaderSettings {
fn default() -> Self {
Self {
enabled: true,
speed: 24,
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,
hidden: Vec::new(),
order: Vec::new(),
hidden_groups: Vec::new(),
shown_groups: Vec::new(),
group_order: std::collections::HashMap::new(),
tree_hidden: Vec::new(),
tree_order: std::collections::HashMap::new(),
}
}
}
#[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,
/// 隐藏的分类 id
pub hidden: Vec<String>,
/// 分类显示顺序(id);未列出的排在后面,保持原有顺序
pub order: Vec<String>,
/// 隐藏的分组,键为 `分区id/分组id`
pub hidden_groups: Vec<String>,
/// 强制显示的分组(覆盖数据库里的 show_in_sidebar=0)
pub shown_groups: Vec<String>,
/// 每个分区下分组的顺序:分区 id → 分组 id 列表
pub group_order: std::collections::HashMap<String, Vec<String>>,
/// 新模型:侧栏隐藏的分组 key(任意层级)
pub tree_hidden: Vec<String>,
/// 新模型:每级分组的顺序,键是父分组 key(根级用空串)
pub tree_order: std::collections::HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct SectionsSettings {
/// 隐藏的首页卡片(分类 id)
pub hidden: Vec<String>,
/// 卡片顺序(分类 id)
pub order: Vec<String>,
/// 标题覆盖:id → 标题
pub titles: std::collections::HashMap<String, String>,
/// 预览条数覆盖:id → 条数
pub limits: std::collections::HashMap<String, u32>,
}
#[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, Default)]
#[serde(default)]
pub struct Settings {
pub header: HeaderSettings,
pub sidebar: SidebarSettings,
pub sections: SectionsSettings,
pub profile: ProfileSettings,
}
/// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SettingsDocument {
pub version: i64,
pub settings: Settings,
}
/// 校验:条数、长度、速度范围。
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} 秒之间"
)));
}
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.sidebar.hidden.len() > 100 || settings.sidebar.order.len() > 100 {
return Err(StashError::InvalidDirectory("侧栏配置过长".into()));
}
if settings.sidebar.hidden_groups.len() > 200
|| settings.sidebar.shown_groups.len() > 200
|| settings.sidebar.group_order.len() > 100
|| settings.sidebar.tree_hidden.len() > 500
|| settings.sidebar.tree_order.len() > 200
{
return Err(StashError::InvalidDirectory("侧栏分组配置过长".into()));
}
if settings.sections.hidden.len() > 100 || settings.sections.order.len() > 100 {
return Err(StashError::InvalidDirectory("首页卡片配置过长".into()));
}
if settings.sections.titles.len() > 100 || settings.sections.limits.len() > 100 {
return Err(StashError::InvalidDirectory("首页卡片配置过长".into()));
}
for title in settings.sections.titles.values() {
if title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("卡片标题最多 40 个字".into()));
}
}
for limit in settings.sections.limits.values() {
if *limit > 60 {
return Err(StashError::InvalidDirectory("卡片条数最多 60".into()));
}
}
if settings.profile.name.chars().count() > 40 {
return Err(StashError::InvalidDirectory("名称最多 40 个字".into()));
}
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()));
}
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!(settings.sections.hidden.is_empty());
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 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());
}
#[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());
}
}
+10
View File
@@ -25,6 +25,15 @@ pub enum StashError {
#[error("未找到资源: {0}")]
NotFound(String),
/// 数据库相关失败:打开、建表、种子导入、查询。
/// 统一收敛成一个变体,避免把 rusqlite 的类型泄漏到库的公开 API。
#[error("数据库错误: {0}")]
Database(String),
/// 乐观锁冲突:资源在本次编辑期间被别人改过。
#[error("冲突: {0}")]
Conflict(String),
}
impl IntoResponse for StashError {
@@ -32,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()),
};
+2
View File
@@ -1,10 +1,12 @@
pub mod config;
pub mod db;
pub mod error;
pub mod listing;
pub mod server;
mod watcher;
pub use config::Config;
pub use db::Db;
pub use error::StashError;
pub use listing::{AppProject, scan_projects};
pub use server::{ServerHandle, start};
+33 -5
View File
@@ -1,7 +1,11 @@
use std::path::PathBuf;
use clap::Parser;
use colored::*;
use stash::{Config, start};
use stash::{
Config,
config::{default_data_dir, display_path, resolve_db_path, validate_db_location},
start,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Parser, Debug)]
@@ -17,6 +21,10 @@ struct Cli {
#[arg(short, long, default_value = "./public", value_name = "DIR")]
root: PathBuf,
/// 数据目录(默认是与二进制同级的 data/,用于存放 stash.db)
#[arg(long, value_name = "DIR")]
data: Option<PathBuf>,
/// 监听端口
#[arg(short, long, default_value_t = 8080, value_name = "PORT")]
port: u16,
@@ -69,9 +77,28 @@ async fn main() -> anyhow::Result<()> {
let abs_root = cli.root.canonicalize().unwrap_or_else(|_| cli.root.clone());
// 3. 构造 stash-core 配置并启动
// 3. 解析数据目录:默认跟随二进制(双击启动时 CWD 不可控),可用 --data 覆盖
let data_dir = match cli.data {
Some(dir) => dir,
None => default_data_dir(),
};
// 先校验再建目录:配置错了就不该在磁盘上留下任何痕迹
let db_path = resolve_db_path(&data_dir);
if let Err(error) = validate_db_location(&abs_root, &db_path) {
eprintln!("{} {}", "[ERROR]".red().bold(), error);
std::process::exit(1);
}
std::fs::create_dir_all(&data_dir)?;
let data_dir = data_dir.canonicalize().unwrap_or(data_dir);
let db_path = resolve_db_path(&data_dir);
// 4. 构造配置并启动
let config = Config {
root: abs_root.clone(),
data_dir: data_dir.clone(),
db_path: db_path.clone(),
port: cli.port,
host: cli.host,
directory_listing: !cli.no_listing,
@@ -86,20 +113,21 @@ async fn main() -> anyhow::Result<()> {
}
};
// 4. 打印启动成功 Banner
// 5. 打印启动成功 Banner
println!();
println!("{}", "==================================================".bright_blue());
println!(" ✨ {} {}", "Stash Server".green().bold(), format!("v{}", env!("CARGO_PKG_VERSION")).dimmed());
println!("{}", "==================================================".bright_blue());
println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline());
println!(" ➜ 托管路径: {}", abs_root.display().to_string().white());
println!(" ➜ 托管路径: {}", display_path(&abs_root).white());
println!(" ➜ 内容库: {}", display_path(&db_path).white());
println!(" ➜ 前端资源: {}", "已内嵌 (stash-web/dist)".white());
println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed());
println!("{}", "--------------------------------------------------".bright_blue());
println!(" 按 {} 停止服务", "Ctrl+C".yellow().bold());
println!();
// 5. 跨平台捕获退出信号
// 6. 跨平台捕获退出信号
wait_for_shutdown_signal().await;
println!("\n{} 正在停止服务并释放端口...", "[SYS]".yellow());
+1133 -31
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -5,6 +5,12 @@
<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 主题一致的正文字体:霞鹜文楷 -->
<link
rel="stylesheet"
href="https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/style.min.css"
crossorigin="anonymous"
/>
<title>Stash · 本地应用工作区</title>
</head>
<body>
+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.1.0",
"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"
},
+1296 -730
View File
File diff suppressed because it is too large Load Diff
+401 -117
View File
@@ -1,152 +1,436 @@
import { useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/BackToTop'
import { SearchBar } from './components/SearchBar'
import { Sidebar } from './components/Sidebar'
import { SiteCard } from './components/SiteCard'
import { TopBar } from './components/TopBar'
import { MOCK_SECTIONS, type CatalogCard, type CatalogSection } from './catalog'
import { Empty, Layout, Notice, Toaster } from '@stash/ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/common/BackToTop'
import { ArticleDetailView } from './components/content/ArticleDetailView'
import { DetailView } from './components/content/DetailView'
import { HomeView } from './components/content/HomeView'
import { ItemDetailView } from './components/content/ItemDetailView'
import { SiteHeader } from './components/header/SiteHeader'
import { SearchBar } from './components/search/SearchBar'
import { Sidebar } from './components/sidebar/Sidebar'
import { withLocalCategory } from './data/local'
import {
appDetailModel,
posterDetailModel,
posterToRelatedItem,
toRelatedItem,
} from './data/detail'
import { filterCategories } from './data/filter'
import type { NavTarget } from './data/nav'
import {
type Category,
findAppItem,
findArticleItem,
findPosterItem,
relatedAppItems,
relatedPosterItems,
} from './data/types'
import { useProjects } from './useProjects'
import { useCatalog } from './useCatalog'
import { useViewRoute } from './useViewRoute'
import { fetchSession, logout as logoutRequest, type SessionState } from './auth'
import {
applySidebarGroups,
applySidebarSettings,
defaultSettings,
fetchSettings,
type SettingsDocument,
} from './settings'
import { LoginDialog } from './components/auth/LoginDialog'
import { ItemEditor } from './components/content/ItemEditor'
import { parseViewPath, viewPath, type View } from './data/view'
import './App.css'
function initialOf(name: string): string {
const [first] = Array.from(name.trim())
return first ? first.toUpperCase() : '#'
}
export default function App() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
// 内容目录来自后端 SQLite 内容库;本地服务分类仍由托管项目动态生成
const catalog = useCatalog()
const [keyword, setKeyword] = useState('')
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
const { view, go } = useViewRoute()
const [session, setSession] = useState<SessionState | null>(null)
const [settings, setSettings] = useState<SettingsDocument>({
version: 0,
settings: defaultSettings(),
})
const [loginOpen, setLoginOpen] = useState(false)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
// 真实项目放最前面,占位分类跟在后面
const sections = useMemo<CatalogSection[]>(() => {
const localCards: CatalogCard[] = projects.map((project) => ({
id: project.route_path,
title: project.name,
desc: project.route_path,
href: project.route_path,
initial: initialOf(project.name),
mono: true,
}))
// 登录态:只用来切换页头按钮与后续的编辑入口
useEffect(() => {
let alive = true
fetchSession().then((next) => {
if (alive) setSession(next)
})
fetchSettings().then((next) => {
if (alive) setSettings(next)
})
return () => {
alive = false
}
}, [])
return [{ id: 'local', title: '本地服务', cards: localCards }, ...MOCK_SECTIONS]
}, [projects])
const categories = useMemo(
() => withLocalCategory(projects, catalog.categories),
[projects, catalog.categories],
)
const visibleCategories = useMemo(
() => filterCategories(categories, keyword),
[categories, keyword],
)
const query = keyword.trim().toLowerCase()
const searching = query !== ''
// 搜索时过滤卡片,并丢掉没有命中的分区
const sectionsToRender = useMemo(() => {
if (!searching) return sections
return sections
.map((section) => ({
...section,
cards: section.cards.filter(
(card) =>
card.title.toLowerCase().includes(query) ||
card.desc.toLowerCase().includes(query),
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区;
// 再按 /admin 里的顺序、显隐与分组设置重排一遍
const sidebarCategories = useMemo(
() =>
applySidebarGroups(
applySidebarSettings(
categories.filter(
(category) => category.id !== 'local' && category.showInSidebar !== false,
),
settings.settings.sidebar,
),
}))
.filter((section) => section.cards.length > 0)
}, [sections, searching, query])
settings.settings.sidebar,
),
[categories, settings.settings.sidebar],
)
const noMatch = searching && sectionsToRender.length === 0
const detailView = view.type === 'detail' ? view : undefined
const itemView = view.type === 'item' ? view : undefined
const detailCategory =
detailView !== undefined
? categories.find((category) => category.id === detailView.categoryId)
: undefined
const itemCategory =
itemView !== undefined
? categories.find((category) => category.id === itemView.categoryId)
: undefined
// 应用与电影共用一个详情模型,这里按分类类型做映射
const detailModel = useMemo(() => {
if (itemView === undefined || itemCategory === undefined) return undefined
if (itemCategory.kind === 'apps') {
const item = findAppItem(itemCategory, itemView.itemId)
return item !== undefined ? appDetailModel(item) : undefined
}
if (itemCategory.kind === 'posters') {
const poster = findPosterItem(itemCategory, itemView.itemId)
return poster !== undefined ? posterDetailModel(poster) : undefined
}
return undefined
}, [itemView, itemCategory])
const relatedItems = useMemo(() => {
if (itemView === undefined || itemCategory === undefined) return []
if (itemCategory.kind === 'apps') {
return relatedAppItems(itemCategory, itemView.itemId, 4).map(toRelatedItem)
}
if (itemCategory.kind === 'posters') {
return relatedPosterItems(itemCategory, itemView.itemId, 4).map(posterToRelatedItem)
}
return []
}, [itemView, itemCategory])
// 切换视图时把内容区(内部滚动)和窗口都回到顶部
useEffect(() => {
const scroller = document.getElementById('content-scroll')
scroller?.scrollTo({ top: 0 })
window.scrollTo({ top: 0 })
}, [view])
/**
* 滚动状态同时驱动「顶部渐隐」和「回到顶部」。
* 桌面端滚的是内容容器(scroll 事件不冒泡),所以用捕获阶段监听。
*/
useEffect(() => {
const sync = () => {
const inner = document.getElementById('content-scroll')?.scrollTop ?? 0
const next = Math.max(inner, window.scrollY) > 4
setScrolled((current) => (current === next ? current : next))
}
sync()
window.addEventListener('scroll', sync, { passive: true })
document.addEventListener('scroll', sync, { passive: true, capture: true })
return () => {
window.removeEventListener('scroll', sync)
document.removeEventListener('scroll', sync, { capture: true })
}
}, [])
/**
* 「打开这个分区」应该落到哪个视图。
*
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
*/
const detailViewFor = (categoryId: string): View => {
const category = categories.find((item) => item.id === categoryId)
if (category?.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) {
return { type: 'detail', categoryId, groupId: group.id }
}
}
return { type: 'detail', categoryId }
}
const openDetail = (categoryId: string) => go(detailViewFor(categoryId))
/** 条目所在的分组:应用 / 海报 / 图文都可能有分组 */
const groupOfItem = (category: Category, itemId: string): string | undefined => {
return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id
}
const openItem = (categoryId: string, itemId: string) => {
const category = categories.find((item) => item.id === categoryId)
go({
type: 'item',
categoryId,
itemId,
// 地址里带上条目自己的分组,刷新后「返回」才有确定的落点
groupId: category === undefined ? undefined : groupOfItem(category, itemId),
})
}
/**
* 侧栏导航:带分组时直接打开那个分组;
* 点分区本身时,交给 openDetail 按展示块落到对应节点。
*/
const navigate = (target: NavTarget) => {
if (target.groupId === undefined) {
openDetail(target.categoryId)
return
}
go({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
}
const goHome = () => go({ type: 'home' })
/** 面包屑点击:把 href 解析回视图再跳转(保持 SPA 不刷新) */
const navigateHref = (href: string) => go(parseViewPath(href))
const searching = keyword.trim() !== ''
const viewCategoryId = detailView?.categoryId ?? itemView?.categoryId
/**
* 首屏数据还没回来时,地址指向的分类 / 条目只是「暂时查不到」,
* 不能直接报「不存在」——刷新详情页时最容易被这个误导。
*/
const dataPending =
viewCategoryId !== undefined &&
(catalog.status === 'loading' || (viewCategoryId === 'local' && status === 'loading'))
/**
* 条目详情按分类类型分流:
* 图文走长文版式,应用 / 电影走通用的条目版式。
*/
function renderItemView() {
if (itemView === undefined) return null
if (itemCategory === undefined) {
return <Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
}
if (itemCategory.kind === 'articles') {
const article = findArticleItem(itemCategory, itemView.itemId)
if (article === undefined) {
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
}
function renderLocalPlaceholder() {
if (status === 'error') {
return (
<div className="notice notice-error" role="alert">
<strong>连不上 Stash 服务</strong>
<p>{error}</p>
<p>
请确认后端已启动(默认 <code>127.0.0.1:8080</code>),开发时 Vite 会把{' '}
<code>/api</code> 代理到后端。
</p>
<button type="button" className="notice-action" onClick={refresh}>
重试
</button>
</div>
<ArticleDetailView
article={article}
categoryId={itemCategory.id}
categoryTitle={itemCategory.title}
onNavigate={navigateHref}
onEdit={() => setEditingKey(itemView.itemId)}
canEdit={session?.authenticated === true}
/>
)
}
if (status === 'loading') {
return (
<div className="notice">
<strong>正在读取项目列表…</strong>
<p>首次加载会扫描一次托管目录。</p>
</div>
)
if (detailModel === undefined) {
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
}
return (
<div className="notice">
<strong>这里还没有项目</strong>
<p>
在托管根目录下创建子目录并放入 <code>index.html</code>,刷新后即可在这里看到它。
</p>
</div>
<ItemDetailView
itemKey={itemView.itemId}
crumbs={[
{ label: '首页', href: '/' },
{ label: itemCategory.title, href: viewPath({ type: 'detail', categoryId: itemCategory.id }) },
{ label: detailModel.title },
]}
model={detailModel}
related={relatedItems}
onNavigate={navigateHref}
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
onEdit={() => setEditingKey(itemView.itemId)}
canEdit={session?.authenticated === true}
/>
)
}
function renderSection(section: CatalogSection) {
return (
<section className="section-group" id={section.id} key={section.id}>
<div className="section-heading">
<h2 className="section-title">{section.title}</h2>
<span className="section-badge">{section.cards.length}</span>
</div>
{section.cards.length > 0 ? (
<div className="grid">
{section.cards.map((card, index) => (
<SiteCard key={card.id} card={card} index={index} />
))}
</div>
) : (
renderLocalPlaceholder()
)}
</section>
)
const handleLogout = () => {
void logoutRequest().then(() => {
setSession({ authenticated: false, has_admin: true })
})
}
return (
<div className="app">
<TopBar
projects={projects}
live={live}
onFocusSearch={() => searchRef.current?.focus()}
<>
<Toaster />
{/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */}
<LoginDialog
open={loginOpen}
hasAdmin={session?.has_admin ?? false}
onClose={() => setLoginOpen(false)}
onSuccess={(next) => {
setSession(next)
setLoginOpen(false)
}}
/>
<div className="search-row">
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
</div>
<ItemEditor
open={editingKey !== null}
itemKey={editingKey}
onClose={() => setEditingKey(null)}
onSaved={() => catalog.reload()}
/>
<div className="layout">
<Sidebar
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={refresh}
/>
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
<div className="site-background" aria-hidden="true" />
<main className="content">
<div className="content-scroll" id="content-scroll">
<h1 className="sr-only">Stash 本地应用工作区</h1>
<Layout
contained
maxWidth={1380}
gutter={20}
siderWidth={240}
gap={20}
headerHeight={60}
headerVariant="solid"
siderBordered={false}
contentPadding="none"
contentOffset={88}
height="screen"
scrollable
hideScrollbar
contentId="content-scroll"
className="app"
header={
<SiteHeader
projectCount={projects.length}
sampleProject={projects[0]?.name}
live={live}
headerItems={settings.settings.header.items}
headerEnabled={settings.settings.header.enabled}
headerSpeed={settings.settings.header.speed}
authenticated={session?.authenticated === true}
username={session?.username ?? undefined}
onFocusSearch={() => searchRef.current?.focus()}
onLogin={() => setLoginOpen(true)}
onLogout={handleLogout}
onSettings={() => window.location.assign('/admin')}
/>
}
sider={
<Sidebar
projects={projects}
categories={sidebarCategories}
profile={settings.settings.profile}
live={live}
refreshing={refreshing}
onRefresh={refresh}
activeCategoryId={viewCategoryId}
activeGroupId={detailView?.groupId}
homeActive={view.type === 'home'}
onNavigate={navigate}
onHome={goHome}
/>
}
>
<div className="content">
{/* 滚动后淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
{sectionsToRender.map(renderSection)}
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
{noMatch && (
<div className="notice">
<strong>没有匹配「{keyword.trim()}」的内容</strong>
<p>换个关键字试试,或清空搜索查看全部。</p>
</div>
)}
</div>
</main>
</div>
<h1 className="sr-only">Stash 本地应用工作区</h1>
<BackToTop />
</div>
{status === 'error' && (
<Notice
tone="error"
title="连不上 Stash 服务"
action={{ label: '重试', onClick: refresh }}
>
{error}
</Notice>
)}
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
{catalog.status === 'error' && (
<Notice
tone="error"
title="内容库读取失败"
action={{ label: '重试', onClick: catalog.reload }}
>
{catalog.error}
</Notice>
)}
{detailView === undefined && itemView === undefined && (
<>
{catalog.status === 'loading' && <Empty text="正在读取内容库…" />}
<HomeView
categories={visibleCategories}
sections={settings.settings.sections}
onOpenDetail={openDetail}
onOpenItem={openItem}
/>
{searching && visibleCategories.length === 0 && (
<Notice title={`没有匹配「${keyword.trim()}」的内容`}>
换个关键字试试,或清空搜索查看全部。
</Notice>
)}
</>
)}
{detailView !== undefined && detailCategory !== undefined && (
<DetailView
category={detailCategory}
groupId={detailView.groupId}
onNavigate={navigateHref}
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
/>
)}
{detailView !== undefined && detailCategory === undefined && (
<Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
)}
{itemView !== undefined && renderItemView()}
</div>
</Layout>
<BackToTop visible={scrolled} />
</>
)
}
+657
View File
@@ -0,0 +1,657 @@
import {
Empty,
Layout,
Menu,
Notice,
Space,
Toaster,
type MenuItem,
} from '@stash/ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/common/BackToTop'
import { ArticleDetailView } from './components/content/ArticleDetailView'
import { AppGrid } from './components/content/AppGrid'
import { CesCardGrid } from './components/content/CesCardGrid'
import { ItemDetailView } from './components/content/ItemDetailView'
import { ItemEditor } from './components/content/ItemEditor'
import { PosterGrid } from './components/content/PosterGrid'
import { SectionCard } from './components/content/SectionCard'
import { SiteHeader } from './components/header/SiteHeader'
import { SearchBar } from './components/search/SearchBar'
import { LoginDialog } from './components/auth/LoginDialog'
import { ProfileCard } from './components/sidebar/ProfileCard'
import { ServicePanel } from './components/sidebar/ServicePanel'
import { useProjects } from './useProjects'
import { fetchSession, logout, type SessionState } from './auth'
import {
fetchSettings,
type SettingsDocument,
type SiteSettings,
} from './settings'
import {
appDetailModel,
posterDetailModel,
posterToRelatedItem,
toRelatedItem,
type ItemDetailModel,
} from './data/detail'
import type { AppItem, ArticleItem, PosterItem, RelatedItem } from './data/types'
import { fetchManage, type CardItem, type GroupNode, type ManageData } from './manage'
import './App.css'
/**
* 新前台:分组树 + 卡片组合。
*
* 路由:
* / 首页(卡片按勾选分组渲染)
* /g/<分组 key> 分组页(自身条目 + 子分组)
* /g/<分组 key>/i/<条目> 条目详情
* 旧 /c/... 地址按第一个路径段当成分组 key,兼容老书签。
*/
type Route =
| { kind: 'home' }
| { kind: 'group'; key: string }
| { kind: 'item'; key: string; itemId: string }
function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean)
if (
(segments[0] === 'g' || segments[0] === 'c') &&
segments[1] !== undefined
) {
if (segments[2] === 'i' && segments[3] !== undefined) {
return { kind: 'item', key: decodeURIComponent(segments[1]), itemId: decodeURIComponent(segments[3]) }
}
return { kind: 'group', key: decodeURIComponent(segments[1]) }
}
return { kind: 'home' }
}
function useRoute() {
const [route, setRoute] = useState<Route>(() => parseRoute(window.location.pathname))
useEffect(() => {
const sync = () => setRoute(parseRoute(window.location.pathname))
window.addEventListener('popstate', sync)
return () => window.removeEventListener('popstate', sync)
}, [])
const navigate = (path: string) => {
if (window.location.pathname === path) return
window.history.pushState({}, '', path)
setRoute(parseRoute(path))
document.getElementById('page-scroll')?.scrollTo({ top: 0 })
window.scrollTo({ top: 0 })
}
return { route, navigate }
}
function findGroup(groups: GroupNode[], key: string): GroupNode | undefined {
for (const group of groups) {
if (group.key === key) return group
const found = findGroup(group.children, key)
if (found !== undefined) return found
}
return undefined
}
function findPath(groups: GroupNode[], key: string, trail: GroupNode[] = []): GroupNode[] | undefined {
for (const group of groups) {
const next = [...trail, group]
if (group.key === key) return next
const found = findPath(group.children, key, next)
if (found !== undefined) return found
}
return undefined
}
function findEntryInGroup(
group: GroupNode,
entryId: string,
): { group: GroupNode; entry: Record<string, unknown> } | undefined {
const direct = group.entries.find((candidate) => candidate.id === entryId)
if (direct !== undefined) {
return { group, entry: direct as unknown as Record<string, unknown> }
}
for (const child of group.children) {
const found = findEntryInGroup(child, entryId)
if (found !== undefined) return found
}
return undefined
}
function findEntry(
groups: GroupNode[],
groupKey: string,
entryId: string,
): { group: GroupNode; entry: Record<string, unknown> } | undefined {
const group = findGroup(groups, groupKey)
if (group === undefined) return undefined
return findEntryInGroup(group, entryId)
}
function groupEntryCount(group: GroupNode): number {
return group.entries.length + group.children.reduce((total, child) => total + groupEntryCount(child), 0)
}
/** 分组子树里的全部条目(格式沿根分组继承,所以可以放在同一个网格里) */
function collectEntries(group: GroupNode): Record<string, unknown>[] {
return [
...(group.entries as unknown as Record<string, unknown>[]),
...group.children.flatMap((child) => collectEntries(child)),
]
}
function defaultLimit(group: GroupNode): number {
return group.previewLimit ?? 6
}
function entryFormat(entry: Record<string, unknown>): 'apps' | 'posters' | 'articles' {
const value = entry.format
return value === 'posters' || value === 'articles' ? value : 'apps'
}
function matches(entry: Record<string, unknown>, keyword: string): boolean {
if (keyword === '') return true
const text = [entry.name, entry.title, entry.description, entry.desc]
.filter((value) => typeof value === 'string')
.join(' ')
.toLowerCase()
return text.includes(keyword)
}
export default function AppNext() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
const { route, navigate } = useRoute()
const [manage, setManage] = useState<ManageData>({ groups: [], cards: [] })
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
const [session, setSession] = useState<SessionState | null>(null)
const [settings, setSettings] = useState<SettingsDocument | null>(null)
const [loginOpen, setLoginOpen] = useState(false)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const load = () => {
void fetchManage().then((data) => {
setManage(data)
setLoading(false)
})
}
useEffect(() => {
load()
void fetchSession().then(setSession)
void fetchSettings().then(setSettings)
const source = new EventSource('/api/catalog/events')
source.onmessage = () => load()
return () => source.close()
}, [])
useEffect(() => {
const content = document.getElementById('content-scroll')
const main = content?.parentElement
const body = main?.parentElement
if (
!(content instanceof HTMLElement) ||
!(main instanceof HTMLElement) ||
!(body instanceof HTMLElement)
) {
return
}
// 左右两栏放进同一个滚动容器:滚动区域从搜索框下方开始,两栏位移完全一致
const sider = body.querySelector('aside')
body.id = 'page-scroll'
body.style.overflowY = 'auto'
body.style.overflowX = 'hidden'
body.style.marginTop = '88px'
body.style.height = 'calc(100% - 88px)'
content.style.overflow = 'visible'
content.style.marginTop = '0'
main.style.height = 'auto'
if (sider instanceof HTMLElement) {
sider.style.overflow = 'visible'
sider.style.height = 'auto'
}
const onScroll = () => setScrolled(body.scrollTop > 4)
body.addEventListener('scroll', onScroll, { passive: true })
onScroll()
return () => {
body.removeEventListener('scroll', onScroll)
body.id = ''
body.style.overflowY = ''
body.style.overflowX = ''
body.style.marginTop = ''
body.style.height = ''
content.style.overflow = ''
content.style.marginTop = ''
main.style.height = ''
if (sider instanceof HTMLElement) {
sider.style.overflow = ''
sider.style.height = ''
}
}
}, [])
const site: SiteSettings = settings?.settings ?? {
header: { enabled: true, speed: 24, items: [] },
sidebar: {
show_profile: true,
show_service: true,
hidden: [],
order: [],
hidden_groups: [],
shown_groups: [],
group_order: {},
tree_hidden: [],
tree_order: {},
},
sections: { hidden: [], order: [], titles: {}, limits: {} },
profile: {
name: '本地管理员',
role: '管理员',
description: '',
location: '',
avatar: '',
},
}
const keywordLower = keyword.trim().toLowerCase()
const sidebarItems: MenuItem[] = useMemo(() => {
const hidden = new Set(site.sidebar.tree_hidden)
const sortNodes = (nodes: GroupNode[], parentKey: string): GroupNode[] => {
const ranks = new Map(
(site.sidebar.tree_order[parentKey] ?? []).map((key, index) => [key, index] as const),
)
return [...nodes].sort((a, b) => {
const left = ranks.get(a.key) ?? Number.MAX_SAFE_INTEGER
const right = ranks.get(b.key) ?? Number.MAX_SAFE_INTEGER
if (left === right) return a.sort - b.sort
return left - right
})
}
const build = (nodes: GroupNode[], parentKey: string): MenuItem[] =>
sortNodes(nodes, parentKey).flatMap((node) => {
if (node.enabled === false) return []
if (hidden.has(node.key)) return build(node.children, node.key)
const children = build(node.children, node.key)
return [
{
key: `g:${node.key}`,
label: node.title,
children: children.length > 0 ? children : undefined,
},
]
})
return [{ key: 'home', label: '首页' }, ...build(manage.groups, '')]
}, [manage.groups, site.sidebar.tree_hidden, site.sidebar.tree_order])
const activeKey =
route.kind === 'home'
? 'home'
: route.kind === 'group'
? `g:${route.key}`
: `g:${route.key}`
function renderEntries(
group: GroupNode,
entries: Record<string, unknown>[],
options: { detail?: boolean; limit?: number } = {},
) {
const filtered = entries.filter((entry) => matches(entry, keywordLower))
const limit = options.limit ?? filtered.length
const items = filtered.slice(0, limit)
const open = (itemId: string) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(itemId)}`)
return (
<>
{(['apps', 'posters', 'articles'] as const).map((format) => {
const subset = items.filter((entry) => entryFormat(entry) === format)
if (subset.length === 0) return null
return (
<div className="mixed-format-block" key={format}>
{format === 'posters' ? (
<PosterGrid items={subset as unknown as PosterItem[]} onOpen={open} />
) : format === 'articles' ? (
<CesCardGrid items={subset as unknown as ArticleItem[]} onOpen={open} />
) : (
<AppGrid items={subset as unknown as AppItem[]} onOpen={open} />
)}
</div>
)
})}
</>
)
}
function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) {
const all = collectEntries(group).filter((entry) => matches(entry, keywordLower))
if (all.length === 0) return null
return (
<div key={group.key} className="card-group-block">
{showTitle && <h3 className="card-group-title">{group.title}</h3>}
{renderEntries(group, all, { limit })}
</div>
)
}
function renderLocalService(card: CardItem) {
const items: AppItem[] = projects.map((project) => ({
id: project.name,
name: project.name,
desc: project.route_path,
icon: project.name.slice(0, 1).toUpperCase(),
href: project.route_path,
badge: '本地项目',
comment: '本地静态站点,由 Stash 直接托管。',
views: 0,
likes: 0,
}))
return (
<SectionCard key={card.id} title="本地服务" count={items.length} barColor="#3858f6">
{items.length > 0 ? (
<AppGrid items={items} />
) : (
<Empty text="还没有项目:在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。" />
)}
</SectionCard>
)
}
function renderCard(card: CardItem) {
const resolved = card.groups
.map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) }))
.filter(
(item): item is { ref: typeof item.ref; group: GroupNode } =>
item.group !== undefined && item.group.enabled !== false,
)
const total = resolved.reduce((sum, item) => sum + groupEntryCount(item.group), 0)
const first = resolved[0]?.group
if (resolved.length === 0 || total === 0) return null
return (
<SectionCard
key={card.id}
title={card.title}
count={total}
barColor={first?.barColor}
>
{resolved.map(({ group, ref }) =>
renderGroupBlock(group, ref.previewLimit ?? defaultLimit(group), resolved.length > 1),
)}
</SectionCard>
)
}
function renderHome() {
const cards = manage.cards
return (
<div className="home-view">
{loading && <Empty text="正在读取内容库…" />}
{!loading && cards.length === 0 && <Empty text="后台还没有创建首页卡片" />}
{cards.map((card) =>
card.key === 'local-service' ? renderLocalService(card) : renderCard(card),
)}
{keywordLower !== '' && cards.length > 0 && (
<Notice title={`搜索结果:${keyword.trim()}`}>在卡片与分组内按标题过滤。</Notice>
)}
</div>
)
}
function renderGroupPage(key: string) {
const group = findGroup(manage.groups, key)
if (group === undefined) {
return <Empty text={loading ? '正在读取内容库…' : '分组不存在'} />
}
const trail = findPath(manage.groups, key) ?? [group]
return (
<div className="detail-view">
<nav className="detail-breadcrumb group-crumbs">
<button type="button" className="link-button" onClick={() => navigate('/')}>
首页
</button>
{trail.map((node) => (
<span key={node.key}>
<span className="group-crumb-sep">/</span>
<button
type="button"
className="link-button"
onClick={() => navigate(`/g/${encodeURIComponent(node.key)}`)}
>
{node.title}
</button>
</span>
))}
</nav>
<div className="detail-hero">
<span className="detail-hero-icon" aria-hidden="true">
{group.icon || '•'}
</span>
<h2 className="detail-hero-title">{group.title}</h2>
<span className="detail-hero-count">[{groupEntryCount(group)}]</span>
</div>
{group.entries.length > 0 ? (
renderEntries(group, group.entries)
) : group.children.some((child) => groupEntryCount(child) > 0) ? null : (
<Empty text="这个分组还没有内容" />
)}
<Space direction="column" gap="lg" align="stretch">
{group.children.map((child) => (
<section key={child.key} className="group-child-block">
<div className="group-title">
<span
className="group-bar"
style={{ backgroundColor: child.barColor || undefined }}
aria-hidden="true"
/>
<h3>{child.title}</h3>
<button
type="button"
className="link-button"
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
>
查看全部
</button>
</div>
{child.entries.length > 0 ? (
renderEntries(child, child.entries, { limit: 12 })
) : (
<Empty text="暂无内容" />
)}
</section>
))}
</Space>
</div>
)
}
function renderItemPage(key: string, itemId: string) {
const found = findEntry(manage.groups, key, itemId)
if (found === undefined) {
return <Empty text={loading ? '正在读取内容库…' : '条目不存在'} />
}
const { group, entry } = found
const trail = findPath(manage.groups, group.key) ?? [group]
const crumbs = [
{ label: '首页', href: '/' },
...trail.map((node) => ({
label: node.title,
href: `/g/${encodeURIComponent(node.key)}`,
})),
{ label: String(entry.name ?? entry.title ?? entry.id) },
]
const format = entryFormat(entry)
if (format === 'articles') {
const article = entry as unknown as ArticleItem
return (
<ArticleDetailView
article={article}
categoryId={group.key}
categoryTitle={group.title}
onNavigate={navigate}
onEdit={() => setEditingKey(String(entry.id))}
canEdit={session?.authenticated === true}
/>
)
}
const model: ItemDetailModel =
format === 'posters'
? posterDetailModel(entry as unknown as PosterItem)
: appDetailModel(entry as unknown as AppItem)
const related: RelatedItem[] =
format === 'posters'
? group.entries
.filter((item) => item.id !== entry.id && entryFormat(item as unknown as Record<string, unknown>) === format)
.slice(0, 4)
.map((item) => posterToRelatedItem(item as unknown as PosterItem))
: group.entries
.filter((item) => item.id !== entry.id && entryFormat(item as unknown as Record<string, unknown>) === format)
.slice(0, 4)
.map((item) => toRelatedItem(item as unknown as AppItem))
return (
<ItemDetailView
itemKey={String(entry.id)}
crumbs={crumbs}
model={model}
related={related}
onNavigate={navigate}
onOpenItem={(next) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(next)}`)}
onEdit={() => setEditingKey(String(entry.id))}
canEdit={session?.authenticated === true}
/>
)
}
return (
<>
<Toaster />
<LoginDialog
open={loginOpen}
hasAdmin={session?.has_admin ?? true}
onClose={() => setLoginOpen(false)}
onSuccess={(next) => {
setSession(next)
setLoginOpen(false)
}}
/>
<ItemEditor
open={editingKey !== null}
itemKey={editingKey}
onClose={() => setEditingKey(null)}
onSaved={() => {
setEditingKey(null)
load()
}}
/>
<div className="site-background" aria-hidden="true" />
<Layout
contained
maxWidth={1380}
gutter={20}
siderWidth={240}
gap={20}
headerHeight={60}
headerVariant="solid"
siderBordered={false}
contentPadding="none"
contentOffset={88}
height="screen"
scrollable
hideScrollbar
contentId="content-scroll"
className="app"
header={
<SiteHeader
projectCount={projects.length}
sampleProject={projects[0]?.name}
live={live}
headerItems={site.header.items}
headerEnabled={site.header.enabled}
headerSpeed={site.header.speed}
authenticated={session?.authenticated === true}
username={session?.username ?? undefined}
onFocusSearch={() => searchRef.current?.focus()}
onLogin={() => setLoginOpen(true)}
onLogout={() => {
void logout().then(() => setSession({ authenticated: false, has_admin: true }))
}}
onSettings={() => window.location.assign('/admin')}
/>
}
sider={
<div className="sidebar">
{site.sidebar.show_profile !== false && <ProfileCard profile={site.profile} />}
{site.sidebar.show_service !== false && (
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={refresh}
/>
)}
<div className="nav-card">
<Menu
items={sidebarItems}
activeKey={activeKey}
itemHeight={39}
onSelect={(key) => {
if (key === 'home') navigate('/')
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
}}
/>
</div>
</div>
}
>
<div className="content">
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
<h1 className="sr-only">Stash 本地应用工作区</h1>
{status === 'error' && (
<Notice
tone="error"
title="连不上 Stash 服务"
action={{ label: '重试', onClick: refresh }}
>
{error}
</Notice>
)}
{route.kind === 'home' && renderHome()}
{route.kind === 'group' && renderGroupPage(route.key)}
{route.kind === 'item' && renderItemPage(route.key, route.itemId)}
</div>
</Layout>
<BackToTop visible={scrolled} targetId="page-scroll" />
</>
)
}
+62 -1
View File
@@ -1,3 +1,5 @@
import type { Category } from './data/types'
export type Project = {
name: string
route_path: string
@@ -9,7 +11,6 @@ type ProjectsPayload = {
const PROJECTS_ENDPOINT = '/api/projects'
const EVENTS_ENDPOINT = '/api/projects/events'
/** 读取服务端扫描出的项目列表。 */
export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
const response = await fetch(PROJECTS_ENDPOINT, {
@@ -67,3 +68,63 @@ export function subscribeProjects(handlers: ProjectEventsHandlers): () => void {
source.close()
}
}
// ---------------------------------------------------------------------------
// 内容目录:数据来自后端的 SQLite 内容库(data/stash.db),不再是前端静态数组
// ---------------------------------------------------------------------------
const CATALOG_ENDPOINT = '/api/catalog'
const CATALOG_EVENTS_ENDPOINT = '/api/catalog/events'
type CatalogPayload = {
categories?: Category[]
}
/** 内容目录的完整返回;侧栏由这份分类树派生 */
export type Catalog = {
categories: Category[]
}
/**
* 拉取完整内容目录。
*
* 数据量只有几十 KB,一次全量取回即可;搜索仍然在前端按关键字过滤。
*/
export async function fetchCatalog(signal?: AbortSignal): Promise<Catalog> {
const response = await fetch(CATALOG_ENDPOINT, {
signal,
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(`请求 ${CATALOG_ENDPOINT} 失败:HTTP ${response.status}`)
}
const payload = (await response.json()) as CatalogPayload
return {
categories: Array.isArray(payload.categories) ? payload.categories : [],
}
}
/**
* 订阅内容库变更。
*
* 服务端用 SQLite 的 data_version 检测「外部工具改了库」,事件里不带数据,
* 只表示「该重新拉一次了」,避免两套数据结构需要保持同步。
*/
export function subscribeCatalog(onChange: () => void): () => void {
if (typeof EventSource === 'undefined') {
return () => {}
}
const source = new EventSource(CATALOG_EVENTS_ENDPOINT)
const handleChange = () => onChange()
source.addEventListener('catalog', handleChange)
return () => {
source.removeEventListener('catalog', handleChange)
source.close()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 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 })
}
-130
View File
@@ -1,130 +0,0 @@
/**
* 占位数据:沿用 index9 设计稿里的分类与卡片。
*
* 后端目前只提供扁平的项目列表,没有分类概念,
* 所以这里的内容仅用于撑起界面结构,等接口支持分类后再替换。
*/
export type CatalogCard = {
id: string
title: string
desc: string
href: string
/** 卡片左侧的字符标记 */
initial: string
/** desc 是路径时用等宽字体 */
mono?: boolean
}
export type CatalogSection = {
id: string
title: string
cards: CatalogCard[]
}
export type NavLink = {
id: string
label: string
}
export type NavGroup = {
id: string
label: string
links: NavLink[]
}
/** 侧栏顶层链接 */
export const MOCK_NAV_PRIMARY: NavLink[] = [{ id: 'common', label: '常用推荐' }]
/** 侧栏可折叠分组 */
export const MOCK_NAV_GROUPS: NavGroup[] = [
{
id: 'assets',
label: '素材资源',
links: [
{ id: 'design', label: 'UI 设计资源' },
{ id: 'icon', label: '矢量图标' },
{ id: 'photo', label: '免费壁纸' },
],
},
{
id: 'tools',
label: '开发工具',
links: [
{ id: 'dev', label: '前端调试工具' },
{ id: 'code', label: '在线编辑器' },
],
},
]
/** 内容区占位分区,顺序与设计稿一致 */
export const MOCK_SECTIONS: CatalogSection[] = [
{
id: 'common',
title: '常用推荐',
cards: [
{
id: 'github',
title: 'GitHub',
desc: '代码托管与开源社区',
href: '#',
initial: 'G',
},
{ id: 'zhihu', title: '知乎', desc: '深度内容问答社区', href: '#', initial: 'Z' },
{
id: 'bilibili',
title: '哔哩哔哩',
desc: '弹幕视频分享网站',
href: '#',
initial: 'B',
},
{
id: 'v2ex',
title: 'V2EX',
desc: '创意工作者讨论社区',
href: '#',
initial: 'V',
},
],
},
{
id: 'dev',
title: '前端调试工具',
cards: [
{
id: 'stackoverflow',
title: 'Stack Overflow',
desc: '程序员技术问答',
href: '#',
initial: 'S',
},
{ id: 'juejin', title: '掘金', desc: '开发者技术社区', href: '#', initial: 'J' },
],
},
{
id: 'code',
title: '在线编辑器',
cards: [
{
id: 'codepen',
title: 'CodePen',
desc: '前端在线代码演练场',
href: '#',
initial: 'C',
},
],
},
{
id: 'design',
title: 'UI 设计资源',
cards: [
{
id: 'dribbble',
title: 'Dribbble',
desc: '全球设计作品分享社区',
href: '#',
initial: 'D',
},
],
},
]
-22
View File
@@ -1,22 +0,0 @@
export function BackToTop() {
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"
title="回到顶部"
aria-label="回到顶部"
onClick={handleClick}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<line x1="5" y1="4" x2="19" y2="4" />
<path d="M12 20V8M6 14l6-6 6 6" />
</svg>
</button>
)
}
-23
View File
@@ -1,23 +0,0 @@
import { PLACEHOLDER_PROFILE } from '../profile'
/** 登录后展示的用户资料卡(数据为占位,见 profile.ts)。 */
export function ProfileCard() {
const { name, role, bio, location } = PLACEHOLDER_PROFILE
return (
<section className="profile-card card-box" aria-label="用户信息">
<div className="profile-avatar" aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="12" cy="9" r="3.4" />
<path d="M5.6 19.4a6.6 6.6 0 0 1 12.8 0" />
</svg>
<span className="profile-badge">✓</span>
</div>
<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>
)
}
-65
View File
@@ -1,65 +0,0 @@
import type { Project } from '../api'
import { LinkIcon, RefreshIcon } from './icons'
type ServicePanelProps = {
projects: Project[]
live: boolean
refreshing: boolean
onRefresh: () => void
}
/**
* 本地服务面板:状态概览 + 操作入口。
* 项目列表本身在右侧内容区以卡片呈现,这里不再展开。
*/
export function ServicePanel({ projects, live, refreshing, onRefresh }: 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" />
{live ? '已连接' : '未连接'}
</span>
</header>
<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-actions">
<button
type="button"
className="service-action"
title="重新扫描项目"
data-spinning={refreshing}
disabled={refreshing}
onClick={onRefresh}
>
<RefreshIcon />
<span>重新扫描</span>
</button>
<a
className="service-action"
href="/api/projects"
target="_blank"
rel="noreferrer"
title="查看项目接口"
>
<LinkIcon />
<span>查看接口</span>
</a>
</div>
</section>
)
}
-84
View File
@@ -1,84 +0,0 @@
import { useState } from 'react'
import type { Project } from '../api'
import { MOCK_NAV_GROUPS, MOCK_NAV_PRIMARY } from '../catalog'
import { ProfileCard } from './ProfileCard'
import { ServicePanel } from './ServicePanel'
type SidebarProps = {
projects: Project[]
live: boolean
refreshing: boolean
onRefresh: () => void
}
export function Sidebar({ projects, live, refreshing, onRefresh }: SidebarProps) {
const [openGroups, setOpenGroups] = useState<string[]>([])
const [activeId, setActiveId] = useState('common')
const toggle = (id: string) => {
setOpenGroups((previous) =>
previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id],
)
}
const isOpen = (id: string) => openGroups.includes(id)
return (
<aside className="sidebar">
<ProfileCard />
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={onRefresh}
/>
<nav className="nav-card card-box" aria-label="站点分类">
<ul className="nav-group">
{MOCK_NAV_PRIMARY.map((link) => (
<li className="nav-item" key={link.id}>
<a
className={`nav-link${activeId === link.id ? ' active' : ''}`}
href={`#${link.id}`}
onClick={() => setActiveId(link.id)}
>
<span>{link.label}</span>
</a>
</li>
))}
{MOCK_NAV_GROUPS.map((group) => (
<li className={`nav-item${isOpen(group.id) ? ' open' : ''}`} key={group.id}>
<button
type="button"
className="nav-link"
aria-expanded={isOpen(group.id)}
onClick={() => toggle(group.id)}
>
<span>{group.label}</span>
<span className="nav-arrow" aria-hidden="true">
›
</span>
</button>
<ul className="submenu">
{group.links.map((link) => (
<li key={link.id}>
<a
className={`nav-link${activeId === link.id ? ' active' : ''}`}
href={`#${link.id}`}
onClick={() => setActiveId(link.id)}
>
<span>{link.label}</span>
</a>
</li>
))}
</ul>
</li>
))}
</ul>
</nav>
</aside>
)
}
-26
View File
@@ -1,26 +0,0 @@
import type { CatalogCard } from '../catalog'
type SiteCardProps = {
card: CatalogCard
index: number
}
export function SiteCard({ card, index }: SiteCardProps) {
return (
<a
className="card"
href={card.href}
title={card.title}
// 轻微错峰入场,最多延迟 12 个卡片
style={{ animationDelay: `${Math.min(index, 12) * 24}ms` }}
>
<span className="card-icon" aria-hidden="true">
{card.initial}
</span>
<span className="card-body">
<span className="card-title">{card.title}</span>
<span className={`card-desc${card.mono ? ' is-mono' : ''}`}>{card.desc}</span>
</span>
</a>
)
}
-89
View File
@@ -1,89 +0,0 @@
import { useMemo } from 'react'
import type { Project } from '../api'
import { LoginIcon, SearchIcon } from './icons'
type TopBarProps = {
projects: Project[]
live: boolean
onFocusSearch: () => void
}
type TickerItem = {
tag: string
text: string
}
export function TopBar({ projects, live, onFocusSearch }: TopBarProps) {
return (
<header className="topbar">
<div className="topbar-inner">
<a className="brand" href="/" aria-label="Stash 首页">
<span className="brand-mark" aria-hidden="true">
S
</span>
<span className="brand-name">STASH</span>
</a>
<Ticker projects={projects} live={live} />
<div className="top-actions">
<button
type="button"
className="icon-button"
title="搜索(Ctrl+K)"
aria-label="聚焦搜索框"
onClick={onFocusSearch}
>
<SearchIcon />
</button>
{/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */}
<button type="button" className="icon-button" title="登录" aria-label="登录">
<LoginIcon />
</button>
</div>
</div>
</header>
)
}
/** 顶栏跑马灯:展示连接状态、项目数量与前几个项目名。 */
function Ticker({ projects, live }: { projects: Project[]; live: boolean }) {
const items = useMemo(() => buildTickerItems(projects, live), [projects, live])
// 复制一份实现无缝循环,动画位移为 -50%
const loop = [...items, ...items]
return (
<div className="ticker" aria-hidden="true">
<div className="ticker-track">
{loop.map((item, index) => (
<span className="ticker-item" key={`${item.tag}-${item.text}-${index}`}>
<span className="ticker-tag">{item.tag}</span>
{item.text}
</span>
))}
</div>
</div>
)
}
function buildTickerItems(projects: Project[], live: boolean): TickerItem[] {
const items: TickerItem[] = [
{ tag: '状态', text: live ? '实时同步已连接' : '等待服务连接' },
{ tag: '项目', text: `共 ${projects.length} 个本地应用` },
]
if (projects.length === 0) {
items.push({
tag: '提示',
text: '把静态站点放进托管目录,并保留 index.html,就会出现在这里',
})
return items
}
for (const project of projects.slice(0, 6)) {
items.push({ tag: '本地', text: project.name })
}
return items
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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>
)
}
@@ -0,0 +1,16 @@
import { ScrollTop } from '@stash/ui'
/**
* 回到顶部:直接用组件库的 ScrollTop,滚动容器是应用的内容区。
*/
type BackToTopProps = {
/** 页面是否已滚动(由 App 传入) */
visible: boolean
/** 滚动容器 id,默认旧版内容区 */
targetId?: string
}
export function BackToTop({ visible, targetId = 'content-scroll' }: BackToTopProps) {
return <ScrollTop visible={visible} targetId={targetId} offset={30} />
}
@@ -0,0 +1,13 @@
import { Badge } from '@stash/ui'
/**
* 数量角标:贴在分区标题旁边的小红标。
* 组件库的 Badge 已经处理了两位数和 99+ 的宽度,这里只做语义封装。
*/
type CountBadgeProps = {
count: number
}
export function CountBadge({ count }: CountBadgeProps) {
return <Badge count={count} tone="danger" />
}
+57
View File
@@ -0,0 +1,57 @@
import { Icon } from '@stash/ui'
/**
* 语义图标:stash 内部一直按用途引用(SearchIcon / RefreshIcon…),
* 这里统一映射到 @stash/ui 的 iconfont 字形,避免各页面记字形名。
*/
type IconProps = {
className?: string
}
export function SearchIcon({ className }: IconProps) {
return <Icon name="search_3_line" className={className} />
}
export function UserIcon({ className }: IconProps) {
return <Icon name="user_3_line" className={className} />
}
export function RefreshIcon({ className }: IconProps) {
return <Icon name="refresh_3_line" className={className} />
}
export function LinkIcon({ className }: IconProps) {
return <Icon name="link_3_line" className={className} />
}
export function ExternalIcon({ className }: IconProps) {
return <Icon name="a-external_link_line1" className={className} />
}
export function EyeIcon({ className }: IconProps) {
return <Icon name="eye_line" className={className} />
}
export function ThumbUpIcon({ className }: IconProps) {
return <Icon name="thumb_up_2_line" className={className} />
}
export function ShareIcon({ className }: IconProps) {
return <Icon name="share_3_line" className={className} />
}
export function CloseIcon({ className }: IconProps) {
return <Icon name="close_line" className={className} />
}
export function MoreIcon({ className }: IconProps) {
return <Icon name="more_1_line" className={className} />
}
export function ArrowUpIcon({ className }: IconProps) {
return <Icon name="arrow_up_line" className={className} />
}
export function ArrowDownIcon({ className }: IconProps) {
return <Icon name="arrow_down_line" className={className} />
}
@@ -0,0 +1,48 @@
import { Avatar, ItemBadge, Space, Text } from '@stash/ui'
import type { AppItem } from '../../data/types'
/**
* 应用卡片:头像 + 名称(热门带 HOT 角标)+ 单行描述。
* 点击进入条目详情;按住修饰键时保持浏览器默认行为(新标签打开原始链接)。
*/
type AppCardProps = {
item: AppItem
onOpen?: (itemId: string) => void
}
export function AppCard({ item, onOpen }: AppCardProps) {
return (
<a
className="app-card"
href={item.href}
title={item.name}
onClick={(event) => {
if (onOpen === undefined) return
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
onOpen(item.id)
}}
>
<Avatar
size="lg"
tone="primary"
className="app-card-avatar"
src={item.cover}
fallback={item.icon}
verified={item.hot === true}
/>
<div className="app-card-info">
<Space gap="xs">
<Text strong truncate className="app-card-name">
{item.name}
</Text>
{item.hot === true && <ItemBadge tone="hot">HOT</ItemBadge>}
</Space>
<Text size="xs" tone="muted" truncate>
{item.desc}
</Text>
</div>
</a>
)
}
@@ -0,0 +1,27 @@
import { Grid } from '@stash/ui'
import type { AppItem } from '../../data/types'
import { AppCard } from './AppCard'
/** 应用网格:桌面 4 列,平板 / 手机 2 列(断点由组件库 Grid 负责) */
type AppGridProps = {
items: AppItem[]
/** 明细页使用更宽松的间距 */
variant?: 'home' | 'detail'
/** 点击卡片打开条目详情 */
onOpen?: (itemId: string) => void
}
export function AppGrid({ items, variant = 'home', onOpen }: AppGridProps) {
return (
<Grid
columns={4}
tabletColumns={2}
mobileColumns={2}
gap={variant === 'detail' ? 'lg' : 'md'}
>
{items.map((item) => (
<AppCard key={item.id} item={item} onOpen={onOpen} />
))}
</Grid>
)
}
@@ -0,0 +1,133 @@
import { Paragraph, Title } from '@stash/ui'
import type { ReactNode } from 'react'
/**
* 极简 Markdown 渲染:只覆盖文章正文实际用到的子集。
*
* 支持:
* - `## 小标题`(1~6 级井号都渲染成同一级小标题,标题层级由版式决定)
* - 空行分段
* - 单独一行的 `![alt](url)` 作为配图
* - 行内 `**粗体**`、`` `代码` ``、`[文字](链接)`
*
* 不支持列表、表格、引用块——这里渲染的是文章正文,不是通用 Markdown 编辑器。
*/
type Block =
| { kind: 'heading'; text: string }
| { kind: 'image'; alt: string; src: string }
| { kind: 'paragraph'; text: string }
/** 行内语法:粗体 / 代码 / 链接;其余按纯文本处理 */
const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g
/** 把 Markdown 文本切成块;规则尽量简单,宁可少解析也不误伤正文 */
function parseBlocks(markdown: string): Block[] {
const blocks: Block[] = []
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
let paragraph: string[] = []
// 空行或遇到标题/图片时,把攒着的行收成一个段落
const flush = () => {
if (paragraph.length === 0) return
blocks.push({ kind: 'paragraph', text: paragraph.join(' ') })
paragraph = []
}
for (const raw of lines) {
const line = raw.trim()
if (line === '') {
flush()
continue
}
const heading = /^#{1,6}\s+(.*)$/.exec(line)
if (heading !== null) {
flush()
blocks.push({ kind: 'heading', text: heading[1] })
continue
}
const image = /^!\[([^\]]*)\]\(([^)\s]+)\)$/.exec(line)
if (image !== null) {
flush()
blocks.push({ kind: 'image', alt: image[1], src: image[2] })
continue
}
paragraph.push(line)
}
flush()
return blocks
}
/** 渲染一行里的行内语法 */
function renderInline(text: string): ReactNode[] {
return text
.split(INLINE_PATTERN)
.filter((part) => part !== '')
.map((part, index) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={index}>{part.slice(2, -2)}</strong>
}
if (part.startsWith('`') && part.endsWith('`')) {
return <code key={index}>{part.slice(1, -1)}</code>
}
const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part)
if (link !== null) {
return (
<a key={index} href={link[2]} target="_blank" rel="noreferrer">
{link[1]}
</a>
)
}
return <span key={index}>{part}</span>
})
}
type ArticleBodyProps = {
/** Markdown 正文;为空时组件渲染空内容 */
markdown?: string
}
export function ArticleBody({ markdown }: ArticleBodyProps) {
if (markdown === undefined || markdown.trim() === '') return null
return (
<>
{parseBlocks(markdown).map((block, index) => {
if (block.kind === 'heading') {
return (
<Title level={2} as="h3" key={index}>
{renderInline(block.text)}
</Title>
)
}
if (block.kind === 'image') {
return (
<img
className="article-body-image"
key={index}
src={block.src}
alt={block.alt}
loading="lazy"
/>
)
}
return (
<Paragraph tone="sub" size="md" leading="relaxed" key={index}>
{renderInline(block.text)}
</Paragraph>
)
})}
</>
)
}
@@ -0,0 +1,23 @@
import { Cover } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
/**
* 首页图文卡:4:3 封面 + 底部渐变条上的标题。
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
*/
type ArticleCardProps = {
item: ArticleItem
onOpen?: (itemId: string) => void
}
export function ArticleCard({ item, onOpen }: ArticleCardProps) {
return (
<Cover
src={item.cover}
alt={item.title}
caption={item.title}
ratio="article"
onClick={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
)
}
@@ -0,0 +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
onNavigate: (href: string) => void
onEdit: () => void
/** 只有登录后才显示 [编辑] */
canEdit: boolean
}
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">
<Card padding="none" className="detail-nav">
<Breadcrumb className="detail-breadcrumb" items={crumbs} onNavigate={onNavigate} />
</Card>
<header className="article-head">
<Title level={1} as="h2" className="article-heading">
{article.title}
{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 size="xs" tone="muted">
{text}
</Text>
</span>
))}
</div>
</header>
<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>
)
}
@@ -0,0 +1,20 @@
import { Grid } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
import { ArticleCard } from './ArticleCard'
/** 图文网格:桌面 4 列,平板 2 列,手机 1 列 */
type ArticleGridProps = {
items: ArticleItem[]
/** 点击卡片打开文章详情 */
onOpen?: (itemId: string) => void
}
export function ArticleGrid({ items, onOpen }: ArticleGridProps) {
return (
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="md">
{items.map((item) => (
<ArticleCard key={item.id} item={item} onOpen={onOpen} />
))}
</Grid>
)
}
@@ -0,0 +1,48 @@
import { Card, Cover, Space, Text } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
/**
* 明细页图文卡:上图下文的白卡。
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
*/
type CesCardProps = {
item: ArticleItem
onOpen?: (itemId: string) => void
}
export function CesCard({ item, onOpen }: CesCardProps) {
const body = (
<Space direction="column" gap="xs" align="stretch">
<Text strong truncate>
{item.title}
</Text>
<Text size="xs" tone="muted" truncate>
{item.desc}
</Text>
</Space>
)
return (
<Card padding="none" hoverable className="ces-card">
<Cover
src={item.cover}
alt={item.title}
ratio={16 / 10}
radius="none"
onClick={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
{onOpen === undefined ? (
<div className="ces-body">{body}</div>
) : (
<button
type="button"
className="ces-body ces-body--button"
title={item.title}
onClick={() => onOpen(item.id)}
>
{body}
</button>
)}
</Card>
)
}
@@ -0,0 +1,20 @@
import { Grid } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
import { CesCard } from './CesCard'
/** 明细页图文网格:桌面 4 列,平板 2 列,手机 1 列 */
type CesCardGridProps = {
items: ArticleItem[]
/** 点击卡片打开文章详情 */
onOpen?: (itemId: string) => void
}
export function CesCardGrid({ items, onOpen }: CesCardGridProps) {
return (
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="lg">
{items.map((item) => (
<CesCard key={item.id} item={item} onOpen={onOpen} />
))}
</Grid>
)
}
@@ -0,0 +1,138 @@
import { Breadcrumb, Card, Empty, Space, Title } from '@stash/ui'
import {
categoryCount,
type AppItem,
type ArticleItem,
type Category,
type PosterItem,
} from '../../data/types'
import { viewPath } from '../../data/view'
import { AppGrid } from './AppGrid'
import { CesCardGrid } from './CesCardGrid'
import { GroupBlock } from './GroupBlock'
import { PosterGrid } from './PosterGrid'
/**
* 分类明细视图:面包屑 + hero(图标 / 标题 / 数量)+ 内容区。
*
* 内容区按分类类型切换:
* - apps:指定分组时只渲染该组,否则单组扁平渲染、多组带小标题分组渲染;
* - posters:6 列海报;
* - articles:上图下文白卡网格。
*/
type DetailViewProps = {
category: Category
/** 从侧栏子菜单进入时指定的分组 */
groupId?: string
/** 点击面包屑中间项(SPA 路由) */
onNavigate: (href: string) => void
/** 点击卡片 / 海报进入条目详情 */
onOpenItem: (itemId: string) => void
}
export function DetailView({ category, groupId, onNavigate, onOpenItem }: DetailViewProps) {
// 三种类型共用「分区 → 分组 → 条目」结构;这里抹平联合类型,渲染时再按 kind 分派
type RenderItem = AppItem | PosterItem | ArticleItem
const groups = category.groups as {
id: string
name: string
items: RenderItem[]
}[]
const activeGroup =
groupId !== undefined ? groups.find((group) => group.id === groupId) : undefined
const title = activeGroup?.name ?? category.title
const count = activeGroup !== undefined ? activeGroup.items.length : categoryCount(category)
const crumbs =
activeGroup !== undefined
? [
{ label: '首页', href: '/' },
{ label: category.title, href: viewPath({ type: 'detail', categoryId: category.id }) },
{ label: activeGroup.name },
]
: [
{ label: '首页', href: '/' },
{ label: category.title, href: viewPath({ type: 'detail', categoryId: category.id }) },
]
/** 按类型渲染一组条目 */
function renderItems(items: RenderItem[]) {
if (category.kind === 'posters') {
return <PosterGrid items={items as PosterItem[]} variant="detail" onOpen={onOpenItem} />
}
if (category.kind === 'articles') {
return <CesCardGrid items={items as ArticleItem[]} onOpen={onOpenItem} />
}
return <AppGrid items={items as AppItem[]} variant="detail" onOpen={onOpenItem} />
}
function renderBody() {
if (activeGroup !== undefined) {
return renderItems(activeGroup.items)
}
const direct = (category.items ?? []) as RenderItem[]
// 空分组不占篇幅(管理后台仍然保留,之后可以往里加条目)
const filledGroups = groups.filter((group) => group.items.length > 0)
if (filledGroups.length === 0) {
return direct.length > 0 ? (
renderItems(direct)
) : (
<Empty text={'emptyText' in category ? (category.emptyText ?? '暂无内容') : '暂无内容'} />
)
}
if (filledGroups.length === 1 && direct.length === 0) {
return renderItems(filledGroups[0].items)
}
return (
<Space direction="column" gap="lg" align="stretch">
{filledGroups.map((group) => (
<GroupBlock
key={group.id}
name={group.name}
barColor={category.barColor !== '' ? category.barColor : undefined}
>
{renderItems(group.items)}
</GroupBlock>
))}
{direct.length > 0 && (
<GroupBlock
name="未分组"
barColor={category.barColor !== '' ? category.barColor : undefined}
>
{renderItems(direct)}
</GroupBlock>
)}
</Space>
)
}
return (
<div className="detail-view">
<Card padding="none" className="detail-nav">
<Breadcrumb
className="detail-breadcrumb"
items={crumbs}
onNavigate={onNavigate}
/>
</Card>
<div className="detail-hero">
<span className="detail-hero-icon" aria-hidden="true">
{category.icon}
</span>
<Title level={1} as="h2" weight="strong" className="detail-hero-title">
{title}
</Title>
<span className="detail-hero-count">[{count}]</span>
</div>
{renderBody()}
</div>
)
}
@@ -0,0 +1,33 @@
import { Space, Title } from '@stash/ui'
import type { ReactNode } from 'react'
/**
* 明细页的分组块:色条 + 小标题 + 该组的内容网格。
*
* 色条沿用分区的 `barColor`,与首页卡片标题保持同一套视觉语言;
* 具体网格由调用方传入(应用 / 海报 / 图文共用同一层外壳)。
*/
type GroupBlockProps = {
name: string
/** 分区色条颜色;缺省用主题主色 */
barColor?: string
children: ReactNode
}
export function GroupBlock({ name, barColor, children }: GroupBlockProps) {
return (
<Space direction="column" gap="sm" align="stretch" className="group-block">
<div className="group-title">
<span
className="group-bar"
style={barColor !== undefined ? { backgroundColor: barColor } : undefined}
aria-hidden="true"
/>
<Title level={4} as="h3">
{name}
</Title>
</div>
{children}
</Space>
)
}
@@ -0,0 +1,118 @@
import { categoryCount, flattenAppItems, type Category } from '../../data/types'
import { AppGrid } from './AppGrid'
import { ArticleGrid } from './ArticleGrid'
import { Empty } from '@stash/ui'
import { PosterGrid } from './PosterGrid'
import { SectionCard } from './SectionCard'
import {
applySectionSettings,
sectionLimit,
sectionTitle,
type SectionSettings,
} from '../../settings'
/**
* 首页:把每个分类渲染成一张分区卡片。
*
* 每个分区只展示前若干条;标题、条数、顺序与显隐都可以在 `/admin`
* 的「首页卡片」里覆盖(见 settings.sections)。
*/
type HomeViewProps = {
categories: Category[]
sections: SectionSettings
onOpenDetail: (categoryId: string) => void
onOpenItem: (categoryId: string, itemId: string) => void
}
export function HomeView({ categories, sections, onOpenDetail, onOpenItem }: HomeViewProps) {
const cards = applySectionSettings(categories, sections)
return (
<div className="home-view">
{cards.map((category) => (
<SectionCard
key={category.id}
// 卡片标题跟着实际展示的内容走:配了展示块就用块标题(如「常用站点」),
// 没配(如「本地服务」)才用分类标题;设置里的覆盖值优先
title={sectionTitle(
category.id,
category.block?.title ?? category.title,
sections,
)}
count={cardCount(category)}
barColor={category.barColor}
onMore={() => onOpenDetail(category.id)}
>
{renderPreview(category, sections, onOpenItem)}
</SectionCard>
))}
</div>
)
}
/**
* 卡片右上角的数量角标。
*
* 配了「分组展示块」时,卡片实际展示的是那个分组,所以数量也应该只数它;
* 否则(电影 / 文章 / 本地服务)数整个分区。
*/
function cardCount(category: Category): number {
if (category.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) return group.items.length
}
return categoryCount(category)
}
/** 某个分区配的「首页预览分组」;没配或找不到返回 undefined */
function blockGroup<T>(category: { block?: { nodeKey?: string }; groups: { id: string; items: T[] }[] }) {
if (category.block?.nodeKey === undefined) return undefined
return category.groups.find((group) => group.id === category.block?.nodeKey)
}
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
function renderPreview(
category: Category,
sections: SectionSettings,
onOpenItem: (categoryId: string, itemId: string) => void,
) {
if (category.kind === 'apps') {
// 配了展示块且指向分组时只展示该组;展示块指向分区本身(「整个分区」)时展示全部条目
const group = blockGroup(category)
const source = group !== undefined ? group.items : flattenAppItems(category)
const fallback = category.block?.previewLimit ?? category.previewLimit ?? 4
const limit = sectionLimit(category.id, fallback, sections)
const items = source.slice(0, limit)
if (items.length === 0) {
return <Empty text={category.emptyText ?? '暂无内容'} />
}
return <AppGrid items={items} onOpen={(itemId) => onOpenItem(category.id, itemId)} />
}
if (category.kind === 'posters') {
const group = blockGroup(category)
const source = group
? group.items
: [...category.items, ...category.groups.flatMap((item) => item.items)]
return (
<PosterGrid
items={source.slice(0, sectionLimit(category.id, category.previewLimit ?? 6, sections))}
onOpen={(itemId) => onOpenItem(category.id, itemId)}
/>
)
}
const group = blockGroup(category)
const source = group
? group.items
: [...category.items, ...category.groups.flatMap((item) => item.items)]
return (
<ArticleGrid
items={source.slice(0, sectionLimit(category.id, category.previewLimit ?? 4, sections))}
onOpen={(itemId) => onOpenItem(category.id, itemId)}
/>
)
}
@@ -0,0 +1,230 @@
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 { RelatedList } from './RelatedList'
/**
* 条目详情页:应用与电影共用。
*
* 版式:面包屑 + 标题 + 主图(方形头像 / 竖版海报)+ 标签行 + 一句话评价 + 正文
* + 操作条(浏览 / 点赞 / 分享 / 跳转)+ 可选横幅 + 「推荐阅读」。
* 具体形态由 ItemDetailModel 决定,这里不判断条目类型。
*/
type ItemDetailViewProps = {
/** 条目业务键:浏览 / 点赞要写回数据库 */
itemKey: string
crumbs: BreadcrumbItem[]
model: ItemDetailModel
related: RelatedItem[]
onNavigate: (href: string) => void
onOpenItem: (itemId: string) => void
/** 打开编辑 Drawer */
onEdit: () => void
/** 只有登录后才显示 [编辑] */
canEdit: boolean
}
export function ItemDetailView({
itemKey,
crumbs,
model,
related,
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">
<Card padding="none" className="detail-nav">
<Breadcrumb className="detail-breadcrumb" items={crumbs} onNavigate={onNavigate} />
</Card>
<Card padding="none" className="item-card">
<Title level={1} as="h2" weight="semibold" className="item-title">
{model.title}
{canEdit && (
<button type="button" className="item-edit" title="编辑条目" onClick={onEdit}>
[编辑]
</button>
)}
</Title>
<div className={`item-cover item-cover--${model.shape}`}>
{model.cover !== undefined ? (
<img src={model.cover} alt={model.title} loading="lazy" />
) : (
<span className="item-cover-letter">{model.fallbackIcon ?? '•'}</span>
)}
</div>
{model.tags.length > 0 && (
<Space gap="sm" wrap>
{model.tags.map((tag) => (
<Tag key={tag} tone="primary" className="item-tag">
{tag}
</Tag>
))}
</Space>
)}
{model.comment !== undefined && (
<p className="item-quote">
<span className="item-quote-mark" aria-hidden="true">
“
</span>
<span className="item-quote-text">{model.comment}</span>
</p>
)}
{model.paragraphs.map((paragraph, index) => (
<Paragraph
key={`${index}-${paragraph.slice(0, 8)}`}
tone="sub"
size="md"
className="item-desc"
>
{paragraph}
</Paragraph>
))}
<div className="item-actions">
<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>
{/* 没有真实地址(空 / # / 不安全协议)时置灰,避免打开错误页面 */}
{jumpHref === undefined ? (
<button type="button" className="item-jump" disabled title="暂无跳转地址,可在编辑里添加">
跳转到
<Icon name="a-external_link_line1" />
</button>
) : (
<a className="item-jump" href={jumpHref} target="_blank" rel="noreferrer">
跳转到
<Icon name="a-external_link_line1" />
</a>
)}
</div>
{model.banner !== undefined && (
<img className="item-banner" src={model.banner} alt="" loading="lazy" />
)}
</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>
)
}
@@ -0,0 +1,23 @@
import { Cover } from '@stash/ui'
import type { PosterItem } from '../../data/types'
/**
* 竖版海报卡:2:3 封面 + 底部渐变遮罩上的标题。
* 传入 onOpen 时整张卡变成按钮,点击进入该电影的详情页。
*/
type PosterCardProps = {
item: PosterItem
onOpen?: (itemId: string) => void
}
export function PosterCard({ item, onOpen }: PosterCardProps) {
return (
<Cover
src={item.cover}
alt={item.title}
caption={item.title}
ratio="poster"
onClick={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
)
}
@@ -0,0 +1,21 @@
import { Grid } from '@stash/ui'
import type { PosterItem } from '../../data/types'
import { PosterCard } from './PosterCard'
/** 海报网格:桌面 6 列,平板 4 列,手机 3 列 */
type PosterGridProps = {
items: PosterItem[]
variant?: 'home' | 'detail'
/** 点击海报打开条目详情 */
onOpen?: (itemId: string) => void
}
export function PosterGrid({ items, onOpen }: PosterGridProps) {
return (
<Grid columns={6} tabletColumns={4} mobileColumns={3} gap="md">
{items.map((item) => (
<PosterCard key={item.id} item={item} onOpen={onOpen} />
))}
</Grid>
)
}
@@ -0,0 +1,50 @@
import { Avatar, Card, Grid, Space, Text, Title } from '@stash/ui'
import type { RelatedItem } from '../../data/types'
/**
* 详情页的「推荐阅读」:两列小卡片,头像 + 名称 + 截断描述。
* 点击任意一条会在同一视图内切换到对应条目的详情。
*/
type RelatedListProps = {
items: RelatedItem[]
onOpen: (itemId: string) => void
}
export function RelatedList({ items, onOpen }: RelatedListProps) {
if (items.length === 0) return null
return (
<Card padding="md" className="related-card">
<Title level={3} as="h3" className="related-title">
<span aria-hidden="true">📚</span>
推荐阅读
</Title>
<Grid className="related-grid" columns={2} tabletColumns={2} mobileColumns={1} gap="sm">
{items.map((item) => (
<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>
))}
</Grid>
</Card>
)
}
@@ -0,0 +1,46 @@
import { Badge, Card, Icon, IconButton, Title } from '@stash/ui'
import type { ReactNode } from 'react'
/**
* 首页分区卡片:白底容器,组成「色条 + 标题 + 角标 + 更多按钮 + 内容」。
* 内容由调用方传入,因此同一容器可以承载应用网格、海报网格或图文网格。
*/
type SectionCardProps = {
title: string
count: number
/** 标题左侧色条颜色,不同分类用不同颜色区分 */
barColor?: string
/** 点击右上角 ••• 进入该分类的明细视图 */
onMore?: () => void
children: ReactNode
}
export function SectionCard({ title, count, barColor, onMore, children }: SectionCardProps) {
return (
<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">
<Title level={3} as="h2" weight="semibold">
{title}
</Title>
<Badge count={count} tone="danger" className="count-badge" />
</span>
</span>
{onMore !== undefined && (
<IconButton
size="xs"
className="section-more"
label={`查看${title}全部`}
icon={<Icon name="more_1_line" />}
onClick={onMore}
/>
)}
</div>
{children}
</Card>
)
}
@@ -0,0 +1,143 @@
import { Icon, IconButton, Tag } from '@stash/ui'
import { fillHeaderText, type HeaderItem, type SettingsTone } from '../../settings'
/**
* 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
* 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。
*/
type SiteHeaderProps = {
projectCount: number
/** 用于展示的第一个项目名,没有项目时留空 */
sampleProject?: string
live: boolean
/** 页头滚动信息;为空时用默认三条 */
headerItems: HeaderItem[]
headerEnabled: boolean
/** 滚动一轮的秒数 */
headerSpeed: number
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */
authenticated: boolean
username?: string
onFocusSearch: () => void
onLogin: () => void
onLogout: () => void
onSettings: () => void
}
const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = {
plain: 'neutral',
neutral: 'neutral',
primary: 'primary',
success: 'success',
}
type InfoGroupProps = {
items: HeaderItem[]
projectCount: number
sampleProject?: string
live: boolean
'aria-hidden'?: boolean
}
/** 运行信息:同一份内容渲染三遍,轨道平移 1/3 形成无缝滚动 */
function InfoGroup({
items,
projectCount,
sampleProject,
live,
'aria-hidden': ariaHidden,
}: InfoGroupProps) {
const context = { count: projectCount, project: sampleProject, live }
return (
<span className="header-ticker-group" aria-hidden={ariaHidden}>
{items.map((item, index) => (
<span className="header-ticker-item" key={`${index}-${item.text}`}>
{item.tag !== undefined && item.tag !== null && item.tag !== '' && (
<Tag tone={tagTone[item.tone]} shape="rounded">
{item.tag}
</Tag>
)}
{fillHeaderText(item.text, context)}
</span>
))}
</span>
)
}
export function SiteHeader({
projectCount,
sampleProject,
live,
headerItems,
headerEnabled,
headerSpeed,
authenticated,
username,
onFocusSearch,
onLogin,
onLogout,
onSettings,
}: SiteHeaderProps) {
return (
<div className="site-header-inner">
<a className="site-brand" href="/" aria-label="Stash 首页">
<span className="site-brand-mark" aria-hidden="true">
S
</span>
<span className="site-brand-name">STASH</span>
</a>
{headerEnabled && (
<div className="header-ticker" role="status" aria-label="运行状态">
<div
className="header-ticker-track"
style={{ animationDuration: `${headerSpeed}s` }}
>
<InfoGroup
items={headerItems}
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
/>
<InfoGroup
items={headerItems}
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
// 后两份只用于视觉无缝,读屏忽略
aria-hidden
/>
<InfoGroup
items={headerItems}
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
aria-hidden
/>
</div>
</div>
)}
<div className="header-actions">
<IconButton
label="聚焦搜索框(Ctrl+K)"
icon={<Icon name="search_3_line" />}
onClick={onFocusSearch}
/>
{authenticated && (
<IconButton label="站点设置" icon={<Icon name="settings_1_line" />} onClick={onSettings} />
)}
{authenticated ? (
<IconButton
label={username === undefined ? '退出登录' : `退出登录(${username})`}
icon={<Icon name="exit_line" />}
onClick={onLogout}
/>
) : (
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
)}
</div>
</div>
)
}
-37
View File
@@ -1,37 +0,0 @@
/** 统一的小图标集合:只负责路径,尺寸与颜色由使用处的 CSS 决定。 */
export function RefreshIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
)
}
export function SearchIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
)
}
export function LinkIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.5 1.5" />
<path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.5-1.5" />
</svg>
)
}
export function LoginIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="8" r="3.6" />
<path d="M4.8 20a7.2 7.2 0 0 1 14.4 0" />
</svg>
)
}
@@ -1,5 +1,10 @@
import { Input } from '@stash/ui'
import { useEffect, type RefObject } from 'react'
/**
* 全局搜索框:吸在内容区顶部。
* 支持 Ctrl/Cmd+K 聚焦、Esc 清空,输入内容由父组件接管过滤逻辑。
*/
type SearchBarProps = {
value: string
onChange: (value: string) => void
@@ -21,38 +26,22 @@ export function SearchBar({ value, onChange, inputRef }: SearchBarProps) {
}, [inputRef])
return (
<div className="search-bar card-box">
<svg className="search-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
ref={inputRef}
className="search-input"
type="text"
<div className="search-row">
<Input
inputRef={inputRef}
size="lg"
icon="search_3_line"
value={value}
placeholder="搜索本地项目…"
aria-label="搜索本地项目"
clearable
onClear={() => onChange('')}
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') onChange('')
}}
/>
{value !== '' && (
<button
type="button"
className="search-clear"
aria-label="清空搜索"
onClick={() => onChange('')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
)
}
@@ -0,0 +1,86 @@
import { Card, Menu, type MenuItem } from '@stash/ui'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
/**
* 分类导航树:由内容树派生。
*
* - 有分组的分类(apps)是折叠节点,子项是分组(分组可用 show_in_sidebar 单独隐藏);
* - 没有分组、或分组全被隐藏的分类,就是一个普通菜单项。
*
* 折叠状态由组件库的 Menu 内部维护;这里只负责把内容树翻译成 items。
*/
type CategoryNavProps = {
/** 内容目录;顺序即侧栏顺序 */
categories: Category[]
/** 当前明细视图的分类 / 分组,用于高亮 */
activeCategoryId?: string
activeGroupId?: string
/** 是否在首页(高亮「首页」项) */
homeActive?: boolean
onNavigate: (target: NavTarget) => void
/** 点击「首页」 */
onHome: () => void
}
const HOME_KEY = 'home'
const categoryKey = (id: string) => `cat:${id}`
const groupKey = (categoryId: string, groupId: string) => `group:${categoryId}:${groupId}`
export function CategoryNav({
categories,
activeCategoryId,
activeGroupId,
homeActive = false,
onNavigate,
onHome,
}: CategoryNavProps) {
const items: MenuItem[] = [
{ key: HOME_KEY, label: '首页' },
...categories.map((category) => {
// 「允许子分组」的分区才有 groups;空分组不出现在侧栏(后台仍保留)
const groups = category.groups.filter((group) => group.items.length > 0)
return {
key: categoryKey(category.id),
label: category.title,
children:
groups.length > 0
? groups.map((group) => ({
key: groupKey(category.id, group.id),
label: group.name,
}))
: undefined,
}
}),
]
const activeKey = homeActive
? HOME_KEY
: activeCategoryId === undefined
? undefined
: activeGroupId !== undefined
? groupKey(activeCategoryId, activeGroupId)
: categoryKey(activeCategoryId)
const handleSelect = (key: string) => {
if (key === HOME_KEY) {
onHome()
return
}
if (key.startsWith('group:')) {
const [, categoryId, groupId] = key.split(':')
onNavigate({ categoryId, groupId })
return
}
onNavigate({ categoryId: key.slice('cat:'.length) })
}
return (
<Card padding="none" className="nav-card">
<Menu items={items} activeKey={activeKey} onSelect={handleSelect} itemHeight={39} />
</Card>
)
}
@@ -0,0 +1,34 @@
import { Avatar, Card, Icon, Text, Title } from '@stash/ui'
import type { ProfileSettings } from '../../settings'
/**
* 用户资料卡:头像、认证徽标、昵称、角色、简介、IP 属地。
* 内容来自站点设置(/admin → 资料),未设置时用默认文案。
*/
type ProfileCardProps = {
profile: ProfileSettings
}
export function ProfileCard({ profile }: ProfileCardProps) {
return (
<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 />
)}
<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>
)
}
@@ -0,0 +1,66 @@
import { Button, Card, Icon, Space, Tag, Text, Title } from '@stash/ui'
import type { Project } from '../../api'
/**
* 本地服务面板:连接状态、项目数、访问地址,以及两个操作入口。
* 只读展示 + 触发刷新,不负责导航。
*/
type ServicePanelProps = {
projects: Project[]
live: boolean
refreshing: boolean
onRefresh: () => void
}
export function ServicePanel({ projects, live, refreshing, onRefresh }: ServicePanelProps) {
const host = typeof window === 'undefined' ? '-' : window.location.host
return (
<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 ? '已连接' : '未连接'}
</Tag>
</Space>
<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
size="sm"
variant="secondary"
className="service-action"
spinning={refreshing}
icon={<Icon name="refresh_3_line" />}
onClick={onRefresh}
title="重新扫描项目"
>
重新扫描
</Button>
<a className="link-button" href="/api/projects" target="_blank" rel="noreferrer">
<Icon name="link_3_line" />
查看接口
</a>
</div>
</Card>
)
}
@@ -0,0 +1,64 @@
import type { Project } from '../../api'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
import type { ProfileSettings } from '../../settings'
import { CategoryNav } from './CategoryNav'
import { ProfileCard } from './ProfileCard'
import { ServicePanel } from './ServicePanel'
/**
* 左侧栏容器:用户资料 + 本地服务面板 + 分类导航。
* 只负责布局与数据透传,具体内容由三个子组件分别渲染。
*/
type SidebarProps = {
projects: Project[]
/** 内容目录;侧栏直接由它派生 */
categories: Category[]
profile: ProfileSettings
live: boolean
refreshing: boolean
onRefresh: () => void
activeCategoryId?: string
activeGroupId?: string
/** 是否在首页 */
homeActive?: boolean
onNavigate: (target: NavTarget) => void
/** 点击「首页」 */
onHome: () => void
}
export function Sidebar({
projects,
categories,
profile,
live,
refreshing,
onRefresh,
activeCategoryId,
activeGroupId,
homeActive = false,
onNavigate,
onHome,
}: SidebarProps) {
return (
<div className="sidebar">
<ProfileCard profile={profile} />
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={onRefresh}
/>
<CategoryNav
categories={categories}
activeCategoryId={activeCategoryId}
activeGroupId={activeGroupId}
homeActive={homeActive}
onNavigate={onNavigate}
onHome={onHome}
/>
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import type { AppItem, PosterItem, RelatedItem } from './types'
/**
* 条目详情页的通用视图模型。
*
* 应用(方形头像 + 横幅)和电影(竖版海报 + 多标签)共用同一套版式,
* 差异通过 shape / tags / banner 这几个字段表达,避免维护两份几乎相同的页面。
*/
export type ItemDetailModel = {
title: string
/** 主图;缺失时用 fallbackIcon 显示字符块 */
cover?: string
/** 主图形态:应用用方形,电影用竖版海报 */
shape: 'square' | 'portrait'
/** 主图缺省时的字符 */
fallbackIcon?: string
/** 标签行(应用 1 个,电影 2 个) */
tags: string[]
/** 一句话评价 */
comment?: string
/** 正文段落 */
paragraphs: string[]
views: number
likes: number
href: string
/** 应用详情页的横幅图 */
banner?: string
}
/** 把应用条目转成详情模型 */
export function appDetailModel(item: AppItem): ItemDetailModel { return {
title: item.name,
cover: item.cover,
shape: 'square',
fallbackIcon: item.icon,
tags: item.badge !== undefined ? [item.badge] : [],
comment: item.comment,
paragraphs: [item.desc],
views: item.views ?? 0,
likes: item.likes ?? 0,
href: item.href,
banner: item.banner,
}
}
/** 把电影海报转成详情模型 */
export function posterDetailModel(item: PosterItem): ItemDetailModel {
return {
title: item.title,
cover: item.cover,
shape: 'portrait',
tags: item.tags ?? [],
comment: item.comment,
paragraphs: item.summary !== undefined ? [item.summary] : [],
views: item.views ?? 0,
likes: item.likes ?? 0,
href: item.href ?? '#',
}
}
/** 应用条目 → 推荐阅读条目 */
export function toRelatedItem(item: AppItem): RelatedItem {
return { id: item.id, name: item.name, desc: item.desc, cover: item.cover, icon: item.icon }
}
/** 电影海报 → 推荐阅读条目 */
export function posterToRelatedItem(item: PosterItem): RelatedItem {
return {
id: item.id,
name: item.title,
desc: item.tags?.join(' · ') ?? '推荐电影',
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
@@ -0,0 +1,49 @@
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
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,12 @@
/**
* 导航目标。
*
* 侧栏现在直接由内容树(分区 → 分组)派生,不再有独立的导航数据模型;
* 这里只保留「点击后要去哪」的最小类型。
*/
export type NavTarget = {
categoryId: string
/** 指定后明细页只展示该分组 */
groupId?: string
}
+238
View File
@@ -0,0 +1,238 @@
/**
* 内容数据模型。
*
* 首页把每个「分类」渲染成一张卡片,明细页按分类的 kind 渲染不同网格,
* 因此这里用可辨识联合把三种展示形态分开。
*/
/** 一个可点击的应用 / 站点条目 */
export type AppItem = {
id: string
name: string
desc: string
/** 卡片左侧的字符图标 */
icon: string
/** 跳转地址;本地项目为 /<目录名>/ */
href: string
/** 详情页头像 / 封面,缺省时退化为字符图标 */
cover?: string
/** 是否为热门条目:卡片名字后显示主题同款 HOT 徽标 */
hot?: boolean
/** 详情页的一句话评价 */
comment?: string
/** 详情页横幅图 */
banner?: string
/** 详情页头像下方的标签 */
badge?: string
/** 详情页浏览量(占位数据) */
views?: number
/** 详情页点赞数(占位数据) */
likes?: number
}
/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */
export type ContentGroup<T> = {
id: string
name: string
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
items: T[]
}
export type AppGroup = ContentGroup<AppItem>
/** 竖版海报条目(电影) */
export type PosterItem = {
id: string
title: string
cover: string
/** 详情页标签行,如「推荐电影」「豆瓣电影 Top250」 */
tags?: string[]
/** 详情页一句话评价 */
comment?: string
/** 详情页正文段落 */
summary?: string
/** 跳转地址(占位数据暂无真实链接) */
href?: string
views?: number
likes?: number
}
/** 「推荐阅读」用的轻量条目,应用与电影都能映射过来 */
export type RelatedItem = {
id: string
name: string
desc: string
cover?: string
icon?: string
}
/** 图文条目(报道) */
export type ArticleItem = {
id: string
title: string
desc: string
cover: string
/** 详情页标题下的元信息,如 ['2 年前', 'CES 现场报道'] */
meta?: string[]
/** 详情页正文:一整段 Markdown(一篇一段文本,不再拆块) */
body?: string
}
/** 首页展示块:分区卡片展示哪个节点、显示几条 */
export type SectionBlock = {
/** 要展示的节点业务键:通常是分组;电影/文章这类无分组的填分区自己 */
nodeKey: string
/** 块标题,缺省用节点标题 */
title?: string
/** 卡片显示几条,缺省用分类的 previewLimit 或默认值 */
previewLimit?: number
}
/** 应用类分类:内部按分组组织 */
export type AppCategory = {
id: string
kind: 'apps'
title: string
/** 标题左侧色条颜色,各分类用不同颜色区分 */
barColor: string
/** 明细页 hero 区的字符图标 */
icon: string
groups: AppGroup[]
/** 直接挂在分区上的条目(未分组) */
items?: AppItem[]
/** 首页预览条数,默认 4 */
previewLimit?: number
/** 首页预览为空时的提示文案 */
emptyText?: string
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
/** 海报类分类:竖版海报网格 */
export type PosterCategory = {
id: string
kind: 'posters'
title: string
barColor: string
icon: string
groups: ContentGroup<PosterItem>[]
/** 直接挂在分区上的条目(未分组) */
items: PosterItem[]
/** 首页预览条数,默认 6 */
previewLimit?: number
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
/** 图文类分类:上图下文卡片网格 */
export type ArticleCategory = {
id: string
kind: 'articles'
title: string
barColor: string
icon: string
groups: ContentGroup<ArticleItem>[]
/** 直接挂在分区上的条目(未分组) */
items: ArticleItem[]
/** 首页预览条数,默认 4 */
previewLimit?: number
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
export type Category = AppCategory | PosterCategory | ArticleCategory
/** 分类内的条目总数(直属 + 分组的),用于角标与明细页计数 */
export function categoryCount(category: Category): number {
const direct = category.items?.length ?? 0
const grouped = category.groups.reduce((total, group) => total + group.items.length, 0)
return direct + grouped
}
/** 把应用类分类的条目拍平(分组 + 直属),首页预览用 */
export function flattenAppItems(category: AppCategory): AppItem[] {
return [
...category.groups.flatMap((group) => group.items),
...(category.items ?? []),
]
}
/** 按 id 在分类里查找条目;详情页用 */
export function findAppItem(category: Category, itemId: string): AppItem | undefined {
if (category.kind !== 'apps') return undefined
const direct = (category.items ?? []).find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
/** 取同分类下的其他条目,用于详情页的「推荐阅读」 */
export function relatedAppItems(
category: Category,
excludeId: string | undefined,
limit = 4,
): AppItem[] {
if (category.kind !== 'apps') return []
return flattenAppItems(category)
.filter((item) => item.id !== excludeId)
.slice(0, limit)
}
/** 按 id 查找海报条目 */
export function findPosterItem(
category: Category,
itemId: string,
): PosterItem | undefined {
if (category.kind !== 'posters') return undefined
const direct = category.items.find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
/** 取同分类下的其他海报,用于详情页的「推荐阅读」 */
export function relatedPosterItems(
category: Category,
excludeId: string | undefined,
limit = 4,
): PosterItem[] {
if (category.kind !== 'posters') return []
return [
...category.items,
...category.groups.flatMap((group) => group.items),
]
.filter((item) => item.id !== excludeId)
.slice(0, limit)
}
/** 按 id 查找图文条目 */
export function findArticleItem(
category: Category,
itemId: string,
): ArticleItem | undefined {
if (category.kind !== 'articles') return undefined
const direct = category.items.find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
+124
View File
@@ -0,0 +1,124 @@
/**
* 视图与地址的对应关系:地址栏是唯一的视图状态源。
*
* 控制台是单页应用,但「当前在哪一页」写在 URL 里,所以刷新、收藏、
* 前进后退都不会丢上下文。服务端对应的规则在 `src/server.rs`:
* `/c/**` 没有实体文件时回落到内嵌的 `index.html`,由前端按 pathname 重新出页面。
*
* 路由形状(各段都是业务键,也就是接口 JSON 里的 `id`):
*
* / 首页
* /c/<分区> 分类明细
* /c/<分区>/<分组> 只展示该分组
* /c/<分区>/i/<条目> 条目详情,返回 → 分类明细
* /c/<分区>/<分组>/i/<条目> 条目详情,返回 → 该分组
*
* 条目地址带上它自己所在的分组,URL 才是自解释的:刷新之后点「返回」
* 仍然落回条目所属的分组,而不是去猜一个返回目标。
*
* 这里只做「地址 ↔ 视图」的纯转换,读写 history 的部分在 `useViewRoute.ts`。
*/
export type DetailRoute = {
type: 'detail'
categoryId: string
/** 指定后明细页只展示该分组 */
groupId?: string
}
export type ItemRoute = {
type: 'item'
categoryId: string
itemId: string
/**
* 条目所在的分组(应用才有)。
*
* 它只描述「条目在哪」,不描述「从哪来」:返回目标由它推导,
* 这样同一个条目永远只有一个地址,刷新和分享都不会跑偏。
*/
groupId?: string
}
export type View =
| { type: 'home' }
| DetailRoute
| ItemRoute
/** 前端路由前缀;改动这里要同步 `src/server.rs` 的 `SPA_PREFIXES`。 */
const CATEGORY_PREFIX = '/c/'
/** 前缀去掉两侧斜杠后的那一段,拼地址时当第一个路径段用 */
const CATEGORY_SEGMENT = CATEGORY_PREFIX.slice(1, -1)
/** 条目段标记:`/c/<分区>[/<分组>]/i/<条目>` */
const ITEM_MARK = 'i'
/** 首页视图;未知地址一律归到这里 */
export const HOME_VIEW: View = { type: 'home' }
/** 把视图转成地址。 */
export function viewPath(view: View): string {
if (view.type === 'home') return '/'
const segments = [CATEGORY_SEGMENT, encodeSegment(view.categoryId)]
if (view.groupId !== undefined) segments.push(encodeSegment(view.groupId))
if (view.type === 'item') segments.push(ITEM_MARK, encodeSegment(view.itemId))
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
}
}
+24 -49
View File
@@ -1,31 +1,11 @@
/* 页面级基础:字号 / 字体 / 重置。颜色与间距全部来自 @stash/ui 的令牌。 */
:root {
--bg-color: #f7f9fb;
--card-bg: #ffffff;
--primary-color: #3b82f6;
--primary-soft: #eff6ff;
--text-main: #1e293b;
--text-sub: #64748b;
--text-muted: #94a3b8;
--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;
/* 内容区最大宽度,宽屏时整体居中 */
--layout-max-width: 1280px;
--font-sans:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
'PingFang SC', 'Microsoft YaHei', Arial, 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;
@@ -40,15 +20,7 @@
html,
body,
#root {
height: 100%;
}
/* 桌面端锁死视口,滚动交给右侧内容区 */
body {
height: 100vh;
overflow: hidden;
background-color: var(--bg-color);
color: var(--text-main);
min-height: 100%;
}
a {
@@ -63,20 +35,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 -5
View File
@@ -1,10 +1,27 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
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>,
)
+197
View File
@@ -0,0 +1,197 @@
/**
* 原子模型的后台接口:分组树 + 卡片。
*
* 分组是唯一容器,卡片只是「勾选分组」的展示组合;两者都通过业务键关联。
*/
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
format: GroupFormat
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; format: GroupFormat }[] {
return groups.flatMap((group) => [
{ key: group.key, title: group.title, depth, format: group.format },
...flattenGroups(group.children, depth + 1),
])
}
+389
View File
@@ -0,0 +1,389 @@
/**
* 站点设置:页头滚动信息 + 侧栏顺序/显隐。
*
* 接口是公开读、登录后写;写的时候带 `version` 做乐观锁,
* 冲突(多标签页同时改)时返回 409,页面提示刷新。
*/
import type { Category } from './data/types'
export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral'
export type HeaderItem = {
text: string
tag?: string | null
tone: SettingsTone
}
export type SectionSettings = {
hidden: string[]
order: string[]
titles: Record<string, string>
limits: Record<string, number>
}
export type ProfileSettings = {
name: string
role: string
description: string
/** 完整文案,如「IP属地:局域网」 */
location: string
/** 头像地址;空表示用图标占位 */
avatar: string
}
export type SiteSettings = {
header: {
enabled: boolean
/** 滚动一轮的秒数(8–120) */
speed: number
items: HeaderItem[]
}
sidebar: {
/** 是否显示「本地管理员」资料卡 */
show_profile: boolean
/** 是否显示「本地服务」面板 */
show_service: boolean
hidden: string[]
order: string[]
/** 隐藏的分组:`分区id/分组id` */
hidden_groups: string[]
/** 强制显示的分组(覆盖数据库 show_in_sidebar=0) */
shown_groups: string[]
/** 每个分区下的分组顺序:分区 id → 分组 id 列表 */
group_order: Record<string, string[]>
/** 新模型:侧栏隐藏的分组 key */
tree_hidden: string[]
/** 新模型:每级分组的顺序,键为父分组 key(根级用空串) */
tree_order: Record<string, string[]>
}
sections: SectionSettings
profile: ProfileSettings
}
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,
items: [
{ text: '共 {count} 个本地应用', tone: 'plain' },
{ text: '{project}', tag: '本地', tone: 'primary' },
{ text: '{status}', tag: '状态', tone: 'success' },
],
},
sidebar: {
show_profile: true,
show_service: true,
hidden: [],
order: [],
hidden_groups: [],
shown_groups: [],
group_order: {},
tree_hidden: [],
tree_order: {},
},
sections: { hidden: [], order: [], titles: {}, limits: {} },
profile: {
name: '本地管理员',
role: '管理员',
description: '本地静态站点托管与预览,所有项目都运行在这台机器上。',
location: 'IP属地:局域网',
avatar: '',
},
}
}
/** 占位符替换:`{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 : '网络错误',
}
}
}
/**
* 按设置重排侧栏:先按 `order`,未列出的保持原顺序,`hidden` 过滤掉。
*/export function applySidebarSettings<T extends { id: string }>(
categories: T[],
sidebar: SiteSettings['sidebar'],
): T[] {
return orderAndFilter(categories, sidebar)
}
/** 首页卡片:过滤隐藏、按 order 排序(未列出的保持原顺序) */
export function applySectionSettings<T extends { id: string }>(
categories: T[],
sections: SectionSettings,
): T[] {
return orderAndFilter(categories, sections)
}
/**
* 侧栏子分类(分组):按设置过滤、排序。
*
* 数据库里的 `show_in_sidebar` 是默认值;`shown_groups` 可以把它强行打开,
* `hidden_groups` 则再隐藏一层。
*/
export function applySidebarGroups(
categories: Category[],
sidebar: SiteSettings['sidebar'],
): Category[] {
return categories.map((category) => {
if (category.kind === 'apps') {
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
}
if (category.kind === 'posters') {
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
}
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
})
}
/** 按设置过滤、排序某个分区下的分组 */
function filterGroups<T extends { id: string }>(
groups: T[],
categoryId: string,
sidebar: SiteSettings['sidebar'],
): T[] {
const hidden = new Set(sidebar.hidden_groups)
const shown = new Set(sidebar.shown_groups)
const ranks = new Map(
(sidebar.group_order[categoryId] ?? []).map((id, index) => [id, index] as const),
)
return groups
.filter((group) => {
const key = `${categoryId}/${group.id}`
const showInSidebar = (group as { showInSidebar?: boolean }).showInSidebar
return !hidden.has(key) && (showInSidebar !== false || shown.has(key))
})
.map((group, index) => ({
group,
index,
rank: ranks.get(group.id) ?? Number.MAX_SAFE_INTEGER,
}))
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
.map((entry) => entry.group)
}
/** 卡片标题:设置里的覆盖值优先 */
export function sectionTitle(
id: string,
fallback: string,
sections: SectionSettings,
): string {
const override = sections.titles[id]
return override !== undefined && override.trim() !== '' ? override : fallback
}
/** 卡片预览条数:设置里的覆盖值优先 */
export function sectionLimit(
id: string,
fallback: number,
sections: SectionSettings,
): number {
const override = sections.limits[id]
return typeof override === 'number' && override > 0 ? override : fallback
}
function orderAndFilter<T extends { id: string }>( categories: T[],
config: { hidden: string[]; order: string[] },
): T[] {
const hidden = new Set(config.hidden)
const ranks = new Map(config.order.map((id, index) => [id, index]))
return categories
.filter((category) => !hidden.has(category.id))
.map((category, index) => ({
category,
index,
rank: ranks.get(category.id) ?? Number.MAX_SAFE_INTEGER,
}))
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
.map((entry) => entry.category)
}
// ---------- 分区(首页卡片 / 侧栏项)的新增与删除 ----------
export type NewCategory = { id: string; title: string; kind: 'apps' | 'posters' | 'articles' }
export type SimpleResult = { ok: true } | { ok: false; error: string }
export async function createCategory(input: NewCategory): Promise<SimpleResult> {
try {
const response = await fetch('/api/categories', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function deleteCategory(id: string): Promise<SimpleResult> {
try {
const response = await fetch(`/api/categories/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'same-origin',
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
/** 修改分区(归类、标题、图标、色条、预览条数) */
export type UpdateCategoryInput = {
title?: string
icon?: string
barColor?: string
kind?: 'apps' | 'posters' | 'articles'
previewLimit?: number
emptyText?: string
/** 首页卡片预览哪个节点(分区自己或其分组) */
blockNodeKey?: string
/** 是否允许子分组(与类型无关的开关) */
allowGroups?: boolean
}
export async function updateCategory(
id: string,
patch: UpdateCategoryInput,
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/categories/${encodeURIComponent(id)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `保存失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
// ---------- 分组(子分类) ----------
export async function createGroup(
sectionId: string,
input: { id: string; title: string },
): Promise<SimpleResult> {
try {
const response = await fetch(
`/api/categories/${encodeURIComponent(sectionId)}/groups`,
{
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
},
)
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function updateGroup(
id: string,
patch: { title?: string; showInSidebar?: boolean },
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `保存失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function deleteGroup(id: string): Promise<SimpleResult> {
try {
const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'same-origin',
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
+79
View File
@@ -0,0 +1,79 @@
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
@@ -0,0 +1,105 @@
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: {
+499
View File
@@ -0,0 +1,499 @@
<!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
@@ -0,0 +1,920 @@
<!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