97 Commits
Author SHA1 Message Date
CNWei 27e411fd2c refactor(db): 删除旧分区展示块与兼容字段
- v23 迁移删除 section_block 表,以及 node 的 view_mode / empty_text / show_in_sidebar / allow_groups
- 重建 node 表并保留 44 条内容、15 个分组、5 张卡片,外键检查通过
- 种子库直接生成 card / card_group,不再依赖 section_block
- 删除旧 /api/catalog、/api/categories、/api/groups/{id} 与 categories 模块
- 分组接口不再接收/返回 format,前端 GroupNode 同步移除
- README/HANDOFF 更新到 v23;管理页提示改为『格式由每条内容决定』
2026-11-02 20:24:08 +08:00
CNWei 4621f8cb22 chore: 清理无引用的前端与旧模板资源
- 删除 13 个旧 App 时代的前端组件/数据层文件(无 import 可达)
- 删除 templates/ 6 个旧 Askama 原型页与 assets/dashboard.{css,js}
- README/设计文档同步当前路由与图标实现,移除失效引用
- stash-web 构建通过,cargo test 59 passed
2026-11-02 20:10:48 +08:00
CNWei 8889515373 style(admin): 后台页头与前台左右对齐
- 页头网格补一个空的中间列,品牌与操作图标分别贴左右边缘
- 后台不渲染滚动信息,仅保留前台同样的 60 高、限宽与对齐
2026-11-02 19:56:54 +08:00
CNWei bbfbc77b30 Revert "style(admin): 统一标题层级与页面排版"
This reverts commit b1f7589538.
2026-11-02 19:54:36 +08:00
CNWei b1f7589538 style(admin): 统一标题层级与页面排版
- 每个后台页面新增统一的加粗标题 + 说明 + 右侧操作区
- 分组/数据页操作按钮移到标题右侧,取消正文里的『说明:』散排
- 页头/资料/系统改为 820px 窄栏,标签加粗,保存按钮与表单对齐
- 系统管理拆成『修改密码 / 数据备份』两段,按钮不再整行拉伸
- 外层标题改为『后台管理』,侧栏与卡片页去掉重复说明,卡片列表标题加粗
2026-11-02 19:50:51 +08:00
CNWei f25fc8b4ba chore: 同步 stash-ui 本地包版本 2026-11-02 19:39:14 +08:00
CNWei dec82080d3 style: 页头滚动信息居中,接口按钮统一为 Button
- 页头改为三列 grid,Ticker 放入中间列,左右等宽,滚动信息在视口居中
- 查看接口从自定义 a.link-button 改为与重新扫描相同的 @stash/ui Button
- 保留站内路由与修饰键新标签打开行为
2026-11-02 19:39:08 +08:00
CNWei 1effbea3dd docs: 按当前实现刷新 README / HANDOFF / ADMIN-PLAN
README:
- 快速开始补上 stash-ui 必须先构建(file: 依赖),目录结构补 stash-ui 与新的前端目录
- 登录与设置改写为新的后台结构(左侧导航 + 页头/侧边栏/内容卡片子项)、明暗主题与 /interface
- 前端路由补 /interface、/admin 与 /c/ 前缀;服务端回落说明改成 SPA_PREFIXES + SPA_PAGES
- 表结构:node 增补 allow_groups / enabled,section_block 章节改为 card / card_group,
  说明 user_version=22 与自动迁移
- HTTP API:内容目录改为 /api/catalog2,新增「其它接口」汇总表(与 /interface 页一致)
- 已知限制:删掉「没有迁移」「没有身份认证」「只有 /c/** 一个前缀」等过时结论

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

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

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

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

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

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

后端
- settings 新增 site(标题 / favicon / 品牌 / 操作工具)与 sidebar.show_nav
- 写接口成功后广播内容库变更,SSE 改为「广播 + data_version 兜底」
- catalog SSE 事件名与前端监听对齐(catalog)
2026-11-01 15:15:20 +08:00
CNWei bb5a615ed2 fix: 搜索框保持实底,不受虚化影响
- 移除搜索框半透明与 backdrop-filter
- 虚化只作用于搜索框周围
- 验证:bg 纯白、blur none、opacity 1
2026-10-31 17:24:36 +08:00
CNWei 6c8eaa9f76 style: 搜索框 30% 透明,分组块底部留白
- 搜索框背景 50% -> 30%
- sidebar 增加 24px 底部内边距,导航块滚到底留 24px
- 验证:alpha=0.30;nav bottom 与视口底部间距 24px
2026-10-31 17:18:57 +08:00
CNWei 762de4a4e1 style: 修复侧栏分类圆角,顶部增加软虚化
- nav-card 补 12px 圆角与 overflow hidden
- 搜索框 50% 透明 + blur(14px)
- 新增 top-blur:blur(8px) + 上下 mask 渐隐,覆盖搜索与内容顶部,不出现硬线
- 验证:nav 圆角 12、搜索可点、虚化生效
2026-10-31 17:03:20 +08:00
CNWei 2a344ae534 style: 加高顶部留白
- TOP_OFFSET 88 -> 104,搜索框与卡片间距 28 -> 44
- contentOffset 与 page-scroll margin/height 共用常量
- 验证 content/sider top=164
2026-10-31 16:57:29 +08:00
CNWei ddef75f862 fix: 去掉顶部独立块,消除横向分割线
- 删除 top-dock 与其半透明填充/底边
- 搜索框保持 fixed 悬浮,页头实底
- 顶部与内容之间只保留页面背景自然渐变
- 验证:top-dock 数量为 0,背景连续
2026-10-31 16:54:44 +08:00
CNWei e13c2b2ce6 fix: 页头恢复实底,搜索改为独立半透明块
- headerVariant 恢复 solid,去掉 .app > header 透明度
- 新增 top-dock(top 60 / height 88,alpha 0.28 + blur + 底边)承载搜索
- 背景图渐隐恢复 80%
- 验证:header 纯白、dock 半透明、搜索可见、无遮罩
2026-10-31 16:41:50 +08:00
CNWei a2155a4c89 fix: 去掉顶部遮罩,页头与搜索框改为半透明
- 删除 scroll-fade 遮罩及创建逻辑
- 页头改为 blur 并降到 55% 透明度,搜索框降到 60%
- 背景图渐隐层从 80% 调到 65%,顶部区域保持背景原色
- 验证:fades=0,header alpha=0.55,search alpha=0.60
2026-10-31 16:34:54 +08:00
CNWei 7a61acfa02 fix: 顶部遮罩合并为横跨左右的一条
- 移除内容区 content-veil,只保留一条 fixed 遮罩
- 遮罩宽度从侧栏左边缘到内容区右边缘,左右同一水平高度
- 保留渐变与 blur(14px),验证 left=50 width=1340 top=148
2026-10-31 16:28:14 +08:00
CNWei 50aea29ea6 fix: 侧栏顶部虚化遮罩与半透明卡片
- 新增固定侧栏遮罩层,滚动后淡入,blur(14px) + 透明渐变,不随吸底 transform 移动
- 侧栏资料/服务/导航卡改为 64% 半透明 + 毛玻璃
- 修正只输出 -webkit-backdrop-filter 导致不生效的问题
- 验证:遮罩 top=148、blur 生效、卡片 alpha=0.64
2026-10-31 16:00:45 +08:00
CNWei 4304fd75b4 fix: 双栏非对称吸底滚动与对齐
- 去掉首页侧栏额外 padding,左右两栏初始顶部对齐
- 隐藏 #page-scroll 滚动条
- 左侧到底后用 translateY 补偿吸附在视口底部,右侧继续滚动;右侧先到底时两栏同步
- ResizeObserver 重算吸附阈值
- 验证:初始 top 148/148;滚动后左栏 top 144 固定、bottom 900;无滚动条
2026-10-31 15:51:57 +08:00
CNWei 54fd0edf84 fix: 左右两栏共用滚动容器,滚动位移保持一致
- 将 Layout body (#page-scroll) 作为唯一滚动容器,左右两栏同容器滚动
- 滚动区域从搜索框下方开始,搜索框固定
- 内容区 overflow visible、侧栏 height auto;返回顶部/路由归零改用 page-scroll
- 验证:滚动 300px 后两侧 top 均为 -152,搜索框不动,返回顶部有效
2026-10-31 15:40:08 +08:00
CNWei 57ab90a1fa feat: 搜索框下滚动、本地服务卡片排序、拖拽末位修复
- 恢复内容区内部滚动:页头/搜索框固定,滚动区域从搜索框下方开始;侧栏跟随内容滚动,侧栏滚轮驱动内容区
- v22:本地服务以 card 行 local-service 参与排序;首页按卡片顺序渲染,后台可拖动排序
- 本地服务卡片后端禁止删除/改名/配置分组
- 修复向下拖动时插到目标之后,倒数第二项可拖到最后
- 验证:搜索框位置固定、侧栏 scrollTop 跟随、本地服务与倒数第二拖末位均落库、首页顺序一致
2026-10-31 15:30:09 +08:00
CNWei 0e6c772466 feat: 侧栏卡片显隐、本地服务卡片恢复、页面同步滚动
- 侧边栏设置新增「侧栏卡片」:本地管理员/本地服务显隐,即时保存 show_profile/show_service
- 首页固定第一位恢复「本地服务」卡片(运行时项目网格),移除卡片三点菜单
- 后台首页卡片新增只读「本地服务」表,标记不可删除
- 前台改为整页滚动,左右两栏同步
- 验证:显隐落库、三点为 0、本地表无删除、滚动两栏同步 300px
2026-10-31 14:59:41 +08:00
CNWei 11f992cdad feat: 格式从分组下沉到条目(v21)
- entry 新增 format(apps/posters/articles),老数据按根分组 view_mode 回填;seed 按分区写入
- 分组管理移除格式字段/显示,根分组只作容器
- 内容弹窗新增逐条格式选择,列表增加格式列;ItemEditor 保存时保留原格式
- 前台按 entry.format 渲染:卡片/分组页支持同组混合格式,详情按条目格式选模板
- 迁移后 live 20 应用 / 13 海报;cargo test 59 passed;Playwright 验证混合展示
2026-10-31 14:26:28 +08:00
CNWei 8d094175fd feat(admin): 卡片新建弹窗、标题保存/删除与拖动排序
- 新建卡片改为弹窗输入标题,确认新建/取消关闭,创建后自动选中
- 卡片标题右侧增加「保存」「删除」按钮
- 左侧卡片支持拖拽排序,顺序写入 card.sort 并影响首页卡片顺序
- 标签栏与内容增加间距,卡片列表加拖动手柄与提示
- Playwright 验证:弹窗取消/确认、按钮、拖动后 API 顺序变化
2026-10-31 13:57:34 +08:00
CNWei 2c126b410c feat(admin): 首页卡片改为左侧列表 + 右侧分组表格
- 左侧卡片列表切换,右侧当前卡片的分组表格(分组/条数/展示)
- 新建卡片即新建一张表,创建后自动选中
- 表格内开关与条数即时保存,标题失焦保存
- 去掉纵向堆叠与卡片级保存按钮
- Playwright 验证:切换、创建、开关落库
2026-10-31 11:46:50 +08:00
CNWei c1a4da5458 feat(admin): 首页卡片按侧边栏逻辑交互
- 分组勾选:标题在左、开关在右,勾选即保存,去掉每卡保存按钮
- 卡片标题与每组条数改为失焦保存
- 保留删除卡片按钮
- Playwright 验证:无保存按钮、开关右置、勾选/条数/改名即时落库
2026-10-31 11:35:48 +08:00
CNWei 87b3c3b35a fix(admin): 内容下拉去格式后缀,侧边栏开关右置并即时保存
- 内容页/条目弹窗的分组下拉只显示名称,去掉格式后缀与缩进,统一对齐
- 侧边栏开关移到右侧;拨动即保存到 settings.sidebar.tree_hidden
- 移除侧边栏页的保存/撤销按钮
- Playwright 验证:选项无括号且对齐、开关在右、无保存按钮、设置即时落库
2026-10-31 11:19:30 +08:00
CNWei b04842978c feat(admin): 内容页列表化,主分组汇总子分组条目
- 内容页改为列表:标题 / 跳转链接 / 所属分组 / 操作
- 选中主分组时递归列出全部子分组条目;选子分组只列该子树
- 编辑弹窗始终显示「标识」(编辑时只读)
- 图文条目接口补齐 href,列表统一显示跳转链接
- 测试 59 passed;Playwright 验证 20 条汇总、子分组 4 条、标识只读
2026-10-31 10:46:02 +08:00
CNWei c249fbd826 feat(admin): 分组管理改为树形表格(图标/排序/状态)
- 参考需求页重做分组管理:树形表格(名称/排序权重/关联网页数/状态/操作),去掉展开全部/折叠全部,新建根分组置于左侧
- 分组弹窗支持上级分组移动、格式、图标(可留空)、排序权重、启停状态
- 数据层 v20:node.enabled 启停;icon/sort 可编辑;禁用分组前台侧栏与卡片隐藏
- 修复同一父级下编辑排序权重被重置为末尾
- 测试 59 passed;Playwright 验证无图标/图标/排序/禁用/折叠
2026-10-31 10:22:58 +08:00
CNWei 165d3f112e feat(front): 前台切换到分组树 + 卡片组合
- 新 AppNext:首页按 /api/catalog2 的卡片勾选渲染(支持混合格式、每组条数),侧栏按分组树 + tree_hidden 渲染
- 分组页 /g/<key>、条目页 /g/<key>/i/<id>;条目在子树内递归查找,面包屑显示祖先链
- SPA 回落增加 /g/ 前缀;旧 /c/... 地址兼容
- 文档同步(路由、API、v19、限制)
- 测试 59 passed;Playwright 验证首页/混合卡片/分组页/条目页/侧栏隐藏
2026-10-30 20:45:39 +08:00
CNWei 2ccfb9b623 feat(admin): 新后台信息架构(分组管理/内容/侧边栏/卡片)
- /admin 切换为 AdminNext:分组管理(多层树、格式、增删改)、内容(选分组新增/编辑/删除条目)
- 侧边栏按分组树勾选,存入 settings.sidebar.tree_hidden
- 首页卡片:新建卡片、勾选分组(可混合格式)、每组条数
- 新增 manage.ts 客户端;旧 AdminPage 暂留作回退
- 测试 59 passed;Playwright 跑通 建根/子分组 → 加条目 → 侧栏勾选 → 混合卡片
2026-10-30 20:40:31 +08:00
CNWei 6340e435e9 feat(api): 原子模型分组树 / 卡片 / catalog2
- 新增 /api/catalog2:多层分组树(继承格式、带条目)+ 卡片勾选列表
- 新增 /api/nodes CRUD:任意层级分组、移动(防环)、排序、根分组格式
- 新增 /api/cards CRUD 与 PUT /api/cards/{id}/groups:一张卡可混合格式、可复用分组
- 条目允许挂在任意层级分组
- 测试 59 passed;API 冒烟通过(三级分组、卡片混合格式)
2026-10-30 20:35:37 +08:00
CNWei eb4fded8db feat(schema): v19 卡片/分组组合表与迁移回填
- 新增 card / card_group:卡片只存标题与顺序,内容由勾选分组组成,支持混合格式
- v18→v19 迁移:按现有 section_block 回填卡片与分组勾选(card-<section>)
- 新库 seed 完成后同样回填,旧界面暂不受影响
- 测试 59 passed(含 15→19 迁移)
2026-10-30 20:32:58 +08:00
CNWei e52db54bdc fix(admin): 子分组开关即时保存并立即显示分组管理
- 拨动开关即调用 PATCH allowGroups,成功后本地更新分类,无需再点保存
- 侧栏导航的分组列表同样跟随开关显示
- 失败时回滚开关状态
2026-10-30 20:10:07 +08:00
CNWei 22c73c1145 docs: 同步子分组开关与 v18 迁移 2026-10-30 20:05:33 +08:00
CNWei be7a0747ed feat(admin): 分区增加「允许子分组」开关(与类型解耦)
- node 新增 allow_groups(迁移 v18):应用默认开、海报/图文默认关,已有分组的非应用分区自动开启
- 后台每行一个「子分组」开关;分组管理、卡片预览、内容弹窗选组、侧栏建组都跟随开关
- 关闭开关时条目平铺且不丢内容;首页卡片指向分组会自动挪回分区
- 三种类型共用同一模板,类型只决定渲染方式
- 测试 59 passed(迁移 15→18、开关建组、类型切换)
2026-10-30 20:05:33 +08:00
CNWei 8df9ecdfda docs: 同步分组按类型收口 2026-10-30 19:55:45 +08:00
CNWei 261d202ce0 feat(admin): 分组能力按类型收口(应用可分组,海报/图文平铺)
- 只有应用类分区显示分组管理与卡片预览分组;海报/图文保持平铺
- 后端 create_group 拒绝非应用分区;海报/图文读取时把历史分组条目一并平铺
- 侧栏不再显示非应用分组与空分组
- 类型 应用 ↔ 海报 切换时分组入口和侧栏子项随之出现/消失
- 测试 59 passed
2026-10-30 19:55:45 +08:00
CNWei 1a4a6a3655 style(content): 明细页分组标题增加分区色条
- 分组标题与首页卡片统一为「色条 + 标题」,颜色取分区的 barColor
- 空分组不再在明细页占标题(后台仍保留)
2026-10-30 19:42:18 +08:00
CNWei 58725caa95 docs: 同步统一内容模型(分组对所有类型生效) 2026-10-30 18:56:18 +08:00
CNWei c84c41dc7d feat(content): 分组对所有内容类型生效,统一分区/分组/条目模型
- 分区=容器,分组=任意类型的可选子容器,条目可挂分区或分组(直属分区)
- catalog 所有类型统一输出 groups + items,条目查询按节点取
- 详情/侧栏/首页卡片预览支持所有类型的分组;多组显示小标题 + 未分组
- 后台「分区」「侧栏导航」对所有类型可建组;「内容」弹窗分组可选直属分区,编辑可换组
- 修复首页卡片配置为「整个分区」时预览为空
- 测试 59 passed
2026-10-30 18:56:17 +08:00
CNWei cc08188c1e docs: 交接文档同步弹窗编辑与侧栏建组 2026-10-30 18:31:41 +08:00
CNWei 71061c6b87 feat(admin): 内容弹窗新增/编辑、侧栏直接建子分组
- 内容 Tab 改为弹窗式新增;列表支持编辑(含换分区/分组)与删除
- 条目 PATCH 支持 nodeKey 移动,EntryPatch 改为 camelCase 解析
- 侧栏导航的每个应用分区下可直接新建子分组
- 测试 59 passed;Playwright 覆盖 建组→新增→编辑→移动分组
2026-10-30 18:31:41 +08:00
CNWei 26042c3e3f docs: 交接文档同步后台内容新增与分组 CRUD 2026-10-30 12:53:49 +08:00
CNWei 5982e0db5a feat(admin): 后台新增条目与分组 CRUD
- POST /api/items + DELETE /api/items/{key}:新增应用/海报/图文,按分区/分组挂载;PATCH 支持 nodeKey 移动
- 分组 CRUD:POST /api/categories/{id}/groups、PATCH/DELETE /api/groups/{id}(删除级联组内条目)
- PATCH /api/categories/{id} 增加 blockNodeKey,首页卡片可选择预览分组
- /admin 新增「内容」Tab(新增/删除条目),「分区」Tab 内联分组管理与卡片预览
- 测试 59 passed(条目创建/移动/删除、分组增删改)
2026-10-30 12:53:49 +08:00
CNWei f56614489a docs: 交接文档同步跳转修复、子分类与归类管理 2026-10-30 12:33:52 +08:00
CNWei 94e16b783c feat(admin): 跳转规范化、侧栏子分类与归类管理
- 条目保存时规范化 href(裸域名补 https,拒绝 javascript:/data: 等协议),详情按钮按解析结果置灰/跳转
- settings.sidebar 增加 hidden_groups / shown_groups / group_order,侧栏 Tab 支持分组显隐与排序
- 新增 PATCH /api/categories/{id} 与「归类」Tab:编辑类型/标题/图标/色条/条数;改类型自动迁移条目与首页卡片
- 测试 54 passed(href 规范化 3 例、分类更新 3 例)
2026-10-30 12:33:52 +08:00
CNWei 00e0360c85 docs: 交接文档同步组件库 v0.3.1 2026-10-30 08:59:22 +08:00
CNWei 5a411fdda8 docs: 交接文档同步组件库 v0.3.0(42 个组件) 2026-10-29 20:46:11 +08:00
CNWei fe9d1b2659 refactor(admin): 接入 PasswordInput,删除分类改用 Modal 确认
- 登录弹窗与系统改密统一用 @stash/ui 的 PasswordInput(改密页补上显隐切换)
- 删除分类不再用 window.confirm,改用 Modal + 危险按钮(组件库推荐的组合)
- 删除页面私有的 PasswordInput 组件与 .password-field 样式
2026-10-29 20:46:11 +08:00
stash 5de1382262 docs: 新增交接文档(架构、命令、状态、限制、下一步) 2026-10-29 20:31:52 +08:00
stash f9915535b6 fix(admin): 修复新增分类行 Select 覆盖「新增」按钮
- Select 自带 min-width:160px,原 120px 网格列导致溢出遮挡
- 第三列改为 160px,网格项 min-width:0
- 实测:select 875..1035,按钮 1043..1135,间隔 8px
2026-10-29 20:31:19 +08:00
stash 6d43b191c9 fix(admin): 删除按钮按可删性区分颜色(危险色 / 弱化色,均用主题令牌)
- 可删:var(--su-color-danger);hover 用 danger 10% 背景
- 不可删(本地服务):disabled + var(--su-text-muted)、opacity .45
- 侧栏与首页卡片两处共用同一个按钮组
2026-10-29 20:28:11 +08:00
stash 427338b329 feat(admin): 分区增删、资料设置、操作条与交互收尾
- POST/DELETE /api/categories(需登录,级联,local 保护)
- profile 设置(头像/名称/角色/简介/位置)应用到资料卡
- 详情操作条:浏览 +1、点赞切换、分享复制、跳转外链
- 编辑按钮垂直居中且留 12px;设置页按钮上间距、保存 Toast、Tabs 去滚动条
- 登录密码可见切换
- 测试 48 passed
2026-10-29 20:15:56 +08:00
stash 3c6c20e534 feat(admin): 首页卡片配置 + 页头开关验证 + 编辑按钮仅登录可见
- settings.sections:显隐 / 顺序 / 标题覆盖 / 预览条数,HomeView 应用
- /admin 新增「首页卡片」Tab,卡片标题使用展示块标题(常用站点等)
- 详情页 [编辑] 仅在登录后渲染(canEdit)
- 测试:46 passed;页头禁用、卡片隐藏/改标题/限条数、编辑可见性均实测
- 文档:ADMIN-PLAN、README
2026-10-29 19:21:34 +08:00
stash 8f688885ed feat(edit): P2 条目就地编辑 + P3 备份/审计/改密
- 迁移 v17:entry.updated_at(乐观锁)+ audit 表
- GET/PATCH /api/items/{key}:字段编辑、标签替换、409 冲突、400 校验
- 详情页 [编辑] → Drawer 表单(标题/简介/标签/评价/正文/封面/跳转),保存后局部刷新
- GET /api/backup:VACUUM INTO 一致性快照,附件下载
- /admin 系统 Tab:修改密码(清空会话)、下载备份
- 审计:item.update / settings.update
- 测试 46 passed(entries 4 例)
- 文档:ADMIN-PLAN P2/P3 记录、README
2026-10-29 12:59:21 +08:00
stash 9ae5a455c5 feat(admin): 登录改页内弹窗 + P1 站点设置(页头/侧栏)
- 登录:LoginDialog(Modal)替代 /login 页面;/login 重定向回首页
- 设置接口:GET /api/settings 公开读、PATCH 需登录 + version 乐观锁(409/400)
- setting 表存 site JSON:页头(启用/速度/条目+标签+色调)、侧栏(hidden/order)
- /admin:Tabs 页头/侧栏,条目增删与上下移,保存后首页生效
- 首页应用设置:ticker 文本占位符与速度、侧栏顺序与显隐
- 测试 42 passed(settings 5 例)
2026-10-29 12:52:29 +08:00
stash dea7781b86 feat(auth): P0 登录与会话(迁移 v16、argon2id、HttpOnly Cookie)
- 迁移机制:schema::migrate 支持 15→16,新增 admin/session/setting 表
- 接口:session/setup/login/logout/password;SameSite=Strict + Origin 校验 + 失败限流
- 会话只存 token 的 sha256;改密清空全部会话
- 前端:/login 独立壳(首次=设置密码),页头登录/退出切换
- 测试:37 passed(迁移 + 登录/会话 4 例)
- 文档:README 登录说明、docs/ADMIN-PLAN.md 方案与 P0 记录
2026-10-29 12:41:03 +08:00
stash 1aac8bcbe7 docs: 后台/编辑能力方案(登录、设置页、就地编辑、数据模型、风险清单) 2026-10-29 12:26:27 +08:00
stash fcba8274c8 fix(web): 渐隐遮罩不吃布局高度(抵消 gap)
- margin-bottom 改为 -32px - gap,首卡回到 148,滚动高度回到 979
2026-10-29 11:34:22 +08:00
stash f456829d16 fix(web): 内容区顶部渐隐恢复(content-veil)
- 滚动后用 sticky 遮罩在内容顶端做 32px 渐变,避免卡片/标题被硬切
- 未滚动时 opacity 0,滚动阈值沿用 scrolled 状态(>4px)
- 尊重 reduced-motion
2026-10-29 11:33:00 +08:00
stash 22f7853bdd feat(web): 页头滚动信息、移除返回按钮、面包屑左侧可点、侧栏加首页
- 页头中间信息改为无缝滚动 ticker(3 份拼接,24s 线性,尊重 reduced-motion)
- 去掉分区 / 条目 / 文章三处「返回」「返回首页」按钮
- 面包屑移到左侧,中间项可点击并通过 onNavigate 走 SPA 路由
- 侧栏新增「首页」,首页时高亮,点击回首页
2026-10-29 11:26:47 +08:00
stash 2724d6f3ff fix(web): 计数角标改回悬挂在标题右上角,搜索框圆角恢复 12px
- 角标:绝对定位 top -8 / right -18、16 高、8 圆角、12/700、危险色阴影(原来跟在标题后面占行内空间)
- 标题前色条补 8px 右间距,标题起点与旧版一致
- 搜索框:圆角 6 → 12(旧版 card-box 的圆角)
2026-10-29 10:23:09 +08:00
stash de1af92ee5 fix(web): 条目详情恢复 24px 标题、179px 封面与左对齐正文
- 标题:level1 24/600(原来 level2 18/500)
- 封面:portrait 179x269、square 96x96,margin 18/10,底色 primary-soft(原来 160 宽、灰底)
- 标签:22 高、12px、2px 10px 内边距(原来 24 高 13px)
- 正文段落:width 100%、14/1.7、次级色、左对齐(原来 16px 主色,flex 下收缩成内容宽而居中)
- 引用:引号 32/700 红色 35%(原来 18px 主色)
2026-10-29 10:17:54 +08:00
stash 43bf742df6 fix(web): 分区头恢复 44px 圆形图标、24/700 标题与 [n] 计数
- 图标:44×44 圆形 + 主色浅底(原来是裸字形 40px)
- 标题:24/700(原来用 Title level=2 的 18/500)
- 计数:改回旧版 [12] 文本(原来用了 Badge 胶囊)
- 间距:图标下 8、标题行高 1.5、计数上 4,与原版逐像素一致
2026-10-29 09:22:59 +08:00
stash ffb96ec7d1 fix(web): 文章头恢复居中排版,内容区补 4px 右留白
- 文章头:align-items center + text-align center + gap 12 + padding 4px 0(旧版规则)
- 文章元信息:改回 .article-meta 12px 行,圆点 3px、左右 8px(原来用 Space + 13px 文本)
- 内容区补 padding-right 4px(旧版 scroll 容器的留白),整体宽度与原版一致
2026-10-29 09:16:59 +08:00
stash 95b6cedb06 fix(web): 搜索框聚焦双框与滚动条
- 组件库 Input:聚焦只用边框变主色,不再叠加 outline(文本框点击也会命中 :focus-visible)
- 页面全局 :focus-visible 收窄到非表单控件,避免覆盖输入框自身的 outline 处理
- 搜索框恢复旧版浅边框 + 卡片阴影,聚焦不追加边框/焦点环
- Layout 新增 hideScrollbar,内容区滚动条隐藏(旧版行为)
2026-10-28 20:29:36 +08:00
stash 1942e201e9 fix(web): 视觉与交互 1:1(页头/侧栏/卡片/详情/动效)
对齐项(8080 vs 8081 实测,误差 ≤1px):
- 侧栏:资料卡 224、服务面板 144、导航 143,navTop 548 与原版一致
- 分区卡:142/142/327/270 vs 142/142/326/269;标题 16/1.2、更多按钮 24px
- 应用条目:48px 圆形头像 + 10px 小认证标;hover 名字变主色(不再整行变色)
- 封面:hover 上浮 2px(0.2s ease),去掉缩放;CES 封面恢复 16/10
- 搜索框:44 高、无边框 + 卡片阴影
- 服务面板:状态药丸 22、统计 12/18、操作按钮 28、刷新时图标原地旋转
- 详情:返回链接 hover 下划线、跳转按钮 hover 深主色、view-in 淡入动画
- 菜单:hover 主色、子菜单 0.28s grid 高度动画、chevron 6px 旋转
- 回到顶部:right 30、top 50%、滚动后淡入(补内容区滚动监听)
- 主题:卡片阴影恢复 2%/6%(旧版值)
2026-10-28 20:16:41 +08:00
stash 6b0c0a62e9 fix(web): 1:1 对齐——页头通栏、内部滚动、侧栏菜单与详情导航
- Layout: screen 模式下约束 body/main 高度,恢复内容区内部滚动(原来内容把根撑高被裁切)
- Layout: contentOffset=88,滚动区从页头下方开始,内容不再从悬浮搜索框下穿过
- 页头改回通栏 60px(contained 只包页身);资料卡头像恢复 64px 图标 + 品牌底色
- 详情导航条左右撑开(返回 / 面包屑),不再挤在左侧
- 侧栏菜单:行高 39、字重 500、圆角 8、CSS chevron、项间距 4px
- 服务面板去掉多余分隔线与外边距,恢复原高度
2026-10-28 19:45:37 +08:00
stash 1bdcca025b fix(web): 恢复与旧版一致的布局与组件参数
- 布局:Layout 使用 contained(1380)+gutter20+siderWidth240+gap20、solid 60px 页头、整屏内部滚动
- 搜索框恢复悬浮居中;侧栏与内容恢复 52/88px 顶部偏移
- 新增并使用 @stash/ui 的 Cover(海报 2:3 / 图文 4:3 / 宽图)替换手写封面
- 头像恢复 64px 品牌底色;正文恢复 14/1.9;详情标题、标签、引用、统计、跳转按原版还原
- 分区卡 padding 20、分区标题 16、网格间距 12/16 对齐原值
2026-10-28 18:12:14 +08:00
stash 64f1fbf906 refactor(web): 前端改用 @stash/ui 组件库
- 新增本地依赖 @stash/ui(file:../../stash-ui),引入其 style.css 与设计令牌
- 页面骨架改用 Layout(contained + header/sider),搜索框改为吸顶
- 卡片/菜单/按钮/标签/头像/分页/空态/提示全部换成组件库组件
- 删除本地 iconfont 与 1400 行手写组件样式,App.css 只保留页面级样式
- 数据层、路由、过滤逻辑未改动;33 个 Rust 测试通过
2026-10-28 12:44:50 +08:00
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
76 changed files with 14376 additions and 3880 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
+296
View File
@@ -0,0 +1,296 @@
# 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. 图标
图标统一来自组件库 `@stash/ui`:
```tsx
import { Icon } from '@stash/ui'
<Icon name="search_3_line" />
```
可用名称见 `@stash/ui` 导出的 `ICON_NAMES`,尺寸与颜色分别由 `size` / CSS `color` 控制。`stash-web` 不再维护本地 iconfont 或 `Icon.tsx`。
---
## 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` |
| 新增图标 | 在 `@stash/ui` 的 iconfont 中补图标,再通过 `<Icon name>` 使用 |
| 新增分组 | 后台「分组管理」新建根分组 / 子分组,内容挂在分组上 |
| 调整侧栏导航 | 后台「侧边栏」勾选要显示的卡片与分组 |
| 新增卡片类型 | 在 `AppNext.tsx` 的 `renderEntries` / `renderCard` 增加分支 |
| 调整分区样式 | [`stash-web/src/App.css`](stash-web/src/App.css) 的「分区卡片」段落 |
| 换背景图 | 替换 `stash-web/src/assets/site-bg.jpg` |
---
## 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
+236
View File
@@ -0,0 +1,236 @@
# Stash 交接文档
> 写于 2026-10-29。新会话第一件事:读这份 + `docs/ADMIN-PLAN.md` + `stash-ui/README.md`。
> **⚠️ 这是一份历史快照,只保留当时的决策背景。** 2026-11-01 之后又做了这些改动,
> 下面正文里提到的文件名与页面结构可能已经不同,以 `README.md` 为准:
>
> - 前端 `App.tsx` → `AppNext.tsx`,后台 `admin/AdminPage.tsx` → `admin/AdminNext.tsx`(旧文件已删除);
> - 后台从横向 Tabs 改成**左侧导航 + 右侧内容**,页头归到「首页管理」下,
> Tab 名改为 分组管理 / 数据管理 / 首页管理(页头·侧边栏·内容卡片)/ 资料管理 / 系统管理;
> - 新增 `/interface` 接口一览页;侧栏「查看接口」走站内路由,不再直接开 `/api/projects`;
> - 接入 `@stash/ui` 组件库:前后台所有卡片、表格、开关、文字按钮都来自库里,
> 明暗主题(浅色 / 深色 / 跟随系统)由令牌接管;
> - 内容库表结构升到 v23 并**有了自动迁移**,卡片模型是 `card` + `card_group`;`section_block` 与旧 `node` 兼容字段已删除。
## 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/catalog2 分组树 + 卡片(新前台/后台)
GET /api/cards 卡片列表
POST /api/nodes {key,title,parentKey?,icon?,sort?,enabled?} 需登录
PATCH /api/nodes/{key} {title,parentKey?,sort?,icon?,enabled?} 需登录
DELETE /api/nodes/{key} 需登录,级联
POST /api/cards {title} 需登录
PATCH /api/cards/{id} {title?,sort?} 需登录
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}
GET /api/backup SQLite 快照附件 需登录
```
## 5. 数据库与迁移
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。
- 当前 `PRAGMA user_version = 23`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21→22→23、更高版本拒绝启动。
- 格式在 `entry.format`(apps / posters / articles),同组可混合;`node` 不再保存格式。
- v23 迁移删除 `section_block` 表与 `node` 的 `view_mode / empty_text / show_in_sidebar / allow_groups` 字段。
- `settings.sidebar.show_profile / show_service` 控制侧栏两张卡片显隐(默认 true)。
- 首页滚动:`#page-scroll`(Layout 的 body)是唯一滚动容器,搜索框固定在其上方;左右两栏顶部对齐,左侧到底后通过 `translateY` 吸底,右侧继续滚动;滚动条隐藏;页头实底,搜索框 50% 透明,顶部另有一层上下渐隐的软虚化(`blur(8px)`)。
- 本地服务是运行时卡片,但 `card` 表里有一行 `local-service` 只用于排序;内容来自 `/api/projects`,后端禁止删除/改名/配置分组。
- `node` 是统一分组树(根节点 `kind='section'`,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。
- 主要表:`node`、`entry`、`entry_tag`、`card`、`card_group`、`admin`、`session`、`setting`、`audit`。
- `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。
- **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。
## 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/{id}`;当前只保留 `/api/catalog2` 与 `/api/nodes` |
| 登录限流 | 进程内计数,重启清空 |
## 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`)。
+499 -172
View File
@@ -1,151 +1,443 @@
# 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 接口。
## 快速开始
### 环境要求
组件库 [`stash-ui`](https://gitea.ngmao.com/CNWei/stash-ui) 以 `file:` 方式挂在 `stash-web` 上,
所以**两个仓库要放在同一级目录**:
- Rust 工具链,需支持 Rust 2024 Edition。
- Cargo。
### 启动示例
```text
<任意目录>/
├── stash/ # 本仓库
└── stash-ui/ # 组件库,必须先构建出 dist
```
```bash
# 托管默认目录 ./public,监听 127.0.0.1:8080
cargo run -p stash-cli
# 1. 先构建组件库(stash-web 依赖它的 dist)
cd stash-ui
npm install && npm run build
cd ../stash
# 托管指定目录并修改端口
cargo run -p stash-cli -- --root ./sites --port 3000
# 2. 再构建前端(dist 会被内嵌,缺了它 cargo build 会直接失败)
cd stash-web
npm install
npm run build
cd ..
# 允许局域网设备访问
cargo run -p stash-cli -- --root ./sites --host 0.0.0.0 --port 3000
# 3. 编译并运行
cargo run # 托管 ./public,监听 127.0.0.1:8080
# 关闭 CORS
cargo run -p stash-cli -- --root ./sites --no-cors
# 自定义目录 / 端口 / 对外暴露
cargo run -- --root ./sites --port 3000
cargo run -- --root ./sites --host 0.0.0.0 --port 3000
```
启动成功后访问终端中显示的地址即可,例如:
改了组件库之后要重新 `npm run build`(库)→ `npm run build`(stash-web)→ `cargo build`,
顺序不能反:`cargo build` 内嵌的是 `stash-web/dist`。
启动后打开终端里打印的地址即可,例如 `http://127.0.0.1:8080/`。
默认托管目录 `./public` 不存在时会自动创建;首次启动还会与二进制同级创建 `data/`,
并在其中生成内容库 `stash.db`(已内置示例内容,可以直接用 SQLite 工具修改)。
环境要求:
- Rust 工具链(2024 Edition)
- Node.js 24,推荐用 [Volta](https://volta.sh/) 管理;`stash-web/package.json` 里已用 `volta` 字段固定版本
### 登录与设置(v0.1.1 起)
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
- 登录后点「站点设置」进入 `/admin`,左侧导航 + 右侧内容:
- **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除;
- **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除;
- **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示);
- **资料管理**与**系统管理**(改密码、下载整库备份);
- 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效;
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
- `/admin` 的「系统管理」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
- 读接口公开(本地工具可只读使用),写接口(分组、条目、卡片、设置、改密)要求登录并校验同源 `Origin`;
- 明暗配色:站点默认值在「页头 → 配色」(跟随系统 / 浅色 / 深色),前台页头的月亮按钮写的是**本机偏好**(localStorage),不需要登录;
- 完整接口清单可以直接开站内页面 `/interface` 查看;
- 忘记密码:删除 `admin` 表里的那一行(或整个库)后重启,会回到「设置密码」;
- 方案与后续阶段见 [`docs/ADMIN-PLAN.md`](docs/ADMIN-PLAN.md)。
## 工作方式
请求按下面的顺序命中:
| 路径 | 处理方式 |
| --- | --- |
| `/api/*` | JSON / SSE 接口 |
| `/`、`/index.html` | 内嵌前端的 `index.html`(`no-cache`) |
| `/c/**`、`/g/**` | 前端路由地址(分组页 / 条目详情),没有实体文件时同样返回 `index.html` |
| `/admin`、`/login`、`/interface` | 固定页面地址,同样返回 `index.html` |
| `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) |
| `/favicon.svg` | 内嵌前端根目录文件 |
| 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` |
| 仍未命中 | 内置 404 页面(显示请求路径) |
托管目录下的每个一级子目录,只要包含 `index.html` 或 `index.htm`,就会被识别为一个项目,
通过 `/<目录名>/` 访问。目录名会做 URL 路径段编码。
## 前端路由
控制台是单页应用,但「当前在哪一页」写在地址栏里,所以刷新、收藏、前进后退都不会丢上下文:
| 地址 | 页面 |
| --- | --- |
| `/` | 首页卡片(按后台勾选的分组组合) |
| `/g/<分组>`、`/c/<分组>` | 分组页(自身内容 + 子分组) |
| `/g/<分组>/i/<条目>` | 条目详情 |
| `/interface` | 接口一览(站内页面,侧边栏「查看接口」进入) |
| `/admin` | 站点设置(左侧导航 + 右侧内容) |
地址里的每一段都是**业务键**(也就是接口 JSON 里的 `id`):库里是 `node.key` / `entry.key`,
本地项目则是目录名(访问地址仍然是 `route_path`,两者不再混用)。
几条约定:
- 条目地址带上它自己所在的分组,所以**一个条目只有一个地址**;刷新之后点「返回」
仍然落回条目所属的分组,而不是去猜一个返回目标;
- 「返回」按钮在应用内有上一页时交给浏览器(`history.back()`),
直接打开地址(刷新 / 收藏,应用内没有上一页)时退到上面的推导结果;
- 服务端只把 `/c/`、`/g/` 两个前缀和 `/admin`、`/login`、`/interface` 三个固定地址回落到入口页
(`src/server.rs` 的 `SPA_PREFIXES` / `SPA_PAGES`),托管目录里写错的地址仍然是 404 页面——
不做「所有未命中都返回 200」的万能回落,否则分不清是路由拼错还是文件真的不存在;
- 地址转换规则在 [`stash-web/src/data/view.ts`](stash-web/src/data/view.ts),
实际解析与跳转在 [`stash-web/src/AppNext.tsx`](stash-web/src/AppNext.tsx) 的 `useRoute` / `navigate`。
新增一类页面时两处都要改:前端加前缀,服务端把它加进 `SPA_PREFIXES` 或 `SPA_PAGES`;
- 站内跳转统一走 `navigate()`(`AppNext.tsx`),不整页刷新;按住 Ctrl/Cmd 点卡片或链接仍可新标签打开。
## 目录结构
```text
http://127.0.0.1:8080/
.
├── 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[]
│ ├── settings.rs # 站点设置的 JSON 结构与校验
│ └── seed.json # 内嵌种子数据(编译进二进制)
├── stash-web/ # React + Vite + TypeScript 控制台
│ ├── package.json # 含 volta 版本固定;@stash/ui 用 file:../../stash-ui 引入
│ ├── vite.config.ts # 开发期 /api 代理到 127.0.0.1:8080
│ └── src/
│ ├── AppNext.tsx # 前台外壳:路由、卡片、侧栏、搜索
│ ├── theme.ts # 明暗主题的本机偏好(localStorage)
│ ├── settings.ts # 站点设置的读写与默认值
│ └── components/
│ ├── admin/ # 后台(AdminNext:左侧导航 + 各面板)
│ ├── api/ # 接口一览页
│ ├── header/ # 页头(品牌、滚动信息、操作图标)
│ └── sidebar/ # 侧栏卡片与本地服务面板
├── data/ # 内容库目录(与二进制同级,运行时生成)
│ └── stash.db # SQLite 内容库
└── public/ # 默认托管目录(可放多个前端项目)
../stash-ui/ # 同级目录:@stash/ui 组件库(令牌 / 组件 / 展示站)
```
### 构建发布版本
## 前端怎么被内嵌
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` 当静态资源对外提供,
启动时会直接报错拒绝。
### 表结构
七张表:
- **分组(`node`)是唯一容器**——分区与分组是同一张自引用树,只是层级不同;
- **内容(`entry`)只挂在分组上**,不直接挂在分区;
- **首页卡片由 `card` + `card_group` 决定**:卡片只存标题与顺序,勾选哪些分组、每个分组预览几条写在 `card_group`;
- **侧栏直接由分组树派生**(`enabled` 与 `sort`),不再有独立的导航表;
- **站点设置整份存在 `setting`**(JSON + `version` 乐观锁),`admin` / `session` 存管理员与会话,`audit` 记操作。
文章正文是一整段 Markdown,直接存在 `entry.body` 里(一篇文章一行),不再拆成正文块表。
主键统一是 `id INTEGER PRIMARY KEY`(SQLite rowid,自动分配,不用 `AUTOINCREMENT`),
另有一列 `key` 作为**唯一业务编码**;外键一律写成 `<目标>_key`,指向对方的 `key`。
表结构有版本号:`PRAGMA user_version` 记录当前版本(`src/db/schema.rs` 的 `SCHEMA_VERSION`,
当前 22),启动时逐级迁移旧库,不需要删库重建。
#### node —— 分区与分组(一棵容器树)
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
| `key` | TEXT | 业务编码,全局唯一(如 `recommend` / `ui`) |
| `parent_key` | TEXT? | 父节点编码:分组指向所属分区,分区为 NULL |
| `title` | TEXT | 展示名:分区标题 / 分组小标题 |
| `kind` | TEXT | `section` 顶层分区 / `group` 分区下的分组 |
| `bar_color` | TEXT? | 标题左侧色条颜色(如 `#3858f6`) |
| `icon` | TEXT? | 图标:可以是字符(`🎬`),也可以是 iconfont 图标名(`film_line`) |
| `preview_limit` | INTEGER? | 分组在卡片里的默认预览条数;为空时按类型取默认 |
| `enabled` | INTEGER | 是否启用,`0` / `1`;停用后侧栏与卡片都不再出现该子树 |
| `sort` | INTEGER | 同级顺序,越小越靠前 |
两条约束写在表上:
- `CHECK`:`section` 必须 `parent_key IS NULL`;`group` 必须有父节点;
- `key` 唯一(UNIQUE),全局不重复。
#### entry —— 内容条目(网站 / 电影 / 文章共用)
| 字段 | 类型 | 说明 / 前端用途 |
| --- | --- | --- |
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
| `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 |
| `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 |
| `format` | TEXT | 展示格式:`apps` / `posters` / `articles`;逐条设置,同一分组可混合 |
| `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`,改编码时条目自动跟随。
三种内容共用一张表,只是**用到的字段不同**,形态由每条自己的 `entry.format` 决定:
| 类型 | 常用字段 |
| --- | --- |
| 应用 | `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` 都存这里——它们都是「有序字符串列表」,
前端只是在不同版式里渲染。
#### card / card_group —— 首页卡片
````text
card 一张卡片 = 标题 + 顺序(内容由 card_group 勾选,卡片本身不存条目)
card_group 卡片包含哪些分组 + 每个分组的预览条数与先后
````
| `card` 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | INTEGER | 主键(AUTOINCREMENT) |
| `key` | TEXT | 业务键,唯一;`local-service` 是内置的本地服务卡片 |
| `title` | TEXT | 卡片标题 |
| `sort` | INTEGER | 卡片在首页的先后 |
| `card_group` 字段 | 类型 | 说明 |
| --- | --- | --- |
| `card_id` | INTEGER | 外键 → `card.id`,删卡片级联 |
| `group_key` | TEXT | 外键 → `node.key`,删分组级联 |
| `sort` | INTEGER | 分组在这张卡片里的先后 |
| `preview_limit` | INTEGER? | 这个分组预览几条;为空用分组自己的默认值 |
主键 `(card_id, group_key)`:同一张卡片里一个分组只出现一次。
一张卡片可以混合展示多个分组(不同格式也能混),顺序与条数各自独立。
旧版的 `section_block` 表已在 v23 迁移中删除;旧库升级时会先把数据回填到
`card` / `card_group`,再删表,不会丢内容。
### 表关系
```text
node (kind=section) 1 ──── n node (kind=group)
│ │
└────────── n entry ───────┘
│
└──── n entry_tag (标签 / 元信息)
card 1 ──── n card_group ──── 1 node(勾选的分组 + 每个分组的预览条数)
```
| 关系 | 外键 | 级联 |
| --- | --- | --- |
| 分区 → 分组 | `node.parent_key → node.key` | 删分区连带删分组 |
| 节点 → 条目 | `entry.node_key → node.key` | 删分区或分组连带删条目;改编码时条目自动跟随 |
| 条目 → 标签 | `entry_tag.entry_key → entry.key` | 删条目删标签 |
| 卡片 → 分组 | `card_group.card_id → card.id`、`card_group.group_key → node.key` | 删卡片或删分组都会清掉这一行 |
几条约束值得记住:
- **API 只暴露业务编码**:响应里的 `id` 都是 `key`,数字主键只在库内使用(卡片例外,用数字 id);
- `entry.node_key`:应用指向分组,海报与图文也指向分组(分组可以是根分组);
- 条目 `key` 是主键,全局唯一;
- **侧栏就是内容树**:分组按 `parent_key` 展开,名称取自 `node.title`,没有别名或跨区编排;
- **首页卡片 = 勾选的分组集合**:`card_group.sort` 决定分组在卡片里的先后,`preview_limit` 决定这个分组预览几条。
### 前端怎么用这些表
| 界面 | 用到的表 | 关键字段 |
| --- | --- | --- |
| 首页卡片 | `card` + `card_group` + `node` + `entry` | 卡片标题用 `card.title`;色条取该卡片第一个分组的 `bar_color`;每个分组按 `card_group.sort` 排序、按 `preview_limit` 取前 N 条 |
| 分组页 | `node` + `entry` | `icon`(hero 图标)、`title`、条目字段;根分组只列直接条目,子分组另起一块 |
| 应用详情 | `entry`(apps) | `name` `description` `icon` `href` `cover` `banner` `badge` `comment` `views` `likes` |
| 海报详情 | `entry` + `entry_tag` | `name`(→`title`)`cover` `comment` `summary` `href` + `tags` |
| 图文详情 | `entry` + `entry_tag` | `name`(→`title`)`description` `cover` `body` + `meta` |
| 侧栏导航 | `node` | 走 `parent_key` 的分组树,`enabled = 0` 的节点跳过;「本地服务」由前端排除 |
| 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 |
### 侧栏导航
侧栏不再是单独一层数据,**直接由分组树派生**:
```text
常用推荐(根分组,可折叠)
├── 常用站点(子分组 → 打开该分组)
├── UI 设计资源
├── 矢量图标
├── 免费壁纸
└── 开发工具
推荐电影(根分组,无子分组 → 直接打开)
CES 现场报道(根分组,无子分组 → 直接打开)
```
规则很简单:
- 有子分组时渲染成可折叠节点,子项就是子分组;
- 没有子分组时整行是链接;
- 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层;
- `enabled = 0` 的节点连同整棵子树都不出现;
- 顺序取 `node.sort`(在「分组管理」里改「排序权重」);
- 「本地服务」是前端拼出来的分类,侧栏按 key 直接排除。
### 首页卡片
一张卡片 = 一个标题 + 一组勾选的分组,分组各自渲染、可以混合格式:
```sql
-- 看看「常用推荐」这张卡片勾了哪些分组、各自几条
SELECT cg.group_key, n.title, cg.sort, cg.preview_limit
FROM card_group cg
JOIN node n ON n.key = cg.group_key
WHERE cg.card_id = (SELECT id FROM card WHERE key = 'card-common')
ORDER BY cg.sort;
```
需要调整时就改 `card_group`:`sort` 是分组在卡片里的先后,`preview_limit` 是预览条数(空表示用分组自己的默认值)。
这些在后台的「首页管理 → 内容卡片」里都能点点鼠标改完,改完即时保存。
### 自己改内容
1. 用任何 SQLite 工具(DB Browser for SQLite、`sqlite3` CLI、DBeaver…)打开 `data/stash.db`;
2. 内容改 `node`(分组树)与 `entry`(文章正文就是 `entry.body` 里的 Markdown),标签改 `entry_tag`,首页卡片改 `card` / `card_group`;
3. 提交修改后,页面会在 **5 秒内**自动刷新——服务端轮询 `PRAGMA data_version` 检测外部写入;
4. 也可以点侧栏的「重新扫描」或直接刷新浏览器。
新增条目时 `id` 不用填,`key` 必须写(全局唯一);`node_key` 填直接所在的分组编码。
先 `SELECT key, kind, parent_key FROM node` 看一眼现有编码即可。
### 重置
表结构有版本号(`PRAGMA user_version`,当前 22),启动时会**自动逐级迁移**旧库,
正常升级不用删库。只有在想回到出厂内容时才需要重置:关掉服务后删掉 `data/stash.db`,
下次启动会重新建表并灌入内嵌种子。
```bash
cargo build --release -p stash-cli
rm data/stash.db # Linux / macOS
Remove-Item data\stash.db # Windows PowerShell
```
构建产物为 `target/release/stash`;Windows 下为 `target/release/stash.exe`。
`SCHEMA_VERSION` 定义在 [`src/db/schema.rs`](src/db/schema.rs),改动表结构时把它 +1 并补一段迁移。
## 托管目录示例
## 前端开发
### 单个静态站点
```text
public/
├── index.html
├── assets/
│ ├── app.js
│ └── style.css
└── images/
└── logo.png
```bash
cd stash-ui && npm install && npm run dev # 组件库文档站 http://127.0.0.1:5199
cd stash-web
npm install
npm run dev # http://127.0.0.1:5173,/api 自动代理到后端 8080
npm run build # 产物输出到 stash-web/dist
npm run lint # oxlint
```
此时访问 `/` 会直接展示 `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`,另一个终端(stash-web 目录下)`npm run dev`。
改了 `@stash/ui` 里的组件要重新 `npm run build`(库)才会被 stash-web 用到;
`npm run dev` 只会热更 stash-web 自己的代码。
## 命令行参数
| 参数 | 默认值 | 说明 |
| --- | --- | --- |
| `-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 +447,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 +473,110 @@ GET /api/projects/events
Accept: text/event-stream
```
连接建立后,服务会立即发送当前项目快照,随后在文件监听检测到项目列表变化时推送新快照:
连接建立后立即推送一次当前快照,之后在项目列表变化时推送:
```text
event: projects
data: {"projects":[{"name":"docs","route_path":"/docs/"}]}
```
服务端会递归监听托管目录,将短时间内的连续文件事件合并为一次扫描,并且只在项目名称或访问路径实际变化时广播。SSE 连接每 15 秒发送一次保活消息,浏览器断开后会自动重连。
服务端递归监听托管目录,把短时间内的连续事件合并成一次扫描,只在项目名称或访问路径真正变化时广播;
每 15 秒发送一次保活消息,浏览器断开后自动重连。
## 项目结构
### 内容目录
```http
GET /api/catalog2
```
返回分组树 + 首页卡片 + 站点设置(结构见 [`types.ts`](stash-web/src/data/types.ts)),
数据来自 `data/stash.db`,一次全量下发,前端搜索在客户端完成:
```json
{
"groups": [
{
"key": "pin",
"title": "常用推荐",
"icon": "🔖",
"enabled": true,
"sort": 1,
"entries": [{ "id": "1", "name": "1", "format": "apps" }],
"children": [{ "key": "common", "title": "常用站点", "entries": [] }]
}
],
"cards": [
{
"id": 1,
"key": "card-pin",
"title": "常用推荐",
"groups": [{ "groupKey": "pin", "previewLimit": 4 }]
}
]
}
```
「本地服务」卡片在 `cards` 里也有一行 `local-service`,但内容由 `/api/projects` 运行时生成。
侧栏不再单独下发,前端直接用 `groups` 派生。
### 订阅内容库变化
```http
GET /api/catalog/events
Accept: text/event-stream
```
当内容库变化后推送一次(自己写入和其它连接写入都会触发),事件本身不带数据:
```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/catalog2`。检测基于 `PRAGMA data_version` 轮询 + 写接口主动广播。
### 其它接口
完整清单(方法 / 路径 / 权限)可以直接开站内页面 [`/interface`](http://127.0.0.1:8080/interface) 查看,
摘要如下:
| 分组 | 接口 |
| --- | --- |
| 健康与项目 | `GET /api/health`、`GET /api/projects`、`GET /api/projects/events`(SSE) |
| 内容库 | `GET /api/catalog2`、`GET /api/catalog/events`(SSE)、`POST /api/nodes`、`PATCH/DELETE /api/nodes/{key}`、`GET /api/items/{key}`、`POST /api/items`、`PATCH/DELETE /api/items/{key}`、`POST /api/items/{key}/view`、`POST /api/items/{key}/like`、`POST /api/cards`、`PATCH/DELETE /api/cards/{id}`、`PUT /api/cards/{id}/groups` |
| 站点设置 | `GET/PATCH /api/settings`、`GET /api/backup` |
| 认证 | `GET /api/auth/session`、`POST /api/auth/setup`、`POST /api/auth/login`、`POST /api/auth/logout`、`PATCH /api/auth/password` |
写接口(POST / PATCH / PUT / DELETE)都要求登录会话,并校验请求的 `Origin` 与本站同源。
## 测试
运行核心库测试:
```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/`、`/g/`,固定页面是 `/admin`、`/login`、`/interface`;新增一类页面时,
`stash-web/src/data/view.ts` 与 `src/server.rs` 的 `SPA_PREFIXES` / `SPA_PAGES` 必须一起改,
否则刷新那条地址会拿到 404 页面。
- 前进 / 后退能回到对应页面,但不恢复滚动位置:切换视图统一滚回内容区顶部。
- 内嵌资源占用了 `/assets/` 前缀,托管目录下如果存在同名目录会被遮蔽。
- `/` 固定由控制台占用,上一版「根目录有 `index.html` 就直接当单站点展示」的行为已取消;
托管目录里的项目统一走 `/<目录名>/`。
- 项目扫描只检查一级子目录,不递归查找更深层项目。
- 文件事件经过约 150ms 防抖,新项目出现在控制台可能有短暂延迟。
- 内容库被其它工具修改后,页面最长有 5 秒延迟才自动刷新;自己的写操作会立即广播。
- 内容库按单实例使用设计:多个进程同时写同一个库会在 `busy_timeout`(5 秒)后报错。
- 单管理员账号:只有一套密码,没有多用户 / 多角色;默认只建议绑定 `127.0.0.1`,
用 `0.0.0.0` 暴露到局域网前请确认目录内容适合被其它设备访问。
- 主题偏好按浏览器存(localStorage),换设备不会跟随;站点默认值在后台改。
-1018
View File
File diff suppressed because it is too large Load Diff
-198
View File
@@ -1,198 +0,0 @@
(() => {
"use strict";
const grid = document.querySelector("#project-grid");
const refreshButton = document.querySelector("#refresh-projects");
const status = document.querySelector("#refresh-status");
const projectCounts = document.querySelectorAll("[data-project-count]");
const liveStatus = document.querySelector("#live-status");
const liveStatusLabel = document.querySelector("#live-status-label");
if (!grid || !refreshButton || !status || !liveStatus || !liveStatusLabel) {
return;
}
document.body.classList.add("is-enhanced");
const createProjectCard = (project) => {
if (
typeof project.name !== "string" ||
typeof project.route_path !== "string" ||
!project.route_path.startsWith("/") ||
project.route_path.startsWith("//")
) {
return null;
}
const link = document.createElement("a");
link.className = "site-item";
link.href = project.route_path;
link.title = project.name;
const cardTop = document.createElement("div");
cardTop.className = "site-card-top";
const avatar = document.createElement("span");
avatar.className = "site-avatar";
avatar.setAttribute("aria-hidden", "true");
avatar.textContent = Array.from(project.name)[0]?.toUpperCase() || "A";
const open = document.createElement("span");
open.className = "site-open";
open.setAttribute("aria-hidden", "true");
open.innerHTML = `
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 17 17 7M8 7h9v9"></path>
</svg>
`;
cardTop.append(avatar, open);
const info = document.createElement("div");
info.className = "site-info";
const name = document.createElement("h3");
name.className = "site-name";
name.textContent = project.name;
const description = document.createElement("span");
description.className = "site-desc";
description.textContent = "本地静态应用";
info.append(name, description);
const footer = document.createElement("div");
footer.className = "site-card-footer";
const path = document.createElement("span");
path.className = "site-path";
path.textContent = project.route_path;
const ready = document.createElement("span");
ready.className = "site-ready";
ready.textContent = "READY";
footer.append(path, ready);
link.append(cardTop, info, footer);
return link;
};
let renderedSignature = JSON.stringify(
Array.from(grid.querySelectorAll(".site-item")).map((link) => [
link.title,
link.getAttribute("href"),
]),
);
const projectsSignature = (projects) =>
JSON.stringify(
projects.map((project) => [project.name, project.route_path]),
);
const renderProjects = (projects) => {
const nextSignature = projectsSignature(projects);
if (nextSignature === renderedSignature) {
return false;
}
grid.replaceChildren();
if (projects.length === 0) {
const emptyState = document.createElement("div");
emptyState.className = "empty-state";
emptyState.innerHTML = `
<span class="empty-visual" aria-hidden="true">
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
<path d="M8 13h8M8 16h5"></path>
</svg>
</span>
<h3>这里还没有项目</h3>
<p>在托管根目录下创建子目录,并放入 <code>index.html</code>,刷新后即可在这里看到它。</p>
`;
grid.append(emptyState);
renderedSignature = nextSignature;
return true;
}
const cards = projects.map(createProjectCard).filter(Boolean);
grid.append(...cards);
renderedSignature = nextSignature;
return true;
};
const updateProjectCounts = (count) => {
projectCounts.forEach((element) => {
element.textContent = String(count);
});
};
refreshButton.addEventListener("click", async () => {
refreshButton.disabled = true;
refreshButton.classList.add("is-loading");
grid.setAttribute("aria-busy", "true");
status.textContent = "正在重新扫描项目...";
try {
const response = await fetch("/api/projects", {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
if (!Array.isArray(payload.projects)) {
throw new Error("项目列表格式无效");
}
const changed = renderProjects(payload.projects);
updateProjectCounts(payload.projects.length);
status.textContent = changed ? "项目列表已刷新" : "项目列表已是最新";
} catch (error) {
console.error("刷新项目列表失败", error);
status.textContent = "刷新失败,请稍后重试";
} finally {
refreshButton.disabled = false;
refreshButton.classList.remove("is-loading");
grid.removeAttribute("aria-busy");
}
});
if (!("EventSource" in window)) {
liveStatusLabel.textContent = "手动刷新";
return;
}
const eventSource = new EventSource("/api/projects/events");
eventSource.addEventListener("open", () => {
liveStatusLabel.textContent = "实时同步";
liveStatus.classList.add("is-live");
liveStatus.classList.remove("is-reconnecting");
});
eventSource.addEventListener("projects", (event) => {
try {
const payload = JSON.parse(event.data);
if (!Array.isArray(payload.projects)) {
throw new Error("项目列表格式无效");
}
const changed = renderProjects(payload.projects);
updateProjectCounts(payload.projects.length);
if (changed) {
status.textContent = "检测到目录变化,项目列表已自动更新";
}
} catch (error) {
console.error("处理项目更新事件失败", error);
status.textContent = "收到项目更新,但无法解析";
}
});
eventSource.addEventListener("error", () => {
liveStatusLabel.textContent = "同步重连中";
liveStatus.classList.remove("is-live");
liveStatus.classList.add("is-reconnecting");
});
})();
+388
View File
@@ -0,0 +1,388 @@
# Stash 后台与编辑能力方案(讨论稿)
> 目标:登录后能改「左侧布局」「分区布局」「页头滚动信息」,并且能编辑内容。
> 状态:**待评审**,评审通过后按 §8 分期实现。
## 1. 结论先行
| 问题 | 建议 | 理由 |
| --- | --- | --- |
| 要不要单独的设置页? | **要**,`/admin`,只放「全局配置」 | 布局、页头、侧栏是低频、结构化、跨页面的配置,塞进当前页会让页面变编辑器 |
| 内容(条目/文章)在哪编辑 | **就地编辑**:详情页的 `[编辑]` 打开右侧 Drawer | 编辑需要上下文预览;跳设置页会丢上下文 |
| 本地服务面板纳入设置吗 | **只纳入「显隐/位置」**,数据本身不进设置 | 项目数、连接状态是运行时只读状态,不是可编辑配置 |
| 详情页要不要独立设置页 | **不要** | 内容编辑用 Drawer,全局配置才进 `/admin` |
一句话:**全局配置进 `/admin`,单个内容就地改。**
## 2. 登录方案(单用户、本地)
- 账号:单管理员(`admin`),密码用 **argon2id** 哈希存库;首次启动若无密码,生成随机密码打印到控制台 / 或首访引导设置。
- 会话:登录成功下发 **HttpOnly + SameSite=Lax + Secure(可选)** 的 Cookie(随机 256bit token,库里存哈希,7 天过期,可撤销)。
- 写操作:所有 `POST/PATCH/DELETE` 校验会话 + **CSRF token**(双提交 Cookie 或自定义头)。
- 读操作:页面与 `GET /api/*` 保持公开(本地工具,保持只读可用);写接口一律要求登录。
- 频控:登录失败 5 次锁 1 分钟,防止本地暴破。
- 不做的:多用户/角色、OAuth、找回密码(单用户场景用「重置密码」命令)。
## 3. 信息架构:`/admin`
| Tab | 内容 | 存储 |
| --- | --- | --- |
| 布局 | 侧栏项顺序/显隐/分组、分区卡片顺序、每卡展示的分组与条数 | `settings.layout` |
| 页头 | 滚动信息条目(文本/标签/强调色)、速度、是否启用;品牌名 | `settings.header` |
| 内容 | 分区、分组、条目的增删改与排序(批量) | 现有内容库表 |
| 系统 | 修改密码、本地服务面板显隐、备份/导出、恢复默认 | `settings.system` / `auth` |
侧栏与页头的实时预览:设置页右侧放一个缩小的 `Layout` 预览(复用组件库)。
## 4. 编辑模型
### 4.1 内容编辑(就地)
- 详情页 `[编辑]` → `Drawer`(宽 480)表单:标题、标签、描述、正文(Markdown)、封面、链接;
- 保存:`PATCH /api/items/:id`,乐观锁(`updatedAt`),冲突时提示「内容已被修改,刷新后重试」;
- 成功:`toast.success` + 局部刷新当前条目(不整页刷新)。
### 4.2 全局配置(`/admin`)
- 表单保存走 `PATCH /api/settings`,整块 JSON 校验后落库;
- 提供「恢复默认」与「导出 JSON / 导入」。
## 5. 数据模型
```sql
-- 设置:一个 key 一段 JSON,带版本号用于乐观锁
settings(key TEXT PRIMARY KEY, value TEXT NOT NULL, version INTEGER NOT NULL, updated_at TEXT);
-- 管理员(单行)
admin(id INTEGER PRIMARY KEY CHECK (id = 1), username TEXT, password_hash TEXT, updated_at TEXT);
-- 会话
sessions(token_hash TEXT PRIMARY KEY, created_at TEXT, expires_at TEXT, last_seen_at TEXT);
-- 审计(谁在什么时候改了什么)
audit(id INTEGER PRIMARY KEY AUTOINCREMENT, at TEXT, action TEXT, target TEXT, detail TEXT);
```
**迁移问题**:当前 `PRAGMA user_version = 15` 且「删库重建」。加这些表要引入**第一套迁移**(v15 → v16),否则老库升级会坏。这是必须先解决的前置项。
## 6. API 草案
```
POST /api/auth/login {username,password} -> set-cookie
POST /api/auth/logout
GET /api/auth/session -> {authenticated, username}
PATCH /api/auth/password {current,next}
GET /api/settings -> {layout, header, system, version}
PATCH /api/settings {partial} + If-Match: version
PATCH /api/items/:id 条目编辑(乐观锁)
POST /api/categories 新建分区
PATCH /api/categories/:id 分区配置(展示块、预览条数、色条)
DELETE /api/items/:id
```
## 7. 没被提到、但必须一起考虑的
### 安全
1. **XSS**:正文是 Markdown,现有渲染器不处理 HTML;编辑后必须转义/白名单,否则登录用户可自注入脚本(虽然是本地单用户,仍要防)。
2. **CSRF**:Cookie 会话必须配 token 或 `SameSite=Strict`。
3. **上传**:封面/配图上传的存储路径、大小限制、MIME 校验、防目录穿越(public 是静态托管目录)。
4. **密码**:哈希算法与参数、首次设置流程、重置命令。
5. **会话**:过期、撤销、记住我、并发登录。
6. **CSP / 安全头**:内嵌页面与 iconfont 的 CSP 白名单。
### 数据与迁移
7. ~~**迁移机制**:现在没有迁移,`user_version` 只是标记~~ → **已完成**:`SCHEMA_VERSION = 22`,启动时按版本逐级迁移(`src/db/schema.rs`),加表要连迁移一起写。
8. **默认值与校验**:设置 JSON 的 schema 校验、缺字段回退默认、坏 JSON 不能白屏。(已做:`src/db/settings.rs` 校验 + 前端默认值合并)
9. **备份/恢复**:编辑前自动备份(`stash.db.bak`)、一键导出/导入。(已做:`GET /api/backup` 导出快照)
10. **审计**:谁改了什么(单用户也需要排查)。(已做:`audit` 表)
11. **乐观锁/冲突**:多标签页同时编辑。(已做:`settings.version` 与 `entry.updated_at`)
12. **草稿/发布**:编辑后立即生效,还是保存草稿再发布?(内容库现在没有状态字段)
13. **撤销**:误删条目能否恢复(软删除 + 回收站)。
### 编辑体验
14. **未保存离开**:Drawer 关闭前的确认(beforeunload / 二次确认)。
15. **表单校验**:必填、长度、URL 合法性、错误定位。
16. **图片上传**:拖拽、进度、失败重试。
17. **排序交互**:拖拽排序(新组件),键盘可达的排序替代方案。
18. **实时预览**:Markdown 编辑预览(复用 `ArticleBody`)。
19. **移动端编辑**:Drawer 全宽、软键盘遮挡、保存按钮吸底。
20. **可访问性**:Drawer 焦点陷阱(已有 `<dialog>`)、表单 label/错误关联、快捷键。
21. **编辑模式提示**:登录后页面出现明显「编辑中」状态,避免误操作。
### 工程
22. **SSE 推送**:编辑后前端已有 `live` 通道,需要定义「设置变更」是否也推送。
23. **缓存**:设置读取缓存与失效策略。
24. **并发写 SQLite**:WAL、事务边界、写冲突重试。
25. **测试**:登录/权限/迁移/设置校验/编辑接口的单测 + e2e。
26. **权限边界**:未来多用户时,哪些接口要按角色收敛(现在先留 `require_auth` 中间件层)。
## 8. 分期建议
| 阶段 | 范围 | 依赖 |
| --- | --- | --- |
| **P0** | 迁移机制 v16 + admin/sessions/settings 表 + 登录/登出/会话 + 登录页 + 写接口鉴权 | 无 |
| **P1** | `/admin` 设置页:页头滚动信息、侧栏项显隐/顺序、分区卡配置 | P0 |
| **P2** | 内容就地编辑(Drawer 表单 + 乐观锁 + 富文本/图片上传) | P0 + 迁移 |
| **P3** | 备份/恢复、审计、回收站、导入导出 | P1/P2 |
## 9. 组件库缺口(P1/P2 会用到)
| 缺口 | 说明 |
| --- | --- |
| `Textarea` | 多行输入(Markdown、描述),当前只有单行 `Input` |
| `ColorPicker` | 分区色条 / 标签色 |
| `SortableList` | 拖拽排序 + 键盘上/下移 |
| 表单布局 | `FormField` 有了,但「两列/分组/折叠」还需要页面组合或补 `Form` |
| `Tabs`/`Drawer`/`Switch`/`Toast` | 已有,可直接用 |
## 10. 开放决策(需拍板)
1. 设置入口:独立 `/admin` 页面(推荐)/ 右侧设置抽屉 / 两者都有。
2. 登录范围:单用户密码(推荐)/ 仅局域网免密 + 编辑口令 / 多用户。
3. 读接口是否也要登录:公开(推荐)/ 全站需登录。
4. 内容编辑是否要「草稿 → 发布」两态:先不要(推荐,保持即时生效)/ 要。
5. 编辑图片:先只接受 URL(推荐,P2 后期再做上传)/ 首期就要上传。
## 11. 实施记录
### P29(2026-10-31,搜索框不受虚化/透明度影响)
- 移除 `.search-row > *:first-child` 的半透明与 `backdrop-filter` 覆盖,搜索框回到组件默认实底白色;
- 顶部软虚化只作用于搜索框周围区域,不再透过输入框;
- 验证:搜索框背景 `rgb(255,255,255)`、`backdrop-filter: none`、opacity=1。
### P28(2026-10-31,搜索框 30% 透明与分组块底部留白)
- 搜索框背景透明度从 50% 调到 30%(`rgba(255,255,255,0.3)`),保留 blur;
- 侧栏 `.sidebar-home` 增加 24px 底部内边距,分组导航块滚到底时距离视口底部保留 24px;
- 验证:搜索框 alpha=0.30;页面滚到底时 navBottom=856、viewportBottom=880,间距 24px。
### P27(2026-10-31,分类块圆角与顶部软虚化)
- 侧栏导航 `.nav-card` 是普通 div(不是 Card),之前没有圆角;补 `border-radius: 12px` + `overflow: hidden`,和资料/服务卡一致;
- 搜索框透明度提到 50%,模糊提到 14px;
- 新增 `.top-blur`:固定在 header 下方(top 60、height 120),`blur(8px)` + 18% 页面色渐变,并用上下 `mask-image` 渐隐,避免再次出现分割线;
- 验证:nav 圆角 12px、top-blur 存在且 blur 生效、搜索框仍可点击、滚动时顶部软虚化无硬边。
### P26(2026-10-31,顶部留白加高)
- 首页顶部偏移 `TOP_OFFSET` 从 88 调到 104,搜索框与内容卡片之间的空白从 28px 增加到 44px;
- Layout `contentOffset` 与 `#page-scroll` 的 `margin-top`/`height` 使用同一个常量,左右两栏仍从 164 对齐开始;
- 验证:content/sider top=164,search top=76 / bottom=120。
### P25(2026-10-31,去掉顶部块的接缝)
- 删除 `.top-dock` 整块:不再有 60–148 的半透明填充与底边,避免出现一条横向分割线;
- 搜索框继续 fixed 悬浮,页头保持实底;顶部与内容之间只剩页面背景的自然渐变;
- 背景图渐隐保持 80%,与内容起点无硬边;
- 验证:`.top-dock` 数量 0,左侧与右侧顶部背景连续。
### P24(2026-10-31,独立的半透明顶部块)
- 页头恢复实底(`headerVariant="solid"`),去掉 `.app > header` 的透明度覆盖;
- 页头下方新增独立 `.top-dock`:`position: fixed; top:60px; height:88px`,背景 28% 页面色 + `blur(10px)` + 半透明底边,作为承载搜索框的单独块,而不是让整条看起来像背景填充;
- 搜索框保持 60% 半透明;
- 背景图渐隐层恢复 80%(撤掉上一轮为露出背景做的 65%);
- 验证:dock top=60、height=88、alpha=0.28;header 纯白;搜索框可见;无 mask 元素。
### P23(2026-10-31,去掉遮罩、顶部透明)
- 删除 `scroll-fade` 整条遮罩及创建逻辑,页面不再有顶部渐隐/模糊层;
- 页头改为 `headerVariant="blur"`,并用 `.app > header` 把底色降到 55%;搜索框底色降到 60% 并保留轻微 blur;
- 背景图渐隐层高度从 80% 调到 65%,让页头到内容之间那一条保持背景原色,不再发白;
- 验证:`.scroll-fade` 数量为 0;header 背景 alpha=0.55;搜索框 alpha=0.60。
### P22(2026-10-31,遮罩合并为整条)
- 去掉内容区自带的 `.content-veil`,只保留一条固定遮罩;
- 遮罩宽度从侧栏左边缘一直覆盖到内容区右边缘(实测 left=50、width=1340),左右同一水平高度(top=148),不再像两个块拼接;
- 渐变、模糊参数不变,滚动后整体淡入。
### P21(2026-10-31,侧栏虚化遮罩与透明度)
- 侧栏顶部增加独立 fixed 遮罩层:位置跟随侧栏左右/宽度,固定在滚动区顶部(y=148),滚动后淡入;
- 遮罩用 `backdrop-filter: blur(14px)` + 45%→20%→透明的页面色渐变,既虚化又不挡内容;不能挂在侧栏内部(会被吸底 `translateY` 带走);
- 左侧资料卡 / 本地服务 / 导航卡改成 64% 不透明 + `backdrop-filter` 毛玻璃,露出页面背景;
- 修复 Tailwind/构建器只保留 `-webkit-backdrop-filter` 导致 Chromium 不生效的问题:只写标准属性;
- 验证:滚动后遮罩 top=148、opacity=1、backdrop-filter=blur(14px) saturate(1.1),卡片背景 alpha=0.64。
### P20(2026-10-31,双栏非对称吸底滚动)
- 两栏顶部对齐:去掉首页侧栏额外的 `padding-top`,两栏都从 `#page-scroll` 顶部(y=148)开始;
- 隐藏滚动条:`#page-scroll` 使用 `scrollbar-width: none` / `::-webkit-scrollbar { display:none }`;
- 非对称吸底:仍共用一个滚动容器;滚动时用 `translateY(max(0, scrollTop - (侧栏高度 - 视口高度)))` 补偿左栏,左侧内容到底后吸附在视口底部,右侧继续滚动;
- 右侧先到底、左侧更长时不补偿,两栏同步滚到整体底部;`ResizeObserver` 在内容变化时重算;
- 验证:初始左右 top 均为 148;滚动 120/300/600 时左侧 top 稳定在 144、bottom 贴住 900,右侧正常滚动;页面无滚动条。
### P19(2026-10-31,左右两栏共用滚动容器)
- 不再用 `sider.scrollTop = content.scrollTop` 模拟同步:把 Layout 的 body(`#page-scroll`)设为滚动容器,`margin-top: 88px`、高度 `calc(100% - 88px)`,左右两栏都在这个容器里;
- 搜索框仍 fixed 在页头下方;滚动内容从搜索框下方开始;左右两栏位移完全一致(滚动 300px 后两边 top 都是 -152);
- 内容区 `overflow: visible`,侧栏高度改为 auto;返回顶部与路由切换都改用 `#page-scroll`;
- 验证:滚动后两栏 top 相同、搜索框位置不变、返回顶部回到 0、切分组后滚动归零。
### P18(2026-10-31,搜索框下滚动、本地服务排序、拖拽末位修复)
- 前台恢复「内容区内部滚动」:页头与搜索框固定,滚动区域从搜索框下方开始;侧栏不再独立滚动,内容区滚动时同步设置侧栏 `scrollTop`,滚轮在侧栏上也驱动内容区;
- 本地服务卡片正式进入 `card` 表(v22,key=`local-service`,sort=-1),后台卡片列表可拖动排序,首页按顺序渲染;后端禁止删除、改名、配置分组;
- 修复拖拽排序:向下拖时插到目标之后,倒数第二项可以拖到最后一项;
- 验证:搜索框滚动前后位置不变;内容滚动 300px 时侧栏 `scrollTop` 跟随(受可滚高度限制);本地服务拖到末位、倒数第二拖到末位均落库;首页顺序与后台一致;删除本地服务返回 400。
### P17(2026-10-31,侧栏卡片显隐、本地服务卡片、同步滚动)
- 侧边栏设置顶部新增「侧栏卡片」:本地管理员 / 本地服务各一个开关,即时保存到 `settings.sidebar.show_profile / show_service`;与下方分组导航分区显示;
- 首页恢复「本地服务」卡片(由 `/api/projects` 运行时生成,应用网格),固定排在最前;移除卡片右上角三点;
- 首页卡片后台新增只读「本地服务」表:列出项目名 / 访问地址,标记不可删除,不提供删除/改名;
- 前台放弃整屏内滚动,改为页面整体滚动,左右两栏同步滚动;
- 验证:开关即落库、前台隐藏对应卡片、三点数量为 0、本地服务表无删除按钮、滚动 300px 两栏同步位移 300px。
### P16(2026-10-31,格式下沉到条目)
- 数据库 v21:`entry.format`(apps / posters / articles),老数据按所属根分组的 `view_mode` 自动回填;新库 seed 按分区写入;
- 分组管理移除「格式」字段与显示,根分组只作为容器;
- 内容弹窗新增「格式」选择,列表增加「格式」列;同一条目可单独设为应用/海报/图文;
- 前台改为按 `entry.format` 渲染:首页卡片、分组页同一分组内可同时出现应用网格与海报/图文网格;详情页按条目格式选择模板;
- 验证:迁移后 20 应用 / 13 海报;在应用分组内新建海报条目后,内容列表、首页卡片、分组页均能混合展示。
### P15(2026-10-31,卡片新建弹窗、标题保存/删除、拖动排序)
- 新建卡片改为弹窗:输入标题 → 确认新建 / 取消关闭;创建后自动选中新表;
- 卡片标题右侧增加「保存」和「删除」按钮,标题按 Enter 或保存按钮提交;
- 左侧卡片支持 HTML5 拖动排序,拖放后按顺序写入 `card.sort`,刷新后首页卡片顺序一致;
- 标签栏与内容之间补 20px 间距;左侧卡片增加拖动手柄和提示;
- 验证:弹窗取消/确认、保存/删除按钮、拖动 `sadf`/新卡到首位后 `/api/catalog2` 顺序变化。
### P14(2026-10-31,首页卡片改为侧栏 + 表格)
- 首页卡片不再纵向堆叠:左侧是卡片列表(名称 + 已选分组数),右侧是当前卡片的分组表格(分组名称 / 预览条数 / 展示开关);
- 「新建卡片」即新增一张表,创建后自动选中;卡片标题、删除在表格上方;
- 表格内开关、条数即时保存,未选分组显示 —;侧栏卡片可一键切换;
- 验证:切换卡片表格同步、新建后自动选中、表格开关即时写入 `card_group`、无卡片级保存按钮。
### P13(2026-10-31,首页卡片按侧边栏逻辑)
- 每张卡片的分组勾选改为与侧边栏一致:标题在左、开关在右、勾选/取消即时保存(`PUT /api/cards/{id}/groups`),去掉每张卡的「保存」按钮;
- 卡片标题、单组预览条数改为失焦保存;条数清空表示使用默认;
- 卡片删除仍保留图标按钮;验证:无卡片级保存按钮、开关在右、勾选后 `card_group` 即时更新、条数 2 与改名均落库。
### P12(2026-10-31,内容/侧边栏交互微调)
- 内容页与条目弹窗的分组下拉只显示分组名,去掉格式后缀与缩进空格,选项统一左对齐;
- 侧边栏页:开关移到右侧,拨动即写入 `settings.sidebar.tree_hidden`;移除「保存侧边栏 / 撤销改动 / 保存」按钮,保存成功用 Toast 提示;
- 验证:下拉选项无括号、x 对齐;侧边栏无保存按钮、开关在右、切换后设置即时落库。
### P11(2026-10-31,内容页列表化)
- 内容页改为与分组管理一致的列表:标题 / 跳转链接 / 所属分组 / 操作;
- 选中主分组时递归列出其所有子分组的条目(显示所属分组),选子分组只列该子树;
- 编辑弹窗始终显示「标识」字段(编辑时只读,创建时可填);
- 条目接口补齐图文的 `href`,列表统一展示跳转链接(空值显示 —);
- 验证:主分组 20 条、子分组 4 条;编辑弹窗标识为 `github` 且只读;`cargo test` 59 passed。
### P10(2026-10-31,分组管理页重做)
- 参考 `TT-Helper/需求/5297/1.html` 重做分组管理:树形表格(分组名称 / 排序权重 / 关联网页数 / 状态 / 操作),去掉「展开全部 / 折叠全部」,新建根分组放左侧;
- 分组弹窗:上级分组(可移动、防环)、名称、格式(根)、**图标(可留空)**、排序权重、状态;
- 数据层:`node.enabled`(迁移 v20)控制启停,禁用分组在前台侧栏/卡片隐藏;`node.icon` 支持 iconfont 名、字符或空;
- 排序修复:同一父级下编辑排序权重不再被当成「移动」重置为末尾;
- 验证:无图标/有图标、排序 5、禁用后前台隐藏、折叠行均通过;`cargo test` 59 passed。
### P9(2026-10-30,前台切换)
- **前台**:`AppNext` 改为读 `/api/catalog2`,首页按 `card` 勾选的分组渲染(可混合格式,每组条数独立),侧栏按分组树 + `tree_hidden` 渲染,分组页 `/g/<key>`、条目页 `/g/<key>/i/<id>`;
- **详情**:条目在分组子树内递归查找,面包屑显示完整祖先链;编辑仍用 Drawer + `PATCH /api/items`;
- **服务端**:SPA 回落增加 `/g/` 前缀;
- **兼容**:旧 `/c/...` 地址按第一段作为分组 key 跳转;旧 `/api/catalog`、旧后台组件暂时保留;
- 验证:首页卡片、混合格式卡片(应用 + 海报)、侧栏隐藏指定分组、分组页/条目页均通过。
### P8(2026-10-30,子分组开关)
- **与类型解耦**:分区新增 `allow_groups` 开关(迁移 v18);三种类型共用同一模板,开启后可建分组,关闭后平铺;
- **默认值**:老库升级时应用类开启、海报/图文关闭;已有分组的非应用分区自动开启;新建分区按类型给默认值,之后可随时改;
- **平铺兼容**:关闭开关时读取会把(历史)分组内条目一起平铺,内容不丢;首页卡片若指向分组会自动挪回分区;
- **后台**:「分区」每行新增「子分组」开关,**拨动即保存并立刻出现/收起分组管理**;「内容」弹窗的分组选择随开关出现;「侧栏导航」的建组入口同理;空分组不在侧栏显示;
- 验证:推荐电影(海报)开启开关 → 建组 → 加海报 → 详情按组显示;关闭 → 恢复平铺且条目仍在;`cargo test` 59 passed。
### P7(2026-10-30,统一内容模型)
- **模型统一**:分区(section)是容器,分组(group)是任意类型的可选子容器,条目可挂在分区或分组上;`view_mode` 只决定渲染方式;
- **catalog**:所有类型都输出 `groups`(带条目)与 `items`(直属分区条目);
- **渲染**:详情页三种类型都支持分组(多组显示小标题,直属条目进「未分组」);侧栏子菜单、首页卡片预览分组对所有类型生效;
- **后台**:「分区」「侧栏导航」对所有类型可建组;「内容」弹窗的分组选择新增「直属分区」,编辑可换组;
- **修复**:首页卡片配置为「整个分区」时应用类预览为空;catalog 条目查询改为按节点取,避免分组重复/错漏;
- 测试:59 passed。
### P6(2026-10-30)
- **后台新增内容**:`POST /api/items`(`key` + `nodeKey` + 条目字段)、`DELETE /api/items/{key}`;「内容」Tab 用弹窗新增,列表逐条编辑(含换分区/分组)与删除;
- **分组 CRUD**:`POST /api/categories/{id}/groups`、`PATCH /api/groups/{id}`(改名/侧栏显隐)、`DELETE /api/groups/{id}`(级联组内条目);「分区」Tab 内联管理,「侧栏导航」Tab 也可直接新建子分组;
- **卡片预览分组**:`PATCH /api/categories/{id}` 增加 `blockNodeKey`,可在「分区」Tab 选择首页卡片预览哪个分组;
- 后端校验:应用类条目必须挂在分组上;条目移动通过 `PATCH /api/items/{key}` 的 `nodeKey`;
- 测试:59 passed(新增条目创建/移动/删除 3 例、分组 2 例)。
### P5(2026-10-30)
- **跳转修复**:保存条目时规范化 `href`(`www.zhihu.com` → `https://www.zhihu.com`,站内路径保留,`javascript:` / `data:` 等协议拒绝),前端渲染再做一次兜底;
- **侧栏子分类**:`settings.sidebar` 增加 `hidden_groups` / `shown_groups` / `group_order`,侧栏 Tab 展示分组并支持单独显隐、上下移;
- **归类页**:新增 `PATCH /api/categories/{id}`,`/admin` 的「归类」Tab 可改分区类型(应用/海报/图文)、标题、图标、色条、预览条数;切换类型时自动迁移条目与首页卡片指向;
- 测试:54 passed(新增 href 规范化 3 例、分类更新 3 例)。
### P4(2026-10-29,收尾)
- 分区增删:`POST /api/categories`、`DELETE /api/categories/{id}`(需登录,级联内容,`local` 不可删),首页与侧栏共用;
- 资料设置:头像 URL / 名称 / 角色 / 简介 / 位置,应用到侧栏资料卡;
- 操作条:进入详情页记一次浏览、点赞可切换(localStorage 记状态)、分享复制链接、跳转外链;
- 交互细节:`[编辑]` 与标题垂直居中并留 12px 间距;设置页操作按钮与内容拉开间距;保存改用 Toast(2.4s 自动消失);设置页 Tabs 不再出现滚动条;
- 登录密码框增加显示/隐藏切换(错误后仍可用);
- 测试:48 passed(新增 categories 2 例、settings 资料校验)。
### P0(已完成,2026-10-29)
- 迁移机制:`user_version 15 → 16`,`schema::migrate` 逐级升级;v15 老库启动时自动补 `admin` / `session` / `setting` 三表;
- 登录:`POST /api/auth/setup`(仅首次)、`/login`、`/logout`、`PATCH /api/auth/password`;argon2id + sha256(token) 落库;
- 会话:HttpOnly + SameSite=Strict + Path=/,7 天;改密后所有会话失效;
- 防护:Origin 同源校验、连续失败 5 次锁 60 秒;
- 前端:`/login` 独立壳(入口按 pathname 分流)、页头登录/退出登录切换;
- 测试:后端 37 个单测(新增迁移 2 个 + 登录 4 个),登录流程 e2e 手动脚本验证通过。
**未做(P1/P2)**:`/admin` 设置页、侧栏/页头编辑、内容就编辑、`setting` 表的读写接口。
### P1(已完成,2026-10-29)
- 登录改为**页内弹窗**(`LoginDialog`):`/login` 旧地址重定向回首页,不再单开页面;
- 设置读写:`GET /api/settings`(公开)、`PATCH /api/settings`(需登录,`version` 乐观锁,冲突 409、校验失败 400);
- `setting` 表存 `key='site'` 的 JSON:页头(启用 / 速度 / 条目与标签/色调)、侧栏(隐藏 / 顺序);
- `/admin` 设置页:Tabs(页头滚动信息 / 侧栏导航),条目支持增删、上移下移、占位符 `{count}`/`{project}`/`{status}`;
- 首页应用设置:滚动条目的文本/标签/速度/启用,侧栏按顺序与显隐渲染;
- 测试:42 passed(新增 settings 5 例:默认值、版本递增、冲突、校验、坏 JSON 回落)。
### P1 扩展(2026-10-29,同日)
- 首页卡片可配置:显隐、顺序、标题覆盖、每卡预览条数(`settings.sections`,前端 `HomeView` 应用);
- 页头滚动信息支持**启用/禁用**(`header.enabled`,关闭后页头中间留空);
- 「编辑」按钮只在登录后出现(`canEdit`,未登录时详情页与文章页不渲染 `[编辑]`);
- 侧栏「首页」与卡片顺序相互独立:侧栏管导航,卡片管首页布局。
### P2(已完成,2026-10-29)
- 库新增 `Textarea`(stash-ui v0.2.0):`rows` / `resize` / `invalid`;
- 条目编辑:`GET /api/items/{key}`(公开读)、`PATCH /api/items/{key}`(需登录);
- 乐观锁:`entry.updated_at`(迁移 v17),冲突返回 409;
- 可编辑字段:标题、简介、标签、一句话评价、正文 Markdown、封面、跳转地址;
- 前端:详情页 `[编辑]` → `Drawer` 表单;保存后 `catalog.reload()` 局部刷新,不整页刷新;
- 审计:每次条目更新写入 `audit` 表;
- 测试:46 passed(新增 entries 4 例:更新/标签替换、冲突、空标题、不存在)。
### P3(部分完成,2026-10-29)
- 审计表 `audit`:记录 `item.update`、`settings.update`;
- 备份:`GET /api/backup`(需登录)用 `VACUUM INTO` 生成一致性副本并以附件下载;
- 系统设置 Tab:修改密码(成功后所有会话失效)、下载备份;
- **未做**:恢复/导入(需要停机替换文件或在线校验导入流程)、回收站/软删除、图片上传、拖拽排序(当前用上移/下移按钮替代)。
+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(())
}
+289
View File
@@ -0,0 +1,289 @@
//! 内容库的查询组装:把统一的 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::map_error,
error::StashError,
};
/// 条目行:与 `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,
}
/// 分组树节点(新视图用)
struct TreeNode {
key: String,
parent: Option<String>,
title: 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, 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)?,
icon: row.get(3)?,
bar_color: row.get(4)?,
preview_limit: row.get(5)?,
sort: row.get(6)?,
enabled: row.get(7)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
// 子节点索引(按 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 tags = load_all_tags(conn)?;
fn render(
conn: &Connection,
nodes: &[TreeNode],
children: &HashMap<Option<String>, Vec<usize>>,
tags: &HashMap<String, Vec<String>>,
idx: usize,
) -> Result<Value, StashError> {
let node = &nodes[idx];
let entries = load_node_entries(conn, &node.key)?;
let items = entries
.iter()
.map(|entry| item_for(entry, tags))
.collect::<Vec<_>>();
let child_values = children
.get(&Some(node.key.clone()))
.map(|list| {
list.iter()
.map(|child| render(conn, nodes, children, tags, *child))
.collect::<Result<Vec<_>, _>>()
})
.transpose()?
.unwrap_or_default();
Ok(json!({
"key": node.key,
"parentKey": node.parent,
"title": node.title,
"icon": node.icon.clone().unwrap_or_default(),
"barColor": node.bar_color.clone().unwrap_or_default(),
"previewLimit": node.preview_limit,
"sort": node.sort,
"enabled": node.enabled,
"entries": items,
"children": child_values,
}))
}
let roots = children
.get(&None)
.map(|list| {
list.iter()
.map(|idx| render(conn, &nodes, &children, &tags, *idx))
.collect::<Result<Vec<_>, _>>()
})
.transpose()?
.unwrap_or_default();
Ok(Value::Array(roots))
}
/// 全部条目标签(一次查询,树组装时用)
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)
}
/// 按条目自身的格式组装列表项。
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
}
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` 升序。
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)
}
+686
View File
@@ -0,0 +1,686 @@
//! 条目的读取与编辑(P2)。
//!
//! 编辑用 `updated_at` 做乐观锁:带上打开表单时的时间戳,
//! 别人已经改过就返回冲突,而不是静默覆盖。
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use crate::{db::map_error, error::StashError};
const MAX_NAME: usize = 120;
const MAX_BODY: usize = 100_000;
const MAX_TAGS: usize = 30;
const MAX_TAG_LEN: usize = 40;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct EntryRecord {
pub key: String,
pub format: String,
pub name: String,
pub description: String,
pub icon: String,
pub href: String,
pub cover: String,
pub banner: String,
pub badge: String,
pub comment: String,
pub summary: String,
pub body: String,
pub tags: Vec<String>,
pub updated_at: i64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EntryPatch {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub icon: String,
#[serde(default)]
pub href: String,
#[serde(default)]
pub cover: String,
#[serde(default)]
pub banner: String,
#[serde(default)]
pub badge: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub tags: Vec<String>,
/// 可选:把条目移动到另一个分区 / 分组(不传则保持原位)
#[serde(default)]
pub node_key: Option<String>,
/// 条目自身的展示格式:apps / posters / articles;不传默认 apps
#[serde(default)]
pub format: Option<String>,
}
/// 新建条目的请求体:`key` 是业务键,`nodeKey` 决定挂在哪个分区 / 分组。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewEntry {
pub key: String,
pub node_key: String,
pub item: EntryPatch,
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0)
}
/// 规范化跳转地址:补协议、拒绝危险协议、空值统一成空串。
///
/// 用户经常直接填 `www.zhihu.com`。原样存进 `href` 会被浏览器当成
/// 相对地址(点「跳转到」会跳到 `/c/.../www.zhihu.com`),所以这里统一补
/// `https://`。`href` 会原样渲染进 `<a>`,`javascript:` / `data:` 这类
/// 协议必须直接拒绝,不能只靠前端过滤。
fn normalize_href(raw: &str) -> Result<String, StashError> {
let value = raw.trim();
if value.is_empty() || value == "#" {
return Ok(String::new());
}
if value.chars().any(char::is_whitespace) {
return Err(StashError::InvalidDirectory("跳转地址不能包含空格".into()));
}
// 站内路径(单个斜杠开头);`//host` 是协议相对地址,按外链处理
if value.starts_with('/') && !value.starts_with("//") {
return Ok(value.to_string());
}
let lower = value.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
return Ok(value.to_string());
}
if value.starts_with("//") {
return Ok(format!("https:{value}"));
}
// 带「其他协议」的地址一律拒绝(javascript: / data: / vbscript: / file: / mailto: …);
// 只放行上面已经处理过的 http(s) 与站内路径。
if let Some((scheme, _)) = lower.split_once(':') {
let looks_like_scheme = !scheme.is_empty()
&& scheme.chars().next().is_some_and(|ch| ch.is_ascii_alphabetic())
&& scheme
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.');
if looks_like_scheme && !looks_like_domain(value) {
return Err(StashError::InvalidDirectory(
"跳转地址只支持 http(s)、站内路径或域名".into(),
));
}
}
if looks_like_domain(value) {
return Ok(format!("https://{value}"));
}
Err(StashError::InvalidDirectory(
"跳转地址需以 http(s):// 或 / 开头".into(),
))
}
/// 裸域名判断:`www.zhihu.com`、`example.com:8080/path` 这类没有协议头的地址。
fn looks_like_domain(value: &str) -> bool {
let host_port = value.split('/').next().unwrap_or("");
let host = host_port.split(':').next().unwrap_or("");
if !host.contains('.') {
return false;
}
let mut parts = host.split('.');
let Some(tld) = parts.next_back() else {
return false;
};
tld.len() >= 2
&& tld.chars().all(|ch| ch.is_ascii_alphabetic())
&& parts.all(|part| {
!part.is_empty()
&& part
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
})
}
fn validate(patch: &EntryPatch) -> Result<(), StashError> {
if let Some(format) = patch.format.as_deref() {
if !matches!(format, "apps" | "posters" | "articles") {
return Err(StashError::InvalidDirectory("格式不合法".into()));
}
}
if patch.name.trim().is_empty() {
return Err(StashError::InvalidDirectory("标题不能为空".into()));
}
if patch.name.chars().count() > MAX_NAME {
return Err(StashError::InvalidDirectory(format!(
"标题最多 {MAX_NAME} 个字"
)));
}
if patch.body.chars().count() > MAX_BODY {
return Err(StashError::InvalidDirectory("正文过长".into()));
}
if patch.tags.len() > MAX_TAGS {
return Err(StashError::InvalidDirectory(format!(
"标签最多 {MAX_TAGS} 个"
)));
}
for tag in &patch.tags {
if tag.chars().count() > MAX_TAG_LEN {
return Err(StashError::InvalidDirectory(format!(
"单个标签最多 {MAX_TAG_LEN} 个字"
)));
}
}
Ok(())
}
/// 读一条条目(编辑表单用),不存在返回 None。
pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashError> {
let row = conn
.query_row(
"SELECT key, COALESCE(format, 'apps'), name, COALESCE(description, ''), COALESCE(icon, ''), COALESCE(href, ''),
COALESCE(cover, ''), COALESCE(banner, ''), COALESCE(badge, ''),
COALESCE(comment, ''), COALESCE(summary, ''), COALESCE(body, ''), updated_at
FROM entry WHERE key = ?1",
[key],
|row| {
Ok(EntryRecord {
key: row.get(0)?,
format: row.get(1)?,
name: row.get(2)?,
description: row.get(3)?,
icon: row.get(4)?,
href: row.get(5)?,
cover: row.get(6)?,
banner: row.get(7)?,
badge: row.get(8)?,
comment: row.get(9)?,
summary: row.get(10)?,
body: row.get(11)?,
tags: Vec::new(),
updated_at: row.get(12)?,
})
},
)
.optional()
.map_err(map_error)?;
let Some(mut entry) = row else {
return Ok(None);
};
let mut statement = conn
.prepare("SELECT label FROM entry_tag WHERE entry_key = ?1 ORDER BY sort")
.map_err(map_error)?;
entry.tags = statement
.query_map([key], |row| row.get::<_, String>(0))
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
Ok(Some(entry))
}
/// 条目能挂到哪:分区本身,或分区下的分组(任意类型都支持分组)。
fn validate_target(conn: &Connection, node_key: &str) -> Result<(), StashError> {
let row: Option<(String, Option<String>)> = conn
.query_row(
"SELECT n.kind, p.kind
FROM node n LEFT JOIN node p ON p.key = n.parent_key
WHERE n.key = ?1",
[node_key],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(map_error)?;
let Some((kind, parent_kind)) = row else {
return Err(StashError::NotFound(format!("节点不存在: {node_key}")));
};
match (kind.as_str(), parent_kind.as_deref()) {
("section", _) => Ok(()),
// 多层分组:父级可以是分区或任意分组
("group", Some(_)) => Ok(()),
("group", _) => Err(StashError::InvalidDirectory(
"分组的父节点必须是分区或分组".into(),
)),
_ => Err(StashError::InvalidDirectory(
"条目只能挂在分区或分组下".into(),
)),
}
}
fn valid_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 64
&& key
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
}
fn write_tags(conn: &Connection, key: &str, tags: &[String]) -> Result<(), StashError> {
conn.execute("DELETE FROM entry_tag WHERE entry_key = ?1", [key])
.map_err(map_error)?;
for (index, tag) in tags.iter().enumerate() {
conn.execute(
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
params![key, index as i64, tag],
)
.map_err(map_error)?;
}
Ok(())
}
/// 新建条目:用于后台「内容」页添加网址 / 海报 / 图文。
pub fn create(conn: &mut Connection, new: NewEntry) -> Result<EntryRecord, StashError> {
let key = new.key.trim().to_string();
if !valid_key(&key) {
return Err(StashError::InvalidDirectory(
"标识只能用小写字母、数字与连字符(≤64)".into(),
));
}
let patch = new.item;
validate(&patch)?;
let format = patch.format.clone().unwrap_or_else(|| "apps".into());
let href = normalize_href(&patch.href)?;
validate_target(conn, &new.node_key)?;
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM entry WHERE key = ?1", [&key], |row| {
row.get(0)
})
.map_err(map_error)?;
if exists > 0 {
return Err(StashError::Conflict(format!("条目已存在: {key}")));
}
let tags: Vec<String> = patch
.tags
.iter()
.map(|tag| tag.trim().to_string())
.filter(|tag| !tag.is_empty())
.collect();
let timestamp = now();
let tx = conn.transaction().map_err(map_error)?;
let sort: i64 = tx
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM entry WHERE node_key = ?1",
[&new.node_key],
|row| row.get(0),
)
.map_err(map_error)?;
tx.execute(
"INSERT INTO entry (key, node_key, format, name, description, icon, href, cover, banner,
badge, comment, summary, body, hot, views, likes, sort, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0, 0, 0, ?14, ?15)",
params![
key,
new.node_key,
format,
patch.name.trim(),
patch.description,
patch.icon,
href,
patch.cover,
patch.banner,
patch.badge,
patch.comment,
patch.summary,
patch.body,
sort,
timestamp
],
)
.map_err(map_error)?;
write_tags(&tx, &key, &tags)?;
tx.execute(
"INSERT INTO audit (at, action, target, detail) VALUES (?1, 'item.create', ?2, ?3)",
params![timestamp, key, patch.name.trim()],
)
.map_err(map_error)?;
tx.commit().map_err(map_error)?;
load(conn, &key)?
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
}
/// 删除条目(后台内容页用)。
pub fn remove(conn: &Connection, key: &str) -> Result<(), StashError> {
let removed = conn
.execute("DELETE FROM entry WHERE key = ?1", [key])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("条目不存在: {key}")));
}
audit(conn, "item.delete", key, "")?;
Ok(())
}
/// 更新条目;`expected_updated_at` 与库里不一致时冲突。
pub fn update( conn: &mut Connection,
key: &str,
patch: EntryPatch,
expected_updated_at: i64,
) -> Result<EntryRecord, StashError> {
validate(&patch)?;
let format = patch.format.clone().unwrap_or_else(|| "apps".into());
// 跳转地址先规范化:补 https、拒绝 javascript: 等危险协议
let href = normalize_href(&patch.href)?;
if let Some(node_key) = patch.node_key.as_deref() {
validate_target(conn, node_key)?;
}
let tags: Vec<String> = patch
.tags
.iter()
.map(|tag| tag.trim().to_string())
.filter(|tag| !tag.is_empty())
.collect();
let timestamp = now();
let tx = conn.transaction().map_err(map_error)?;
let updated = tx
.execute(
"UPDATE entry SET
name = ?1, description = ?2, icon = ?3, href = ?4,
cover = ?5, banner = ?6, badge = ?7, comment = ?8,
summary = ?9, body = ?10, updated_at = ?11,
node_key = COALESCE(?14, node_key),
format = ?15
WHERE key = ?12 AND updated_at = ?13",
params![
patch.name.trim(),
patch.description,
patch.icon,
href,
patch.cover,
patch.banner,
patch.badge,
patch.comment,
patch.summary,
patch.body,
timestamp,
key,
expected_updated_at,
patch.node_key,
format
],
)
.map_err(map_error)?;
if updated == 0 {
return Err(StashError::Conflict(
"条目已被修改,请刷新后重试".into(),
));
}
tx.execute("DELETE FROM entry_tag WHERE entry_key = ?1", [key])
.map_err(map_error)?;
for (index, tag) in tags.iter().enumerate() {
tx.execute(
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
params![key, index as i64, tag],
)
.map_err(map_error)?;
}
tx.execute(
"INSERT INTO audit (at, action, target, detail) VALUES (?1, 'item.update', ?2, ?3)",
params![timestamp, key, patch.name.trim()],
)
.map_err(map_error)?;
tx.commit().map_err(map_error)?;
load(conn, key)?
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
}
/// 记录一次浏览:views +1,返回 (views, likes)。
pub fn bump_view(conn: &Connection, key: &str) -> Result<(i64, i64), StashError> {
conn.execute("UPDATE entry SET views = views + 1 WHERE key = ?1", [key])
.map_err(map_error)?;
counts(conn, key)
}
/// 点赞 / 取消点赞:likes 加一或减一(不低于 0),返回 (views, likes)。
pub fn set_like(conn: &Connection, key: &str, liked: bool) -> Result<(i64, i64), StashError> {
let delta: i64 = if liked { 1 } else { -1 };
conn.execute(
"UPDATE entry SET likes = MAX(0, likes + ?1) WHERE key = ?2",
params![delta, key],
)
.map_err(map_error)?;
counts(conn, key)
}
fn counts(conn: &Connection, key: &str) -> Result<(i64, i64), StashError> {
conn.query_row("SELECT views, likes FROM entry WHERE key = ?1", [key], |row| {
Ok((row.get(0)?, row.get(1)?))
})
.optional()
.map_err(map_error)?
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
}
/// 记录一条审计(设置保存等)。
pub fn audit(conn: &Connection, action: &str, target: &str, detail: &str) -> Result<(), StashError> {
conn.execute(
"INSERT INTO audit (at, action, target, detail) VALUES (?1, ?2, ?3, ?4)",
params![now(), action, target, detail],
)
.map_err(map_error)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.execute_batch(
"CREATE TABLE entry (
key TEXT PRIMARY KEY,
node_key TEXT NOT NULL,
format TEXT NOT NULL DEFAULT 'apps',
name TEXT NOT NULL,
description TEXT, icon TEXT, href TEXT, cover TEXT, banner TEXT,
badge TEXT, comment TEXT, summary TEXT, body TEXT,
hot INTEGER NOT NULL DEFAULT 0, views INTEGER NOT NULL DEFAULT 0,
likes INTEGER NOT NULL DEFAULT 0, sort INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE entry_tag (
entry_key TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL,
PRIMARY KEY (entry_key, sort)
);
CREATE TABLE audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at INTEGER NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT
);
CREATE TABLE node (
key TEXT PRIMARY KEY,
parent_key TEXT,
title TEXT NOT NULL,
kind TEXT NOT NULL,
view_mode TEXT
);
INSERT INTO node (key, parent_key, title, kind, view_mode) VALUES
('movie', NULL, '推荐电影', 'section', 'posters'),
('apps1', NULL, '常用推荐', 'section', 'apps'),
('g1', 'apps1', '常用站点', 'group', NULL);
INSERT INTO entry (key, node_key, name, updated_at) VALUES ('movie-1', 'movie', '千与千寻', 0);",
)
.expect("应能建表");
conn
}
fn patch(name: &str) -> EntryPatch {
EntryPatch {
name: name.into(),
description: "简介".into(),
icon: String::new(),
href: "https://example.com".into(),
cover: String::new(),
banner: String::new(),
badge: "推荐".into(),
comment: "一句话".into(),
summary: "正文".into(),
body: "## 标题".into(),
tags: vec!["推荐电影".into(), "动画".into()],
node_key: None,
format: None,
}
}
#[test]
fn update_replaces_fields_and_tags() {
let mut conn = db();
let saved = update(&mut conn, "movie-1", patch("千与千寻 2"), 0).expect("应能更新");
assert_eq!(saved.name, "千与千寻 2");
assert_eq!(saved.tags, vec!["推荐电影".to_string(), "动画".to_string()]);
assert!(saved.updated_at > 0);
let audit: i64 = conn
.query_row("SELECT COUNT(*) FROM audit", [], |row| row.get(0))
.expect("应能查审计");
assert_eq!(audit, 1);
}
#[test]
fn stale_timestamp_conflicts() {
let mut conn = db();
let saved = update(&mut conn, "movie-1", patch("A"), 0).expect("应能更新");
let error = update(&mut conn, "movie-1", patch("B"), saved.updated_at - 1)
.expect_err("应冲突");
assert!(matches!(error, StashError::Conflict(_)));
}
#[test]
fn rejects_empty_name() {
let mut conn = db();
assert!(update(&mut conn, "movie-1", patch(" "), 0).is_err());
}
#[test]
fn missing_entry_is_not_found() {
let mut conn = db();
let error = update(&mut conn, "none", patch("X"), 0).expect_err("应不存在");
assert!(matches!(error, StashError::Conflict(_) | StashError::NotFound(_)));
}
#[test]
fn normalizes_links_so_jump_works() {
// 裸域名补 https,避免被浏览器当成相对地址
assert_eq!(normalize_href("www.zhihu.com").unwrap(), "https://www.zhihu.com");
assert_eq!(
normalize_href("example.com:8080/path").unwrap(),
"https://example.com:8080/path"
);
// 已带协议的保持原样;站内路径保持站内
assert_eq!(normalize_href("https://example.com/a").unwrap(), "https://example.com/a");
assert_eq!(normalize_href("/git_helper/").unwrap(), "/git_helper/");
// 空值和占位符统一成空串
assert_eq!(normalize_href(" ").unwrap(), "");
assert_eq!(normalize_href("#").unwrap(), "");
}
#[test]
fn rejects_dangerous_or_unknown_links() {
for bad in ["javascript:alert(1)", "data:text/html,hi", "mailto:[email protected]", "not a url"] {
assert!(normalize_href(bad).is_err(), "{bad} 应被拒绝");
}
}
#[test]
fn update_stores_normalized_href() {
let mut conn = db();
let mut input = patch("知乎");
input.href = "www.zhihu.com".into();
let saved = update(&mut conn, "movie-1", input, 0).expect("应能更新");
assert_eq!(saved.href, "https://www.zhihu.com");
}
#[test]
fn creates_entry_under_section_or_group() {
let mut conn = db();
let saved = create(
&mut conn,
NewEntry {
key: "new-site".into(),
node_key: "g1".into(),
item: patch("新站点"),
},
)
.expect("应能创建");
assert_eq!(saved.key, "new-site");
assert_eq!(saved.name, "新站点");
let node: String = conn
.query_row("SELECT node_key FROM entry WHERE key='new-site'", [], |row| {
row.get(0)
})
.expect("应能查");
assert_eq!(node, "g1");
// 任何类型的分区都允许条目直属分区
let direct = create(
&mut conn,
NewEntry {
key: "direct-site".into(),
node_key: "apps1".into(),
item: patch("直属条目"),
},
)
.expect("应能创建");
assert_eq!(direct.key, "direct-site");
}
#[test]
fn moves_entry_between_nodes() {
let mut conn = db();
let mut input = patch("搬家");
input.node_key = Some("g1".into());
update(&mut conn, "movie-1", input, 0).expect("应能移动");
let node: String = conn
.query_row("SELECT node_key FROM entry WHERE key='movie-1'", [], |row| {
row.get(0)
})
.expect("应能查");
assert_eq!(node, "g1");
}
#[test]
fn deletes_entry() {
let conn = db();
remove(&conn, "movie-1").expect("应能删除");
assert!(remove(&conn, "movie-1").is_err(), "重复删除应失败");
}
}
+255
View File
@@ -0,0 +1,255 @@
//! 分组树的管理:多层分组、父级移动与排序。
//!
//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`),
//! 下面的节点都叫分组(`kind='group'`),可以继续嵌套。
//! 条目的展示格式由 `entry.format` 决定,分组不再保存格式。
use rusqlite::{Connection, OptionalExtension, params};
use serde::Deserialize;
use crate::{db::entries, db::map_error, error::StashError};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewNode {
/// 业务键,只允许小写字母、数字与连字符
pub key: String,
pub title: String,
/// 为空表示新建一个根分组
#[serde(default)]
pub parent_key: Option<String>,
/// 图标:可以是 iconfont 名或字符;空字符串表示不显示
#[serde(default)]
pub icon: Option<String>,
/// 排序权重;不传则排到同级末尾
#[serde(default)]
pub sort: Option<i64>,
/// 是否启用,默认 true
#[serde(default)]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateNode {
pub title: Option<String>,
/// 传入即移动到新的父节点;不支持移回根(根分组请直接新建)
pub parent_key: Option<String>,
pub sort: Option<i64>,
/// 图标:空字符串表示清除图标
pub icon: Option<String>,
pub enabled: Option<bool>,
}
fn valid_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 64
&& key
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
}
pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
let key = new.key.trim();
let title = new.title.trim();
if !valid_key(key) {
return Err(StashError::InvalidDirectory(
"分组标识只能用小写字母、数字与连字符(≤64)".into(),
));
}
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("分组名称需为 1–40 个字".into()));
}
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [key], |row| {
row.get(0)
})
.map_err(map_error)?;
if exists > 0 {
return Err(StashError::Conflict(format!("分组已存在: {key}")));
}
let kind = match new.parent_key.as_deref() {
Some(parent) => {
let parent_exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [parent], |row| {
row.get(0)
})
.map_err(map_error)?;
if parent_exists == 0 {
return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
}
"group"
}
None => "section",
};
let default_sort: i64 = match new.parent_key.as_deref() {
Some(parent) => conn
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1",
[parent],
|row| row.get(0),
)
.map_err(map_error)?,
None => conn
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key IS NULL",
[],
|row| row.get(0),
)
.map_err(map_error)?,
};
let sort = new.sort.unwrap_or(default_sort);
let icon = new
.icon
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let enabled = new.enabled.unwrap_or(true);
let tx = conn.transaction().map_err(map_error)?;
tx.execute(
"INSERT INTO node (key, parent_key, title, kind, icon, enabled, sort)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
key,
new.parent_key,
title,
kind,
icon,
enabled,
sort
],
)
.map_err(map_error)?;
entries::audit(&tx, "node.create", key, title)?;
tx.commit().map_err(map_error)?;
Ok(())
}
pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> {
let current: Option<(String, Option<String>, i64, Option<String>, bool)> = conn
.query_row(
"SELECT title, parent_key, sort, icon, enabled FROM node WHERE key = ?1",
[key],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
},
)
.optional()
.map_err(map_error)?;
let Some((current_title, current_parent, current_sort, current_icon, current_enabled)) = current
else {
return Err(StashError::NotFound(format!("分组不存在: {key}")));
};
let title = match patch.title {
Some(title) => {
let title = title.trim().to_string();
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("分组名称需为 1–40 个字".into()));
}
title
}
None => current_title,
};
let parent_key = match patch.parent_key.clone() {
Some(parent) => {
if parent == key {
return Err(StashError::InvalidDirectory("不能把分组移到自己下面".into()));
}
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [&parent], |row| {
row.get(0)
})
.map_err(map_error)?;
if exists == 0 {
return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
}
// 防止把节点移进自己的子孙
let mut cursor = Some(parent.clone());
while let Some(candidate) = cursor {
if candidate == key {
return Err(StashError::InvalidDirectory(
"不能把分组移到自己的子分组下面".into(),
));
}
cursor = conn
.query_row(
"SELECT parent_key FROM node WHERE key = ?1",
[candidate],
|row| row.get::<_, Option<String>>(0),
)
.optional()
.map_err(map_error)?
.flatten();
}
Some(parent)
}
// 不传表示保持原父级(不支持移回根)
None => current_parent.clone(),
};
let icon = match patch.icon {
Some(icon) => {
let icon = icon.trim().to_string();
(!icon.is_empty()).then_some(icon)
}
None => current_icon,
};
let enabled = patch.enabled.unwrap_or(current_enabled);
let sort = patch.sort.unwrap_or(current_sort);
let tx = conn.transaction().map_err(map_error)?;
// 只有真正换了父级才排到末尾;同一父级下编辑排序权重时按传入值保存
let parent_changed = patch
.parent_key
.as_deref()
.is_some_and(|parent| Some(parent) != current_parent.as_deref());
let final_sort = if parent_changed {
let parent = patch.parent_key.as_deref().unwrap_or_default();
tx.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1 AND key <> ?2",
params![parent, key],
|row| row.get::<_, i64>(0),
)
.map_err(map_error)?
} else {
sort
};
tx.execute(
"UPDATE node SET title = ?1, parent_key = ?2, sort = ?3,
icon = ?4, enabled = ?5
WHERE key = ?6",
params![title, parent_key, final_sort, icon, enabled, key],
)
.map_err(map_error)?;
entries::audit(&tx, "node.update", key, &title)?;
tx.commit().map_err(map_error)?;
Ok(())
}
pub fn remove(conn: &Connection, key: &str) -> Result<(), StashError> {
let removed = conn
.execute("DELETE FROM node WHERE key = ?1", [key])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("分组不存在: {key}")));
}
entries::audit(conn, "node.delete", key, "")?;
Ok(())
}
+699
View File
@@ -0,0 +1,699 @@
//! 内容库(SQLite)。
//!
//! 三条设计约束:
//!
//! 1. **单文件**:库固定是 `data/stash.db`,用 `journal_mode=DELETE`,
//! 不会像 WAL 那样在磁盘上多出 `-wal` / `-shm` 两个旁文件。
//! 2. **可编辑**:用户可以用任何 SQLite 工具直接改库;服务只读,不独占文件。
//! 3. **开发期不做迁移**:表结构还在变,改结构就删掉 `data/stash.db` 重启,
//! 启动时会重新建表并灌入内嵌种子。
pub mod auth;
pub mod cards;
mod catalog;
pub mod entries;
pub mod groups;
mod schema;
pub mod settings;
use std::sync::Mutex;
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};
/// 内容库句柄。
///
/// 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),
})
}
/// 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 load_tree(&self) -> Result<Value, StashError> {
let conn = self.lock()?;
catalog::load_tree(&conn)
}
pub fn load_cards(&self) -> Result<Vec<Value>, StashError> {
let conn = self.lock()?;
cards::load(&conn)
}
pub fn create_node(&self, new: groups::NewNode) -> Result<(), StashError> {
let mut conn = self.lock()?;
groups::create(&mut conn, new)
}
pub fn update_node(&self, key: &str, patch: groups::UpdateNode) -> Result<(), StashError> {
let mut conn = self.lock()?;
groups::update(&mut conn, key, patch)
}
pub fn remove_node(&self, key: &str) -> Result<(), StashError> {
let conn = self.lock()?;
groups::remove(&conn, key)
}
pub fn create_card(&self, new: cards::NewCard) -> Result<i64, StashError> {
let mut conn = self.lock()?;
cards::create(&mut conn, new)
}
pub fn update_card(&self, id: i64, patch: cards::UpdateCard) -> Result<(), StashError> {
let mut conn = self.lock()?;
cards::update(&mut conn, id, patch)
}
pub fn remove_card(&self, id: i64) -> Result<(), StashError> {
let conn = self.lock()?;
cards::remove(&conn, id)
}
pub fn set_card_groups(
&self,
id: i64,
groups: Vec<cards::CardGroupInput>,
) -> Result<(), StashError> {
let mut conn = self.lock()?;
cards::set_groups(&mut conn, id, groups)
}
}
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
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 card", [], |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, "每个分区一张首页卡片");
}
/// 分区与分组是同一张表:分组必须挂在分区下。
#[test]
fn groups_are_nodes_under_their_section() {
let conn = seeded();
let parent_key: String = conn
.query_row(
"SELECT p.key
FROM node g
JOIN node p ON p.key = g.parent_key
WHERE g.kind = 'group' AND g.key = 'ui'",
[],
|row| row.get(0),
)
.expect("应能查到分组及其父节点");
assert_eq!(parent_key, "recommend");
// 分区一定是顶层节点
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 tree = catalog::load_tree(&conn).expect("应能组装分组树");
let roots = tree.as_array().expect("根节点应为数组");
assert_eq!(roots.len(), 3);
let apps = roots
.iter()
.find(|node| node["key"] == "recommend")
.expect("应有常用推荐");
let groups = apps["children"].as_array().expect("应有子分组");
assert_eq!(groups.len(), 5);
let common = groups
.iter()
.find(|node| node["key"] == "common")
.expect("应有常用站点");
let first_app = &common["entries"][0];
assert_eq!(first_app["name"], "GitHub");
assert_eq!(first_app["format"], "apps");
assert_eq!(first_app["hot"], true);
assert_eq!(first_app["views"], 429);
let posters = roots
.iter()
.find(|node| node["key"] == "movie")
.expect("应有推荐电影");
let first_poster = &posters["entries"][0];
assert_eq!(first_poster["format"], "posters");
assert!(
first_poster.get("name").is_none(),
"海报不应同时下发 name 字段"
);
assert!(first_poster["title"].is_string());
assert_eq!(
first_poster["tags"]
.as_array()
.expect("海报应有标签")
.len(),
2
);
let articles = roots
.iter()
.find(|node| node["key"] == "ces")
.expect("应有 CES 现场报道");
let first_article = &articles["entries"][0];
assert_eq!(first_article["format"], "articles");
let body = first_article["body"].as_str().expect("图文应有正文");
assert!(body.contains("## 设计细节"), "正文应含小标题: {body}");
assert!(body.contains("!["), "正文应含配图语法: {body}");
assert!(body.contains("\n\n"), "段落之间用空行分隔");
assert!(
first_article.get("blocks").is_none(),
"不应再下发 blocks 数组"
);
assert_eq!(
first_article["meta"]
.as_array()
.expect("图文应有元信息")
.len(),
2
);
// 卡片只记录勾选的分组;常用推荐卡片展示「常用站点」,最多 4 条
let (group_key, preview_limit): (String, Option<i64>) = conn
.query_row(
"SELECT cg.group_key, cg.preview_limit
FROM card c JOIN card_group cg ON cg.card_id = c.id
WHERE c.key = 'card-recommend'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("应能查到常用推荐卡片");
assert_eq!(group_key, "common");
assert_eq!(preview_limit, Some(4));
}
/// 删除分组时,卡片里勾选它的记录一起消失(外键级联)。
#[test]
fn card_groups_cascade_with_their_group() {
let conn = seeded();
conn.execute(
"DELETE FROM node WHERE kind = 'group' AND key = 'common' AND parent_key = 'recommend'",
[],
)
.expect("应能删除分组");
let remaining: i64 = conn
.query_row(
"SELECT COUNT(*) FROM card_group WHERE group_key = 'common'",
[],
|row| row.get(0),
)
.expect("应能统计卡片分组");
assert_eq!(remaining, 0, "卡片里对 common 的勾选应被级联删除");
}
/// 删除分组节点时,组内条目也要一起删掉。
#[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", "card"] {
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 tree = catalog::load_tree(&conn).expect("应能组装分组树");
let roots = tree.as_array().expect("根节点应为数组");
let apps = roots
.iter()
.find(|node| node["key"] == "recommend-v2")
.expect("应能找到改名后的分区");
fn count(node: &serde_json::Value) -> usize {
let own = node["entries"].as_array().map_or(0, |items| items.len());
let children = node["children"]
.as_array()
.map_or(0, |items| items.iter().map(count).sum());
own + children
}
assert_eq!(count(apps), 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,
bar_color TEXT,
icon TEXT,
preview_limit INTEGER,
empty_text TEXT,
show_in_sidebar INTEGER NOT NULL DEFAULT 1,
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL);
CREATE TABLE section_block (id INTEGER PRIMARY KEY, node_key TEXT NOT NULL, title TEXT, preview_limit INTEGER, sort INTEGER NOT NULL DEFAULT 0);
PRAGMA user_version = 15;",
)
.expect("应能造出 v15 老库");
assert_eq!(
schema::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 必须变化");
}
}
+656
View File
@@ -0,0 +1,656 @@
//! 内容库的表结构与种子导入。
//!
//! 两条设计原则:
//!
//! 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_key 必须为 NULL
-- kind = 'group' → 分区下的分组,parent_key 指向分区或上层分组
CREATE TABLE IF NOT EXISTS node (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT)
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui)
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')),
-- 容器层级
bar_color TEXT, -- 标题左侧色条颜色
icon TEXT, -- 字符图标
preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
-- 是否启用(禁用后前台侧栏/卡片不展示)
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
CHECK (
(kind = 'section' AND parent_key IS NULL)
OR (kind = 'group' AND parent_key IS NOT NULL)
)
);
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)
);
-- ========== v16:登录与设置 ==========
-- 管理员:单用户场景只允许一行
CREATE TABLE IF NOT EXISTS admin (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL,
password_hash TEXT NOT NULL, -- argon2id PHC 字符串
updated_at INTEGER NOT NULL -- unix 秒
);
-- 会话:只存 token 的 sha256,原始 token 只在 Cookie 里
CREATE TABLE IF NOT EXISTS session (
token_hash TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS session_expiry_idx ON session(expires_at);
-- 全局设置:一个 key 一段 JSON,version 用于乐观锁
CREATE TABLE IF NOT EXISTS setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);
-- ========== v17:审计 ==========
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at INTEGER NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT
);
-- ========== v19:首页卡片(展示层组合) ==========
-- 一张卡片 = 标题 + 顺序;内容由 card_group 勾选分组组成,卡片不拥有数据
CREATE TABLE IF NOT EXISTS card (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0
);
-- 卡片包含哪些分组(可混合格式),每个分组可单独设预览条数
CREATE TABLE IF NOT EXISTS card_group (
card_id INTEGER NOT NULL REFERENCES card(id) ON DELETE CASCADE,
group_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
sort INTEGER NOT NULL DEFAULT 0,
preview_limit INTEGER,
PRIMARY KEY (card_id, group_key)
);
"#;
/// 把老的「分区 → 一个展示块」迁移成「卡片 → 多个分组」。
const BACKFILL_CARDS_SQL: &str = r#"
INSERT OR IGNORE INTO card (key, title, sort)
SELECT 'card-' || COALESCE(p.key, n.key),
COALESCE(b.title, COALESCE(p.title, n.title)),
MIN(b.sort)
FROM section_block b
JOIN node n ON n.key = b.node_key
LEFT JOIN node p ON p.key = n.parent_key
GROUP BY COALESCE(p.key, n.key);
INSERT OR IGNORE INTO card_group (card_id, group_key, sort, preview_limit)
SELECT c.id, b.node_key, b.sort, b.preview_limit
FROM section_block b
JOIN node n ON n.key = b.node_key
LEFT JOIN node p ON p.key = n.parent_key
JOIN card c ON c.key = 'card-' || COALESCE(p.key, n.key);
-- 本地服务是运行时卡片,但用一行 card 记录它的位置,参与首页排序
INSERT OR IGNORE INTO card (key, title, sort) VALUES ('local-service', '本地服务', -1);
"#;
/// v16 → v17:条目加 `updated_at`(乐观锁),新增审计表。
const V17_SQL: &str = r#"
ALTER TABLE entry ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at INTEGER NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT
);
"#;
/// v17 → v18:分区增加「允许子分组」开关(与类型无关)。
///
/// 老库默认保持原行为:应用类开启,其余类型关闭;已经有分组的非应用分区也开启。
const V18_SQL: &str = r#"
ALTER TABLE node ADD COLUMN allow_groups INTEGER NOT NULL DEFAULT 1;
UPDATE node SET allow_groups = 0
WHERE kind = 'section' AND view_mode <> 'apps';
UPDATE node SET allow_groups = 1
WHERE kind = 'section' AND key IN (
SELECT DISTINCT parent_key FROM node
WHERE kind = 'group' AND parent_key IS NOT NULL
);
"#;
/// v19 → v20:分组增加启停状态。
const V20_SQL: &str = r#"
ALTER TABLE node ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
"#;
/// v21 → v22:本地服务卡片入库,用 card.sort 参与首页排序(内容仍来自运行时项目)。
const V22_SQL: &str = r#"
INSERT OR IGNORE INTO card (key, title, sort) VALUES ('local-service', '本地服务', -1);
"#;
/// v22 → v23:删除旧分区展示块与 node 上的兼容字段,node 只保留当前模型需要的列。
const V23_SQL: &str = r#"
PRAGMA foreign_keys = OFF;
DROP TABLE IF EXISTS section_block;
CREATE TABLE node_new (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
title TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
bar_color TEXT,
icon TEXT,
preview_limit INTEGER,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
sort INTEGER NOT NULL DEFAULT 0,
CHECK (
(kind = 'section' AND parent_key IS NULL)
OR (kind = 'group' AND parent_key IS NOT NULL)
)
);
INSERT INTO node_new (key, parent_key, title, kind, bar_color, icon, preview_limit, enabled, sort)
SELECT key, parent_key, title, kind, bar_color, icon, preview_limit, enabled, sort
FROM node;
DROP TABLE node;
ALTER TABLE node_new RENAME TO node;
CREATE INDEX IF NOT EXISTS node_parent_idx ON node(parent_key, sort);
PRAGMA foreign_keys = ON;
"#;
/// v20 → v21:格式从分组下沉到条目;老数据按所属根分组的 view_mode 回填。
const V21_SQL: &str = r#"
ALTER TABLE entry ADD COLUMN format TEXT NOT NULL DEFAULT 'apps';
WITH RECURSIVE roots(key, root_key, view_mode) AS (
SELECT key, key, view_mode FROM node WHERE parent_key IS NULL
UNION ALL
SELECT n.key, r.root_key, r.view_mode
FROM node n JOIN roots r ON n.parent_key = r.key
)
UPDATE entry
SET format = COALESCE(
(SELECT view_mode FROM roots WHERE roots.key = entry.node_key),
'apps'
);
"#;
/// v18 → v19:首页卡片改为「卡片 + 勾选分组」。
const V19_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS card (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS card_group (
card_id INTEGER NOT NULL REFERENCES card(id) ON DELETE CASCADE,
group_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
sort INTEGER NOT NULL DEFAULT 0,
preview_limit INTEGER,
PRIMARY KEY (card_id, group_key)
);
"#;
/// v15 → v16 的增量(老库升级用;新库直接走 SCHEMA_SQL)。
const V16_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS admin (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS session (
token_hash TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS session_expiry_idx ON session(expires_at);
CREATE TABLE IF NOT EXISTS setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);
"#;
/// 种子导入结果,仅用于启动日志。
#[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 = 23;
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;
}
if version == 22 {
conn.execute_batch(V23_SQL).map_err(map_error)?;
version = 23;
}
conn.pragma_update(None, "user_version", version)
.map_err(map_error)?;
Ok(version)
}
/// 建表(幂等):只在测试里直接用;启动流程走 `migrate`。
#[cfg(test)]
pub fn apply(conn: &Connection) -> Result<(), StashError> {
conn.execute_batch(SCHEMA_SQL).map_err(map_error)
}
/// 把内嵌种子写进空库。
///
/// 只用业务编码,不需要先插入再回查自增 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, bar_color, icon, preview_limit, sort)
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6)",
params![
section.key,
section.title,
section.bar_color,
section.icon,
section.preview_limit,
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, sort)
VALUES (?1, ?2, ?3, 'group', ?4)",
params![group.key, section.key, group.title, group.sort],
)
.map_err(map_error)?;
report.groups += 1;
for (index, entry) in group.entries.iter().enumerate() {
insert_entry(&tx, &group.key, &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 card (key, title, sort) VALUES (?1, ?2, ?3)",
params![
format!("card-{}", section.key),
section
.block
.title
.clone()
.unwrap_or_else(|| section.title.clone()),
section.sort,
],
)
.map_err(map_error)?;
let card_id = tx.last_insert_rowid();
tx.execute(
"INSERT INTO card_group (card_id, group_key, sort, preview_limit)
VALUES (?1, ?2, 0, ?3)",
params![card_id, section.block.node_key, section.block.preview_limit],
)
.map_err(|error| {
StashError::Database(format!(
"分区 {} 的展示节点 {} 无效: {error}",
section.key, section.block.node_key
))
})?;
report.blocks += 1;
}
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>,
/// 仅 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,
entries: Vec<SeedEntry>,
}
/// 一条内容:字段与 `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文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
}
]
}
+512
View File
@@ -0,0 +1,512 @@
//! 站点级设置(页头滚动信息、侧栏顺序/显隐)。
//!
//! 存 `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,
/// 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽
pub gap: u32,
pub items: Vec<HeaderItem>,
}
impl Default for HeaderSettings {
fn default() -> Self {
Self {
enabled: true,
speed: 24,
gap: 26,
items: vec![
HeaderItem {
text: "共 {count} 个本地应用".into(),
tag: None,
tone: Tone::Plain,
},
HeaderItem {
text: "{project}".into(),
tag: Some("本地".into()),
tone: Tone::Primary,
},
HeaderItem {
text: "{status}".into(),
tag: Some("状态".into()),
tone: Tone::Success,
},
],
}
}
}
fn default_true() -> bool {
true
}
impl Default for SidebarSettings {
fn default() -> Self {
Self {
show_profile: true,
show_service: true,
show_nav: true,
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,
/// 是否显示「分组导航」卡片(首页 / 分组树)
#[serde(default = "default_true")]
pub show_nav: bool,
/// 隐藏的分类 id
pub hidden: Vec<String>,
/// 分类显示顺序(id);未列出的排在后面,保持原有顺序
pub order: Vec<String>,
/// 隐藏的分组,键为 `分区id/分组id`
pub hidden_groups: Vec<String>,
/// 强制显示的分组(覆盖数据库里的 show_in_sidebar=0)
pub shown_groups: Vec<String>,
/// 每个分区下分组的顺序:分区 id → 分组 id 列表
pub group_order: std::collections::HashMap<String, Vec<String>>,
/// 新模型:侧栏隐藏的分组 key(任意层级)
pub tree_hidden: Vec<String>,
/// 新模型:每级分组的顺序,键是父分组 key(根级用空串)
pub tree_order: std::collections::HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct SectionsSettings {
/// 隐藏的首页卡片(分类 id)
pub hidden: Vec<String>,
/// 卡片顺序(分类 id)
pub order: Vec<String>,
/// 标题覆盖:id → 标题
pub titles: std::collections::HashMap<String, String>,
/// 预览条数覆盖:id → 条数
pub limits: std::collections::HashMap<String, u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ProfileSettings {
pub name: String,
pub role: String,
pub description: String,
pub location: String,
/// 头像地址;空字符串表示用首字占位
pub avatar: String,
}
impl Default for ProfileSettings {
fn default() -> Self {
Self {
name: "本地管理员".into(),
role: "管理员".into(),
description: "本地静态站点托管与预览,所有项目都运行在这台机器上。".into(),
location: "IP属地:局域网".into(),
avatar: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct BrandSettings {
/// 品牌文字(页头左侧,可留空只显示 logo)
pub name: String,
/// 没有 logo 图片时,圆形标记里显示的首字
pub mark: String,
/// logo 图片地址;空字符串表示用首字
pub logo: String,
}
impl Default for BrandSettings {
fn default() -> Self {
Self {
name: "STASH".into(),
mark: "S".into(),
logo: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ActionSettings {
/// 明暗切换图标
#[serde(default = "default_true")]
pub theme: bool,
/// 刷新图标(重扫本地项目并重拉内容库)
pub refresh: bool,
/// 站点设置图标(仅登录后出现)
pub settings: bool,
/// 登录 / 退出登录
pub session: bool,
}
impl Default for ActionSettings {
fn default() -> Self {
Self {
theme: true,
refresh: true,
settings: true,
session: true,
}
}
}
/// 配色:浅色 / 深色 / 跟随系统。
/// 落到 `<html data-scheme>` 上,深色由组件库的令牌块接管,页面不用改组件。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThemeSetting {
Light,
Dark,
#[default]
Auto,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SiteMetaSettings {
/// 浏览器标签标题
pub title: String,
/// favicon 地址;空字符串表示用内置图标
pub favicon: String,
/// 配色
pub theme: ThemeSetting,
pub brand: BrandSettings,
pub actions: ActionSettings,
}
impl Default for SiteMetaSettings {
fn default() -> Self {
Self {
title: "Stash · 本地应用工作区".into(),
favicon: String::new(),
theme: ThemeSetting::Auto,
brand: BrandSettings::default(),
actions: ActionSettings::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct Settings {
pub header: HeaderSettings,
pub sidebar: SidebarSettings,
pub sections: SectionsSettings,
pub profile: ProfileSettings,
pub site: SiteMetaSettings,
}
/// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。
#[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} 秒之间"
)));
}
if settings.header.gap > 120 {
return Err(StashError::InvalidDirectory("信息间距最多 120".into()));
}
for item in &settings.header.items {
let text = item.text.trim();
if text.is_empty() {
return Err(StashError::InvalidDirectory("信息文本不能为空".into()));
}
if text.chars().count() > 60 {
return Err(StashError::InvalidDirectory("单条信息最多 60 个字".into()));
}
if let Some(tag) = &item.tag {
if tag.chars().count() > 8 {
return Err(StashError::InvalidDirectory("标签最多 8 个字".into()));
}
}
}
if settings.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()));
}
if settings.site.title.chars().count() > 120 {
return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into()));
}
if settings.site.favicon.chars().count() > 500 {
return Err(StashError::InvalidDirectory("favicon 地址最多 500 个字符".into()));
}
if settings.site.brand.name.chars().count() > 20 {
return Err(StashError::InvalidDirectory("品牌文字最多 20 个字".into()));
}
if settings.site.brand.mark.chars().count() > 4 {
return Err(StashError::InvalidDirectory("logo 标记最多 4 个字".into()));
}
if settings.site.brand.logo.chars().count() > 500 {
return Err(StashError::InvalidDirectory("logo 地址最多 500 个字符".into()));
}
Ok(())
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0)
}
/// 读设置;没有记录时返回默认值与 version = 0。
pub fn load(conn: &Connection) -> Result<SettingsDocument, StashError> {
let row: Option<(String, i64)> = conn
.query_row(
"SELECT value, version FROM setting WHERE key = ?1",
[SETTINGS_KEY],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(map_error)?;
let Some((raw, version)) = row else {
return Ok(SettingsDocument {
version: 0,
settings: Settings::default(),
});
};
let settings = serde_json::from_str::<Settings>(&raw).unwrap_or_else(|error| {
tracing::warn!("设置 JSON 解析失败,使用默认值: {error}");
Settings::default()
});
Ok(SettingsDocument { version, settings })
}
/// 保存设置;`expected_version` 与库里不一致时返回冲突。
pub fn save(
conn: &mut Connection,
settings: Settings,
expected_version: i64,
) -> Result<SettingsDocument, StashError> {
validate(&settings)?;
let value = serde_json::to_string(&settings)
.map_err(|error| StashError::Database(format!("设置序列化失败: {error}")))?;
let tx = conn.transaction().map_err(map_error)?;
let updated = if expected_version == 0 {
tx.execute(
"INSERT INTO setting (key, value, version, updated_at) VALUES (?1, ?2, 1, ?3)
ON CONFLICT(key) DO NOTHING",
params![SETTINGS_KEY, value, now()],
)
.map_err(map_error)?
} else {
tx.execute(
"UPDATE setting SET value = ?1, version = version + 1, updated_at = ?2
WHERE key = ?3 AND version = ?4",
params![value, now(), SETTINGS_KEY, expected_version],
)
.map_err(map_error)?
};
if updated == 0 {
return Err(StashError::Conflict(
"设置已被其它页面修改,请刷新后重试".into(),
));
}
let (version,): (i64,) = tx
.query_row(
"SELECT version FROM setting WHERE key = ?1",
[SETTINGS_KEY],
|row| Ok((row.get(0)?,)),
)
.map_err(map_error)?;
tx.commit().map_err(map_error)?;
Ok(SettingsDocument { version, settings })
}
#[cfg(test)]
mod tests {
use super::*;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.execute_batch(
"CREATE TABLE setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);",
)
.expect("应能建表");
conn
}
#[test]
fn defaults_are_stable_and_valid() {
let settings = Settings::default();
assert!(settings.header.enabled);
assert_eq!(settings.header.items.len(), 3);
assert!(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());
+977 -32
View File
File diff suppressed because it is too large Load Diff
+25 -28
View File
@@ -1,32 +1,29 @@
# React + TypeScript + Vite
# stash-web
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Stash 的前端控制台:React 19 + Vite + TypeScript。组件与样式全部来自
[`@stash/ui`](../stash-ui)(`file:` 依赖,需要先在同级目录构建出它的 `dist`)。
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```bash
npm install
npm run dev # http://127.0.0.1:5173,/api 代理到后端 8080
npm run build # 产物到 dist,随后会被 cargo build 内嵌进二进制
npm run lint # oxlint
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
## 目录
| 路径 | 内容 |
| --- | --- |
| `src/AppNext.tsx` | 前台外壳:路由、卡片、侧栏、搜索、主题应用 |
| `src/components/admin/` | 后台(`AdminNext.tsx`:左侧导航 + 分组 / 数据 / 首页 / 资料 / 系统面板) |
| `src/components/api/` | `/interface` 接口一览页 |
| `src/components/header/`、`sidebar/` | 页头(品牌、滚动信息、操作图标)与侧栏卡片 |
| `src/components/content/` | 卡片、网格、详情视图 |
| `src/data/view.ts`、`src/useViewRoute.ts` | 地址 ↔ 视图的路由规则(与服务端 `SPA_PREFIXES` / `SPA_PAGES` 对应) |
| `src/theme.ts` | 明暗主题:本机偏好(localStorage)优先于站点默认 |
## 约定
- 页面里不写死颜色 / 字号 / 间距,一律用 `@stash/ui` 的 `--su-*` 令牌;
- 站内跳转走 `navigate()`,不要用 `<a href>` 触发整页刷新;需要「新标签打开」时保留 `href` 并放行修饰键点击;
- 组件库里没有的东西优先补进 `stash-ui`,而不是在这里再手写一遍(后台已经是 100% 由库组件组装)。
+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.3.1",
"devDependencies": {
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"oxlint": "^1.88.0",
"playwright-core": "^1.64.1",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"typescript": "~6.0.3",
"vite": "^8.3.4"
},
"peerDependencies": {
"react": ">=19",
"react-dom": ">=19"
}
},
"node_modules/@oxc-project/types": {
"version": "0.154.0",
"resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.154.0.tgz",
@@ -616,6 +636,10 @@
"dev": true,
"license": "MIT"
},
"node_modules/@stash/ui": {
"resolved": "../../stash-ui",
"link": true
},
"node_modules/@types/node": {
"version": "24.19.2",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.19.2.tgz",
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@stash/ui": "file:../../stash-ui",
"react": "^19.3.1",
"react-dom": "^19.3.1"
},
+1337 -787
View File
File diff suppressed because it is too large Load Diff
-152
View File
@@ -1,152 +0,0 @@
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 { useProjects } from './useProjects'
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()
const [keyword, setKeyword] = useState('')
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,
}))
return [{ id: 'local', title: '本地服务', cards: localCards }, ...MOCK_SECTIONS]
}, [projects])
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),
),
}))
.filter((section) => section.cards.length > 0)
}, [sections, searching, query])
const noMatch = searching && sectionsToRender.length === 0
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>
)
}
if (status === 'loading') {
return (
<div className="notice">
<strong>正在读取项目列表…</strong>
<p>首次加载会扫描一次托管目录。</p>
</div>
)
}
return (
<div className="notice">
<strong>这里还没有项目</strong>
<p>
在托管根目录下创建子目录并放入 <code>index.html</code>,刷新后即可在这里看到它。
</p>
</div>
)
}
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>
)
}
return (
<div className="app">
<TopBar
projects={projects}
live={live}
onFocusSearch={() => searchRef.current?.focus()}
/>
<div className="search-row">
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
</div>
<div className="layout">
<Sidebar
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={refresh}
/>
<main className="content">
<div className="content-scroll" id="content-scroll">
<h1 className="sr-only">Stash 本地应用工作区</h1>
{sectionsToRender.map(renderSection)}
{noMatch && (
<div className="notice">
<strong>没有匹配「{keyword.trim()}」的内容</strong>
<p>换个关键字试试,或清空搜索查看全部。</p>
</div>
)}
</div>
</main>
</div>
<BackToTop />
</div>
)
}
+756
View File
@@ -0,0 +1,756 @@
import {
Empty,
Layout,
Menu,
Notice,
SearchField,
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 { ApiDocs } from './components/api/ApiDocs'
import { GroupMark } from './components/common/GroupMark'
import { LoginDialog } from './components/auth/LoginDialog'
import { ProfileCard } from './components/sidebar/ProfileCard'
import { ServicePanel } from './components/sidebar/ServicePanel'
import { useProjects } from './useProjects'
import { fetchSession, logout, type SessionState } from './auth'
import {
fetchSettings,
type SettingsDocument,
type SiteSettings,
} from './settings'
import {
isDarkScheme,
readThemeOverride,
writeThemeOverride,
type ThemeChoice,
} from './theme'
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 }
| { kind: 'api' }
function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean)
if (segments[0] === 'interface') return { kind: 'api' }
if (
(segments[0] === 'g' || segments[0] === 'c') &&
segments[1] !== undefined
) {
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 {
const children = group.children.filter((child) => child.enabled !== false)
return group.entries.length + children.reduce((total, child) => total + groupEntryCount(child), 0)
}
/** 分组自身与所有祖先都启用时才可见:停用父级会连带停用整棵子树 */
function isGroupVisible(groups: GroupNode[], key: string): boolean {
const path = findPath(groups, key)
if (path === undefined) return false
return path.every((node) => node.enabled !== false)
}
/** 分组子树里的全部条目(格式沿根分组继承,所以可以放在同一个网格里) */
function collectEntries(group: GroupNode): Record<string, unknown>[] {
return [
...(group.entries as unknown as Record<string, unknown>[]),
...group.children
.filter((child) => child.enabled !== false)
.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')
// 服务端推的是具名事件 `catalog`(不是默认的 message),两种都接;
// 收到后连站点设置一起重拉,改完设置不用手动刷新页面。
const onCatalogChange = () => {
load()
void fetchSettings().then(setSettings)
}
source.onmessage = onCatalogChange
source.addEventListener('catalog', onCatalogChange)
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
}
// 左右两栏放进同一个滚动容器:滚动区域从搜索条下方开始,两栏位移完全一致。
// 左栏的吸顶交给 CSS position: sticky(App.css 里 #page-scroll > aside):
// 之前用 scroll 事件改 transform,主线程永远落后平滑滚动一帧,看起来就在抖。
// 注意别再给 aside 设内联 overflow,否则会盖掉 CSS 里的 overflow-y: auto,
// 侧栏比视口高时就没法在栏内滚动了。
body.id = 'page-scroll'
body.style.overflowY = 'auto'
body.style.overflowX = 'hidden'
content.style.overflow = 'visible'
content.style.marginTop = '0'
main.style.height = 'auto'
const onScroll = () => setScrolled(body.scrollTop > 4)
// 滚动容器只覆盖 1380 的两栏,窗口两侧的空白、页头与搜索条都在它外面,
// 滚轮落在这些地方时浏览器找不到可滚动元素。统一转发给内容区。
const forwardWheel = (event: WheelEvent) => {
if (event.target instanceof Node && body.contains(event.target)) return
// deltaMode 1 = 按行(Firefox 鼠标滚轮),换算成像素
body.scrollTop += event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY
event.preventDefault()
}
window.addEventListener('wheel', forwardWheel, { passive: false })
body.addEventListener('scroll', onScroll, { passive: true })
onScroll()
return () => {
body.removeEventListener('scroll', onScroll)
window.removeEventListener('wheel', forwardWheel)
body.id = ''
body.style.overflowY = ''
body.style.overflowX = ''
content.style.overflow = ''
content.style.marginTop = ''
main.style.height = ''
}
}, [])
const site: SiteSettings = settings?.settings ?? {
header: { enabled: true, speed: 24, gap: 26, items: [] },
sidebar: {
show_profile: true,
show_service: true,
show_nav: true,
hidden: [],
order: [],
hidden_groups: [],
shown_groups: [],
group_order: {},
tree_hidden: [],
tree_order: {},
},
sections: { hidden: [], order: [], titles: {}, limits: {} },
profile: {
name: '本地管理员',
role: '管理员',
description: '',
location: '',
avatar: '',
},
site: {
title: 'Stash · 本地应用工作区',
favicon: '',
theme: 'auto',
brand: { name: 'STASH', mark: 'S', logo: '' },
actions: { theme: true, refresh: true, settings: true, session: true },
},
}
const keywordLower = keyword.trim().toLowerCase()
// 配色:本机偏好优先于站点默认,写在 <html data-scheme> 上,
// 深色由组件库的令牌块接管,页面与组件都不用改
const [themeOverride, setThemeOverride] = useState<ThemeChoice | null>(() =>
readThemeOverride(),
)
const effectiveTheme = themeOverride ?? site.site.theme
useEffect(() => {
document.documentElement.dataset.scheme = effectiveTheme
}, [effectiveTheme])
/** 当前实际是深色吗(auto 时按系统偏好算) */
const isDark = isDarkScheme(effectiveTheme)
/** 页头的明暗切换:只写本机偏好,不需要登录;站点默认值在后台改 */
const toggleTheme = () => {
const next: ThemeChoice = isDark ? 'light' : 'dark'
setThemeOverride(next)
writeThemeOverride(next)
}
// 站点级设置:浏览器标签标题与 favicon(空值回落到内置)
useEffect(() => {
const title = site.site.title.trim()
if (title !== '') document.title = title
const href = site.site.favicon.trim() === '' ? '/favicon.svg' : site.site.favicon.trim()
let link = document.head.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (link === null) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = href
link.type = href.endsWith('.svg') ? 'image/svg+xml' : ''
}, [site.site.title, site.site.favicon])
const sidebarItems: MenuItem[] = useMemo(() => {
// 侧边栏的显隐只认「分组管理 → 状态」:停用的分组整棵子树都不进导航,
// 顺序按分组自己的排序权重,不再有侧栏专属的隐藏/排序状态。
const build = (nodes: GroupNode[]): MenuItem[] =>
[...nodes]
.sort((a, b) => a.sort - b.sort)
.flatMap((node) => {
if (node.enabled === false) return []
const children = build(node.children)
return [
{
key: `g:${node.key}`,
label: node.title,
children: children.length > 0 ? children : undefined,
},
]
})
return [{ key: 'home', label: '首页' }, ...build(manage.groups)]
}, [manage.groups])
const activeKey =
route.kind === 'home' || route.kind === 'api'
? '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 rootGroup = group.parentKey === undefined || group.parentKey === null
const source = rootGroup
? (group.entries as unknown as Record<string, unknown>[])
: collectEntries(group)
const all = source.filter((entry) => matches(entry, keywordLower))
if (all.length === 0) return null
return (
<div key={group.key} className="card-group-block">
{showTitle && !rootGroup && <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 && isGroupVisible(manage.groups, item.ref.groupKey),
)
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 || !isGroupVisible(manage.groups, key)) {
return <Empty text={loading ? '正在读取内容库…' : '分组不存在'} />
}
const trail = findPath(manage.groups, key) ?? [group]
// 根分组页就是「这个分区的全部内容」:所有启用的子分组整段铺开,不再需要「查看全部」
const rootGroup = group.parentKey === undefined || group.parentKey === null
return (
<div className="detail-view">
<nav className="detail-breadcrumb group-crumbs">
<button type="button" className="link-button" onClick={() => navigate('/')}>
首页
</button>
{trail.map((node) => (
<span key={node.key}>
<span className="group-crumb-sep">/</span>
<button
type="button"
className="link-button"
onClick={() => navigate(`/g/${encodeURIComponent(node.key)}`)}
>
{node.title}
</button>
</span>
))}
</nav>
<div className="detail-hero">
<span className="detail-hero-icon" aria-hidden="true">
<GroupMark icon={group.icon} fallback="•" />
</span>
<h2 className="detail-hero-title">{group.title}</h2>
<span className="detail-hero-count">[{groupEntryCount(group)}]</span>
</div>
{group.entries.length > 0 ? (
renderEntries(group, group.entries)
) : group.children.some((child) => groupEntryCount(child) > 0) ? null : (
<Empty text="这个分组还没有内容" />
)}
<Space direction="column" gap="lg" align="stretch">
{group.children
.filter((child) => child.enabled !== false)
.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>
{!rootGroup && (
<button
type="button"
className="link-button"
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
>
查看全部
</button>
)}
</div>
{child.entries.length > 0 ? (
renderEntries(child, child.entries, rootGroup ? {} : { limit: 12 })
) : (
<Empty text="暂无内容" />
)}
</section>
))}
</Space>
</div>
)
}
function renderItemPage(key: string, itemId: string) {
const found = findEntry(manage.groups, key, itemId)
if (found === undefined || !isGroupVisible(manage.groups, found.group.key)) {
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"
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}
headerGap={site.header.gap}
site={site.site}
authenticated={session?.authenticated === true}
username={session?.username ?? undefined}
refreshing={refreshing}
dark={isDark}
onToggleTheme={() => void toggleTheme()}
onRefresh={() => {
// 刷新 = 整页重载:项目扫描、内容库、设置、主题都重新走一遍
window.location.reload()
}}
onLogin={() => setLoginOpen(true)}
onLogout={() => {
void logout().then(() => setSession({ authenticated: false, has_admin: true }))
}}
onSettings={() => window.location.assign('/admin')}
/>
}
toolbar={
<div className="search-toolbar">
<SearchField
value={keyword}
onChange={setKeyword}
inputRef={searchRef}
placeholder="搜索本地项目…"
label="搜索本地项目"
variant="card"
hotkey="k"
/>
</div>
}
sider={
<div className="sidebar-wrap">
<div className="sidebar sidebar-home">
{site.sidebar.show_profile !== false && <ProfileCard profile={site.profile} />}
{site.sidebar.show_service !== false && (
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={refresh}
onOpenApi={() => navigate('/interface')}
/>
)}
{site.sidebar.show_nav !== false && (
<div className="nav-card">
<Menu
items={sidebarItems}
activeKey={activeKey}
itemHeight={39}
onSelect={(key) => {
if (key === 'home') navigate('/')
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
}}
/>
</div>
)}
</div>
</div>
}
>
<div className="content">
<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 === 'api' && <ApiDocs />}
{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>
)
}
-58
View File
@@ -1,58 +0,0 @@
import { useEffect, type RefObject } from 'react'
type SearchBarProps = {
value: string
onChange: (value: string) => void
inputRef: RefObject<HTMLInputElement | null>
}
export function SearchBar({ value, onChange, inputRef }: SearchBarProps) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
inputRef.current?.focus()
inputRef.current?.select()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [inputRef])
return (
<div className="search-bar card-box">
<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"
value={value}
placeholder="搜索本地项目…"
aria-label="搜索本地项目"
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') onChange('')
}}
/>
{value !== '' && (
<button
type="button"
className="search-clear"
aria-label="清空搜索"
onClick={() => onChange('')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
)
}
-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
+131
View File
@@ -0,0 +1,131 @@
import { Card, Space, Table, Tag, Text, Title } from '@stash/ui'
/**
* 接口一览:把 `/api/*` 按用途分组列出来。
*
* 页面只做展示,不请求后端——清单跟 `src/server.rs` 的路由表人工对齐,
* 改接口时顺手改这里(文件顶部有分组与权限说明)。
*/
type Endpoint = {
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
path: string
summary: string
/** 需要登录会话 */
auth?: boolean
}
const GROUPS: { title: string; desc: string; items: Endpoint[] }[] = [
{
title: '健康与项目',
desc: '托管目录的扫描结果与实时推送。',
items: [
{ method: 'GET', path: '/api/health', summary: '存活检查,返回服务名与状态' },
{ method: 'GET', path: '/api/projects', summary: '扫描托管目录(每次请求都重新扫描)' },
{ method: 'GET', path: '/api/projects/events', summary: 'SSE:目录变化时推送最新项目列表' },
],
},
{
title: '内容库',
desc: '分区 / 分组 / 条目 / 卡片。写操作都需要登录会话与同源 Origin。',
items: [
{ method: 'GET', path: '/api/catalog', summary: '旧版完整目录(兼容保留)' },
{ method: 'GET', path: '/api/catalog2', summary: '分组树 + 卡片,后台配置用' },
{ method: 'GET', path: '/api/catalog/events', summary: 'SSE:内容库变更时推送「该重拉」' },
{ method: 'POST', path: '/api/nodes', summary: '新建分区 / 分组', auth: true },
{ method: 'PATCH', path: '/api/nodes/{key}', summary: '改标题、所属、图标、排序、启停', auth: true },
{ method: 'DELETE', path: '/api/nodes/{key}', summary: '删除分区 / 分组(级联子节点)', auth: true },
{ method: 'GET', path: '/api/items/{key}', summary: '条目详情' },
{ method: 'POST', path: '/api/items', summary: '新建条目', auth: true },
{ method: 'PATCH', path: '/api/items/{key}', summary: '就地编辑(带 updatedAt 乐观锁)', auth: true },
{ method: 'DELETE', path: '/api/items/{key}', summary: '删除条目', auth: true },
{ method: 'POST', path: '/api/items/{key}/view', summary: '浏览数 +1' },
{ method: 'POST', path: '/api/items/{key}/like', summary: '点赞 / 取消点赞' },
{ method: 'POST', path: '/api/cards', summary: '新建首页卡片', auth: true },
{ method: 'PATCH', path: '/api/cards/{id}', summary: '改卡片标题 / 顺序', auth: true },
{ method: 'DELETE', path: '/api/cards/{id}', summary: '删除卡片', auth: true },
{ method: 'PUT', path: '/api/cards/{id}/groups', summary: '整组替换卡片包含的分组与顺序', auth: true },
{ method: 'POST', path: '/api/categories', summary: '旧版:新建分类', auth: true },
{ method: 'PATCH', path: '/api/categories/{id}', summary: '旧版:改分类', auth: true },
{ method: 'DELETE', path: '/api/categories/{id}', summary: '旧版:删分类', auth: true },
{ method: 'POST', path: '/api/categories/{id}/groups', summary: '旧版:新建分组', auth: true },
{ method: 'PATCH', path: '/api/groups/{id}', summary: '旧版:改分组', auth: true },
{ method: 'DELETE', path: '/api/groups/{id}', summary: '旧版:删分组', auth: true },
],
},
{
title: '站点设置',
desc: '页头、侧边栏、卡片、资料、站点信息等整份 JSON。',
items: [
{ method: 'GET', path: '/api/settings', summary: '读取设置(含 version 乐观锁)' },
{ method: 'PATCH', path: '/api/settings', summary: '整份保存设置', auth: true },
{ method: 'GET', path: '/api/backup', summary: '下载 SQLite 快照', auth: true },
],
},
{
title: '认证',
desc: '单管理员账号;会话 token 只存 sha256,Cookie 为 HttpOnly + SameSite=Strict。',
items: [
{ method: 'GET', path: '/api/auth/session', summary: '当前会话状态(是否登录、是否已设密码)' },
{ method: 'POST', path: '/api/auth/setup', summary: '首次设置管理员密码(仅空库可用)' },
{ method: 'POST', path: '/api/auth/login', summary: '登录;连续失败 5 次锁 60 秒' },
{ method: 'POST', path: '/api/auth/logout', summary: '退出并清掉会话' },
{ method: 'PATCH', path: '/api/auth/password', summary: '改密码(需当前密码)', auth: true },
],
},
]
const METHOD_TONE = {
GET: 'neutral',
POST: 'primary',
PATCH: 'warning',
PUT: 'warning',
DELETE: 'danger',
} as const
export function ApiDocs() {
return (
<div className="api-docs">
<Space direction="column" gap="sm" align="stretch">
<Title level={2}>接口一览</Title>
<Text size="sm" tone="muted">
说明:这是 Stash 自带的 HTTP 接口清单,全部挂在同一个端口上。读接口公开,写接口需要登录会话并校验同源
Origin;带 <Tag tone="primary">需登录</Tag> 标记的即为写操作。
</Text>
</Space>
{GROUPS.map((group) => (
<Card key={group.title} padding="lg">
<Space direction="column" gap="sm" align="stretch">
<Space gap="sm" align="center">
<Title level={4} as="h2">
{group.title}
</Title>
<Text size="sm" tone="muted">
{group.items.length} 个
</Text>
</Space>
<Text size="sm" tone="muted">
{group.desc}
</Text>
<Table
rowKey="id"
columns={[
{ key: 'method', title: '方法', width: 90 },
{ key: 'path', title: '路径', width: 280 },
{ key: 'summary', title: '说明' },
{ key: 'auth', title: '权限', width: 100 },
]}
rows={group.items.map((item) => ({
id: `${item.method} ${item.path}`,
method: <Tag tone={METHOD_TONE[item.method]}>{item.method}</Tag>,
path: <code className="api-path">{item.path}</code>,
summary: item.summary,
auth: item.auth === true ? <Tag tone="primary">需登录</Tag> : <Text tone="muted">公开</Text>,
}))}
/>
</Space>
</Card>
))}
</div>
)
}
@@ -0,0 +1,117 @@
import { Button, FormField, Icon, Modal, Notice, PasswordInput, Space, Text } from '@stash/ui'
import { useEffect, useState } from 'react'
import { login, setup, type SessionState } from '../../auth'
/**
* 登录弹窗:首次使用显示「设置密码 + 确认」,之后只显示密码。
*
* 成功后由父组件刷新会话状态;弹窗本身不跳转页面。
*/
type LoginDialogProps = {
open: boolean
hasAdmin: boolean
onClose: () => void
onSuccess: (session: SessionState) => void
}
export function LoginDialog({ open, hasAdmin, onClose, onSuccess }: LoginDialogProps) {
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// 每次打开重置,避免上次的错误/输入残留
useEffect(() => {
if (!open) return
setPassword('')
setConfirm('')
setError('')
setBusy(false)
}, [open])
const isSetup = !hasAdmin
const submit = async () => {
setError('')
if (isSetup && password !== confirm) {
setError('两次输入的密码不一致')
return
}
setBusy(true)
const result = isSetup ? await setup(password) : await login(password)
setBusy(false)
if (!result.ok) {
setError(result.error)
return
}
onSuccess({
authenticated: true,
has_admin: true,
username: 'admin',
})
}
return (
<Modal
open={open}
onClose={onClose}
title={isSetup ? '设置管理员密码' : '登录'}
footer={
<Space gap="sm" justify="end">
<Button variant="secondary" onClick={onClose}>
取消
</Button>
<Button loading={busy} icon={<Icon name="lock_line" />} onClick={submit}>
{isSetup ? '设置并登录' : '登录'}
</Button>
</Space>
}
>
<Space direction="column" gap="md" align="stretch">
<Text size="sm" tone="muted">
{isSetup
? '首次使用:设置一个至少 8 位的密码,之后用它登录编辑。'
: '登录后可以编辑页头滚动信息、侧栏顺序与显示。'}
</Text>
{error !== '' && (
<Notice tone="error" title="操作失败">
{error}
</Notice>
)}
<FormField label="密码" htmlFor="login-password" required>
<PasswordInput
id="login-password"
placeholder="输入密码"
autoComplete={isSetup ? 'new-password' : 'current-password'}
value={password}
autoFocus
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
{isSetup && (
<FormField label="确认密码" htmlFor="login-confirm" required>
<PasswordInput
id="login-confirm"
placeholder="再输入一次"
autoComplete="new-password"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
)}
</Space>
</Modal>
)
}
@@ -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,14 @@
import { Icon, ICON_NAMES, type IconName } from '@stash/ui'
/**
* 分组图标:能对上 iconfont 名字就渲染图标,否则按字符渲染。
*
* 旧数据里存的是 emoji / 任意字符,新数据存图标名(如 `folder_2_line`);
* 两种都要能显示,所以这里做一次判定,前后台共用同一套规则。
*/
export function GroupMark({ icon, fallback = '' }: { icon: string; fallback?: string }) {
const value = icon.trim()
if (value === '') return fallback === '' ? null : <>{fallback}</>
if (ICON_NAMES.includes(value as IconName)) return <Icon name={value as IconName} />
return <>{value}</>
}
@@ -0,0 +1,26 @@
import { EntryRow, ItemBadge } from '@stash/ui'
import type { AppItem } from '../../data/types'
/**
* 应用卡片:头像 + 名称(热门带 HOT 角标)+ 单行描述。
* 版式与点击语义都在库里的 EntryRow,这里只做数据映射。
*/
type AppCardProps = {
item: AppItem
onOpen?: (itemId: string) => void
}
export function AppCard({ item, onOpen }: AppCardProps) {
return (
<EntryRow
title={item.name}
description={item.desc}
href={item.href}
avatar={item.cover}
fallback={item.icon}
verified={item.hot === true}
badge={item.hot === true ? <ItemBadge tone="hot">HOT</ItemBadge> : undefined}
onOpen={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
)
}
@@ -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,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,22 @@
import { EntryTile } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
/**
* 明细页图文卡:上图下文的白卡。
* 版式与点击语义都在库里的 EntryTile,这里只做数据映射。
*/
type CesCardProps = {
item: ArticleItem
onOpen?: (itemId: string) => void
}
export function CesCard({ item, onOpen }: CesCardProps) {
return (
<EntryTile
title={item.title}
description={item.desc}
cover={item.cover}
onOpen={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
)
}
@@ -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,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,128 @@
import { Brand, Icon, IconButton, Tag, Ticker } from '@stash/ui'
import {
fillHeaderText,
type HeaderItem,
type SettingsTone,
type SiteMetaSettings,
} from '../../settings'
/**
* 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
* 品牌与操作图标是否出现,都来自「页头」Tab 的站点设置。
* 外壳(品牌、跑马灯)都用组件库,这里只拼内容。
*/
type SiteHeaderProps = {
projectCount: number
/** 用于展示的第一个项目名,没有项目时留空 */
sampleProject?: string
live: boolean
/** 页头滚动信息;为空时用默认三条 */
headerItems: HeaderItem[]
headerEnabled: boolean
/** 滚动一轮的秒数 */
headerSpeed: number
/** 信息条目之间的间距(px) */
headerGap?: number
/** 品牌与操作工具配置 */
site: SiteMetaSettings
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */
authenticated: boolean
username?: string
/** 刷新中:刷新按钮转圈 */
refreshing?: boolean
/** 当前是否为深色,决定明暗按钮的图标与文案 */
dark?: boolean
onRefresh: () => void
onToggleTheme: () => void
onLogin: () => void
onLogout: () => void
onSettings: () => void
}
const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = {
plain: 'neutral',
neutral: 'neutral',
primary: 'primary',
success: 'success',
}
export function SiteHeader({
projectCount,
sampleProject,
live,
headerItems,
headerEnabled,
headerSpeed,
headerGap,
site,
authenticated,
username,
refreshing = false,
dark = false,
onRefresh,
onToggleTheme,
onLogin,
onLogout,
onSettings,
}: SiteHeaderProps) {
const context = { count: projectCount, project: sampleProject, live }
return (
<div className="site-header-inner">
<Brand
href="/"
name={site.brand.name}
mark={site.brand.mark}
logo={site.brand.logo}
/>
<div className="header-ticker-slot">
{headerEnabled && (
<Ticker speed={headerSpeed} gap={headerGap} label="运行状态" className="header-ticker">
{headerItems.map((item, index) => (
<span className="header-ticker-item" key={`${index}-${item.text}`}>
{item.tag !== undefined && item.tag !== null && item.tag !== '' && (
<Tag tone={tagTone[item.tone]} shape="rounded">
{item.tag}
</Tag>
)}
{fillHeaderText(item.text, context)}
</span>
))}
</Ticker>
)}
</div>
<div className="header-actions">
{site.actions.theme && (
<IconButton
label={dark ? '切换到浅色' : '切换到深色'}
icon={<Icon name={dark ? 'sun_line' : 'moon_line'} />}
onClick={onToggleTheme}
/>
)}
{site.actions.refresh && (
<IconButton
label="刷新(重扫项目并重拉内容)"
icon={<Icon name="lightning_line" />}
spinning={refreshing}
onClick={onRefresh}
/>
)}
{site.actions.settings && authenticated && (
<IconButton label="站点设置" icon={<Icon name="settings_1_line" />} onClick={onSettings} />
)}
{site.actions.session &&
(authenticated ? (
<IconButton
label={username === undefined ? '退出登录' : `退出登录(${username})`}
icon={<Icon name="exit_line" />}
onClick={onLogout}
/>
) : (
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
))}
</div>
</div>
)
}
-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>
)
}
@@ -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,87 @@
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
/** 打开接口一览:走站内路由,不整页刷新 */
onOpenApi: () => void
}
export function ServicePanel({
projects,
live,
refreshing,
onRefresh,
onOpenApi,
}: 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>
<Button
size="sm"
variant="secondary"
className="service-action"
icon={<Icon name="link_3_line" />}
onClick={(event) => {
// 无修饰键时走站内路由;按住修饰键仍可新标签打开接口页
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
window.open('/interface', '_blank', 'noopener')
return
}
onOpenApi()
}}
title="查看接口一览"
>
查看接口
</Button>
</div>
</Card>
)
}
+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))
)
}
+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>,
)
+201
View File
@@ -0,0 +1,201 @@
/**
* 原子模型的后台接口:分组树 + 卡片。
*
* 分组是唯一容器,卡片只是「勾选分组」的展示组合;两者都通过业务键关联。
*/
export type GroupFormat = 'apps' | 'posters' | 'articles'
export type GroupEntry = {
id: string
format?: GroupFormat
name?: string
title?: string
href?: string
description?: string
desc?: string
}
export type GroupNode = {
key: string
parentKey?: string | null
title: string
icon: string
barColor: string
previewLimit?: number | null
sort: number
/** 是否启用;禁用后前台不展示 */
enabled: boolean
entries: GroupEntry[]
children: GroupNode[]
}
export type CardGroupRef = {
groupKey: string
previewLimit?: number | null
}
export type CardItem = {
id: number
key: string
title: string
sort: number
groups: CardGroupRef[]
}
export type ManageData = {
groups: GroupNode[]
cards: CardItem[]
}
export type SimpleResult = { ok: true } | { ok: false; error: string }
async function errorMessage(response: Response, fallback: string): Promise<string> {
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return payload?.error ?? `${fallback}(${response.status})`
}
export async function fetchManage(): Promise<ManageData> {
try {
const response = await fetch('/api/catalog2', { credentials: 'same-origin' })
if (!response.ok) return { groups: [], cards: [] }
return (await response.json()) as ManageData
} catch {
return { groups: [], cards: [] }
}
}
export async function createNode(input: {
key: string
title: string
parentKey?: string
format?: GroupFormat
icon?: string
sort?: number
enabled?: boolean
}): Promise<SimpleResult> {
try {
const response = await fetch('/api/nodes', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '创建失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function updateNode(
key: string,
patch: {
title?: string
parentKey?: string
sort?: number
format?: GroupFormat
icon?: string
enabled?: boolean
},
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/nodes/${encodeURIComponent(key)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '保存失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function deleteNode(key: string): Promise<SimpleResult> {
try {
const response = await fetch(`/api/nodes/${encodeURIComponent(key)}`, {
method: 'DELETE',
credentials: 'same-origin',
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '删除失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function createCard(title: string): Promise<{ ok: true; id: number } | { ok: false; error: string }> {
try {
const response = await fetch('/api/cards', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!response.ok) return { ok: false, error: await errorMessage(response, '创建失败') }
const payload = (await response.json()) as { id: number }
return { ok: true, id: payload.id }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function updateCard(
id: number,
patch: { title?: string; sort?: number },
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/cards/${id}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '保存失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function deleteCard(id: number): Promise<SimpleResult> {
try {
const response = await fetch(`/api/cards/${id}`, {
method: 'DELETE',
credentials: 'same-origin',
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '删除失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function setCardGroups(
id: number,
groups: CardGroupRef[],
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/cards/${id}/groups`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(groups),
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '保存失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
/** 把分组树拍平成下拉选项 */
export function flattenGroups(
groups: GroupNode[],
depth = 0,
): { key: string; title: string; depth: number; enabled: boolean }[] {
return groups.flatMap((group) => [
{
key: group.key,
title: group.title,
depth,
enabled: group.enabled !== false,
},
...flattenGroups(group.children, depth + 1),
])
}
-19
View File
@@ -1,19 +0,0 @@
/**
* 登录后展示的用户模块数据。
*
* 后端目前没有鉴权 / 用户信息接口,这里先用占位数据,
* 等类似 `/api/me` 的接口就绪后替换为真实数据即可。
*/
export type UserProfile = {
name: string
role: string
bio: string
location: string
}
export const PLACEHOLDER_PROFILE: UserProfile = {
name: '本地管理员',
role: '管理员',
bio: '本地静态站点托管与预览,所有项目都运行在这台机器上。',
location: '局域网',
}
+433
View File
@@ -0,0 +1,433 @@
/**
* 站点设置:页头滚动信息 + 侧栏顺序/显隐。
*
* 接口是公开读、登录后写;写的时候带 `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
/** 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽 */
gap: number
items: HeaderItem[]
}
sidebar: {
/** 是否显示「本地管理员」资料卡 */
show_profile: boolean
/** 是否显示「本地服务」面板 */
show_service: boolean
/** 是否显示「分组导航」卡片(首页 / 分组树) */
show_nav: boolean
hidden: string[]
order: string[]
/** 隐藏的分组:`分区id/分组id` */
hidden_groups: string[]
/** 强制显示的分组(覆盖数据库 show_in_sidebar=0) */
shown_groups: string[]
/** 每个分区下的分组顺序:分区 id → 分组 id 列表 */
group_order: Record<string, string[]>
/** 新模型:侧栏隐藏的分组 key */
tree_hidden: string[]
/** 新模型:每级分组的顺序,键为父分组 key(根级用空串) */
tree_order: Record<string, string[]>
}
sections: SectionSettings
profile: ProfileSettings
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
site: SiteMetaSettings
}
export type SiteTheme = 'light' | 'dark' | 'auto'
export type SiteMetaSettings = {
/** 浏览器标签标题 */
title: string
/** favicon 地址;空表示用内置图标 */
favicon: string
/** 配色:浅色 / 深色 / 跟随系统 */
theme: SiteTheme
brand: {
/** 页头品牌文字,可留空 */
name: string
/** 没有图片时圆形标记里的首字 */
mark: string
/** logo 图片地址;空表示用首字 */
logo: string
}
actions: {
/** 明暗切换 */
theme: boolean
/** 刷新图标(重扫本地项目并重拉内容库) */
refresh: boolean
/** 站点设置图标 */
settings: boolean
/** 登录 / 退出登录 */
session: boolean
}
}
export type SettingsDocument = {
version: number
settings: SiteSettings
}
export type SettingsResult =
| { ok: true; document: SettingsDocument }
| { ok: false; status: number; error: string }
export const TONE_OPTIONS: { value: SettingsTone; label: string }[] = [
{ value: 'plain', label: '无底色' },
{ value: 'neutral', label: '灰色' },
{ value: 'primary', label: '品牌色' },
{ value: 'success', label: '成功绿' },
]
export function defaultSettings(): SiteSettings {
return {
header: {
enabled: true,
speed: 24,
gap: 26,
items: [
{ text: '共 {count} 个本地应用', tone: 'plain' },
{ text: '{project}', tag: '本地', tone: 'primary' },
{ text: '{status}', tag: '状态', tone: 'success' },
],
},
sidebar: {
show_profile: true,
show_service: true,
show_nav: true,
hidden: [],
order: [],
hidden_groups: [],
shown_groups: [],
group_order: {},
tree_hidden: [],
tree_order: {},
},
sections: { hidden: [], order: [], titles: {}, limits: {} },
profile: {
name: '本地管理员',
role: '管理员',
description: '本地静态站点托管与预览,所有项目都运行在这台机器上。',
location: 'IP属地:局域网',
avatar: '',
},
site: {
title: 'Stash · 本地应用工作区',
favicon: '',
theme: 'auto',
brand: { name: 'STASH', mark: 'S', logo: '' },
actions: { theme: true, refresh: true, settings: true, session: true },
},
}
}
/** 占位符替换:`{count}` / `{project}` / `{status}` */
export function fillHeaderText(
text: string,
context: { count: number; project?: string; live: boolean },
): string {
return text
.replaceAll('{count}', String(context.count))
.replaceAll('{project}', context.project ?? '—')
.replaceAll('{status}', context.live ? '实时同步已连接' : '等待服务连接')
}
export async function fetchSettings(): Promise<SettingsDocument> {
try {
const response = await fetch('/api/settings', { credentials: 'same-origin' })
if (!response.ok) throw new Error(String(response.status))
return (await response.json()) as SettingsDocument
} catch {
return { version: 0, settings: defaultSettings() }
}
}
export async function saveSettings(
version: number,
settings: SiteSettings,
): Promise<SettingsResult> { try {
const response = await fetch('/api/settings', {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ version, settings }),
})
if (response.ok) {
return { ok: true, document: (await response.json()) as SettingsDocument }
}
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return {
ok: false,
status: response.status,
error: payload?.error ?? `保存失败(${response.status})`,
}
} catch (error) {
return {
ok: false,
status: 0,
error: error instanceof Error ? error.message : '网络错误',
}
}
}
/**
* 按设置重排侧栏:先按 `order`,未列出的保持原顺序,`hidden` 过滤掉。
*/export function applySidebarSettings<T extends { id: string }>(
categories: T[],
sidebar: SiteSettings['sidebar'],
): T[] {
return orderAndFilter(categories, sidebar)
}
/** 首页卡片:过滤隐藏、按 order 排序(未列出的保持原顺序) */
export function applySectionSettings<T extends { id: string }>(
categories: T[],
sections: SectionSettings,
): T[] {
return orderAndFilter(categories, sections)
}
/**
* 侧栏子分类(分组):按设置过滤、排序。
*
* 数据库里的 `show_in_sidebar` 是默认值;`shown_groups` 可以把它强行打开,
* `hidden_groups` 则再隐藏一层。
*/
export function applySidebarGroups(
categories: Category[],
sidebar: SiteSettings['sidebar'],
): Category[] {
return categories.map((category) => {
if (category.kind === 'apps') {
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
}
if (category.kind === 'posters') {
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
}
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
})
}
/** 按设置过滤、排序某个分区下的分组 */
function filterGroups<T extends { id: string }>(
groups: T[],
categoryId: string,
sidebar: SiteSettings['sidebar'],
): T[] {
const hidden = new Set(sidebar.hidden_groups)
const shown = new Set(sidebar.shown_groups)
const ranks = new Map(
(sidebar.group_order[categoryId] ?? []).map((id, index) => [id, index] as const),
)
return groups
.filter((group) => {
const key = `${categoryId}/${group.id}`
const showInSidebar = (group as { showInSidebar?: boolean }).showInSidebar
return !hidden.has(key) && (showInSidebar !== false || shown.has(key))
})
.map((group, index) => ({
group,
index,
rank: ranks.get(group.id) ?? Number.MAX_SAFE_INTEGER,
}))
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
.map((entry) => entry.group)
}
/** 卡片标题:设置里的覆盖值优先 */
export function sectionTitle(
id: string,
fallback: string,
sections: SectionSettings,
): string {
const override = sections.titles[id]
return override !== undefined && override.trim() !== '' ? override : fallback
}
/** 卡片预览条数:设置里的覆盖值优先 */
export function sectionLimit(
id: string,
fallback: number,
sections: SectionSettings,
): number {
const override = sections.limits[id]
return typeof override === 'number' && override > 0 ? override : fallback
}
function orderAndFilter<T extends { id: string }>( categories: T[],
config: { hidden: string[]; order: string[] },
): T[] {
const hidden = new Set(config.hidden)
const ranks = new Map(config.order.map((id, index) => [id, index]))
return categories
.filter((category) => !hidden.has(category.id))
.map((category, index) => ({
category,
index,
rank: ranks.get(category.id) ?? Number.MAX_SAFE_INTEGER,
}))
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
.map((entry) => entry.category)
}
// ---------- 分区(首页卡片 / 侧栏项)的新增与删除 ----------
export type NewCategory = { id: string; title: string; kind: 'apps' | 'posters' | 'articles' }
export type SimpleResult = { ok: true } | { ok: false; error: string }
export async function createCategory(input: NewCategory): Promise<SimpleResult> {
try {
const response = await fetch('/api/categories', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function deleteCategory(id: string): Promise<SimpleResult> {
try {
const response = await fetch(`/api/categories/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'same-origin',
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
/** 修改分区(归类、标题、图标、色条、预览条数) */
export type UpdateCategoryInput = {
title?: string
icon?: string
barColor?: string
kind?: 'apps' | 'posters' | 'articles'
previewLimit?: number
emptyText?: string
/** 首页卡片预览哪个节点(分区自己或其分组) */
blockNodeKey?: string
/** 是否允许子分组(与类型无关的开关) */
allowGroups?: boolean
}
export async function updateCategory(
id: string,
patch: UpdateCategoryInput,
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/categories/${encodeURIComponent(id)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `保存失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
// ---------- 分组(子分类) ----------
export async function createGroup(
sectionId: string,
input: { id: string; title: string },
): Promise<SimpleResult> {
try {
const response = await fetch(
`/api/categories/${encodeURIComponent(sectionId)}/groups`,
{
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
},
)
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function updateGroup(
id: string,
patch: { title?: string; showInSidebar?: boolean },
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `保存失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function deleteGroup(id: string): Promise<SimpleResult> {
try {
const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'same-origin',
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* 明暗主题的「本机偏好」。
*
* 站点设置里的 `site.theme` 是**所有人**的默认值;这里的 localStorage 是**这台浏览器**
* 的选择,读到时优先于默认值。前台页头的切换按钮只改本机偏好,不需要登录;
* 后台「页头 → 配色」改的是站点默认值。
*/
export type ThemeChoice = 'light' | 'dark'
const STORAGE_KEY = 'stash-theme'
export function readThemeOverride(): ThemeChoice | null {
try {
const value = window.localStorage.getItem(STORAGE_KEY)
return value === 'light' || value === 'dark' ? value : null
} catch {
return null
}
}
export function writeThemeOverride(value: ThemeChoice | null): void {
try {
if (value === null) window.localStorage.removeItem(STORAGE_KEY)
else window.localStorage.setItem(STORAGE_KEY, value)
} catch {
// 隐私模式下 localStorage 可能不可写,忽略即可
}
}
/** `light` / `dark` / `auto` 里,当前实际是不是深色 */
export function isDarkScheme(scheme: string): boolean {
if (scheme === 'dark') return true
if (scheme === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
+4
View File
@@ -6,6 +6,10 @@ const BACKEND = 'http://127.0.0.1:8080'
// 前后端分离开发:dev / preview 都把 /api 透传给本地 Rust 服务
export default defineConfig({
plugins: [react()],
// @stash/ui 是本地 file 依赖(软链),去重 React,避免加载两份运行时
resolve: {
dedupe: ['react', 'react-dom'],
},
server: {
port: 5173,
proxy: {
-17
View File
@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - 资源未找到</title>
<link rel="stylesheet" href="/__stash__/dashboard.css">
</head>
<body class="error-page">
<main class="error-box">
<div class="error-code">404</div>
<h1>资源或页面不存在</h1>
<p>请求的路径 <code>{{ path }}</code> 不存在或缺少 index.html</p>
<a href="/public" class="button">返回应用大厅</a>
</main>
</body>
</html>
-120
View File
@@ -1,120 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#f7f8fa">
<title>Stash · 本地应用工作区</title>
<link rel="stylesheet" href="/__stash__/dashboard.css">
<script src="/__stash__/dashboard.js" defer></script>
</head>
<body class="dashboard-page">
<div class="ambient ambient-blue" aria-hidden="true"></div>
<div class="ambient ambient-warm" aria-hidden="true"></div>
<div class="workspace">
<aside class="sidebar">
<header class="sidebar-header">
<a class="brand" href="/public" aria-label="Stash 首页">
<span class="brand-mark" aria-hidden="true">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="2"></rect>
<rect x="14" y="3" width="7" height="7" rx="2"></rect>
<rect x="3" y="14" width="7" height="7" rx="2"></rect>
<path d="M17.5 14v7M14 17.5h7"></path>
</svg>
</span>
<span class="brand-name">stash<span>.</span></span>
</a>
<span class="local-badge">LOCAL</span>
</header>
<div class="sidebar-intro">
<p class="eyebrow"><span class="eyebrow-dot"></span>YOUR WORKSPACE</p>
<h1>把项目,<br>放在手边。</h1>
<p class="sidebar-lead">Stash 正在整理当前目录下的静态前端应用,让每次预览都从一个清爽入口开始。</p>
</div>
<section class="overview-card" aria-label="工作区概览">
<div class="overview-label">项目概览</div>
<div class="project-total">
<strong data-project-count>{{ projects.len() }}</strong>
<span>个应用</span>
</div>
<div class="service-status">
<span class="status-dot"></span>
<span>本地服务运行中</span>
</div>
</section>
<div class="sidebar-actions">
<button class="refresh-button js-only" id="refresh-projects" type="button" aria-label="重新扫描项目">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 11a8.1 8.1 0 0 0-15.5-2M4 4v5h5"></path>
<path d="M4 13a8.1 8.1 0 0 0 15.5 2M20 20v-5h-5"></path>
</svg>
<span>重新扫描</span>
</button>
<p class="sidebar-hint">目录变化会自动同步,也可以手动重新扫描。</p>
</div>
<footer class="sidebar-footer">
<span>STATIC HOSTING</span>
<span class="footer-ready"><i></i>READY</span>
</footer>
</aside>
<main class="content">
<header class="content-header">
<div class="content-heading">
<p class="eyebrow">PROJECT LIBRARY</p>
<h2>本地应用</h2>
<p>一个入口,快速抵达每个正在开发或预览的页面。</p>
</div>
<div class="content-meta">
<span class="live-pill" id="live-status"><i></i><span id="live-status-label">服务在线</span></span>
<span class="count-pill"><strong data-project-count>{{ projects.len() }}</strong> 个项目</span>
</div>
</header>
<section class="site-grid" id="project-grid" aria-live="polite">
{% if projects.is_empty() %}
<div class="empty-state">
<span class="empty-visual" aria-hidden="true">
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
<path d="M8 13h8M8 16h5"></path>
</svg>
</span>
<h3>这里还没有项目</h3>
<p>在托管根目录下创建子目录,并放入 <code>index.html</code>,刷新后即可在这里看到它。</p>
</div>
{% else %}
{% for project in projects %}
<a class="site-item" href="{{ project.route_path }}" title="{{ project.name }}">
<div class="site-card-top">
<span class="site-avatar" aria-hidden="true">{{ project.initial() }}</span>
<span class="site-open" aria-hidden="true">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 17 17 7M8 7h9v9"></path>
</svg>
</span>
</div>
<div class="site-info">
<h3 class="site-name">{{ project.name }}</h3>
<span class="site-desc">本地静态应用</span>
</div>
<div class="site-card-footer">
<span class="site-path">{{ project.route_path }}</span>
<span class="site-ready">READY</span>
</div>
</a>
{% endfor %}
{% endif %}
</section>
<p class="refresh-status" id="refresh-status" role="status" aria-live="polite"></p>
</main>
</div>
</body>
</html>
-741
View File
@@ -1,741 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>导航主页</title>
<style>
:root {
--bg-color: #f7f9fb;
--card-bg: #ffffff;
--primary-color: #3b82f6;
--text-main: #1e293b;
--text-sub: #64748b;
--border-color: #e2e8f0;
--radius-main: 12px;
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.03), 0 1px 3px rgba(0, 0, 0, 0.02);
--shadow-hover: 0 10px 20px rgba(0, 0, 0, 0.06), 0 4px 6px rgba(0, 0, 0, 0.03);
--sidebar-width: 240px;
--top-nav-height: 60px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* 1. 锁死大窗口,禁止全局滚动 */
html, body {
width: 100vw;
height: 100vh;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
}
/* 通用卡片容器 */
.card-box {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-main);
box-shadow: var(--shadow-card);
}
/* 2. 顶部直角导航栏 */
.top-header-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: var(--top-nav-height);
background-color: var(--card-bg);
border-bottom: 1px solid var(--border-color);
border-radius: 0 !important;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 200;
}
.site-logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 800;
font-size: 18px;
color: #0f172a;
text-decoration: none;
width: 180px;
flex-shrink: 0;
}
.logo-icon {
width: 32px;
height: 32px;
background-color: #000;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
font-weight: bold;
}
.ticker-wrapper {
flex: 1;
max-width: 550px;
height: 36px;
overflow: hidden;
position: relative;
margin: 0 20px;
display: flex;
align-items: center;
mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);
-webkit-mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);
}
.ticker-content {
display: flex;
gap: 40px;
white-space: nowrap;
animation: ticker 18s linear infinite;
}
.ticker-wrapper:hover .ticker-content {
animation-play-state: paused;
}
.ticker-item {
font-size: 13.5px;
color: var(--text-sub);
text-decoration: none;
display: flex;
align-items: center;
gap: 6px;
}
.ticker-tag {
background-color: #eff6ff;
color: var(--primary-color);
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
}
@keyframes ticker {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.top-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.action-btn {
background: transparent;
border: none;
cursor: pointer;
color: #475569;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 50%;
transition: all 0.2s;
}
.action-btn:hover {
background-color: #f1f5f9;
color: var(--primary-color);
}
.action-btn svg {
width: 19px;
height: 19px;
stroke-width: 2;
stroke: currentColor;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
/* ---------------- 搜索框:全局固定且全屏水平居中 ---------------- */
.search-row {
position: fixed;
top: 76px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
width: 480px;
height: 44px;
pointer-events: auto;
}
.search-bar {
display: flex;
align-items: center;
gap: 10px;
height: 100%;
width: 100%;
padding: 0 12px;
}
.engine-btn {
background: transparent;
border: none;
color: var(--text-sub);
cursor: pointer;
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.engine-btn:hover {
background-color: #f1f5f9;
color: var(--primary-color);
}
.engine-btn svg {
width: 16px;
height: 16px;
stroke-width: 2;
stroke: currentColor;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.search-input {
width: 100%;
height: 36px;
font-size: 13.5px;
border: none;
background: transparent;
outline: none;
color: var(--text-main);
}
/* ---------------- 3. 双列核心网格 ---------------- */
.layout-grid {
margin-top: var(--top-nav-height);
height: calc(100vh - var(--top-nav-height));
padding: 36px 24px 0 20px;
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
gap: 20px;
align-items: start;
}
.left-column {
display: flex;
flex-direction: column;
gap: 16px;
flex-shrink: 0;
margin-top: 68px;
}
.right-column {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow: hidden;
padding-top: 52px;
}
.user-card {
height: 52px;
padding: 0 12px;
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 8px;
background-color: #eff6ff;
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 15px;
}
.user-name {
font-size: 13.5px;
font-weight: 600;
color: var(--text-main);
line-height: 1.2;
}
.user-desc {
font-size: 11.5px;
color: var(--text-sub);
margin-top: 2px;
}
.nav-card {
padding: 8px;
height: auto;
}
.nav-group {
list-style: none;
}
.nav-item-wrapper {
margin-bottom: 4px;
}
.nav-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 12px;
color: var(--text-sub);
text-decoration: none;
border-radius: 8px;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.nav-link:hover, .nav-link.active {
background-color: #eff6ff;
color: var(--primary-color);
}
.arrow-icon {
font-size: 13px;
transition: transform 0.3s;
color: #94a3b8;
font-family: monospace;
font-weight: bold;
}
.nav-item-wrapper.open > .nav-link .arrow-icon {
transform: rotate(90deg);
}
.sub-menu {
list-style: none;
padding-left: 12px;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s cubic-bezier(0, 1, 0, 1);
}
.nav-item-wrapper.open .sub-menu {
max-height: 500px;
transition: max-height 0.3s ease-in-out;
}
.sub-menu .nav-link {
padding: 7px 12.5px;
font-size: 12.5px;
}
.submit-card {
padding: 6px;
}
.submit-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 9px 12px;
color: var(--text-sub);
text-decoration: none;
border-radius: 8px;
font-size: 13.5px;
font-weight: 500;
}
.submit-btn:hover {
background-color: #eff6ff;
color: var(--primary-color);
}
/* 4. 右侧独立滚动区域 */
.main-content {
flex: 1;
overflow-y: auto;
padding-right: 4px;
padding-top: 16px;
padding-bottom: 40px;
-webkit-mask-image: linear-gradient(to bottom, transparent 0px, black 22px, black 100%);
mask-image: linear-gradient(to bottom, transparent 0px, black 22px, black 100%);
-ms-overflow-style: none;
scrollbar-width: none;
}
.main-content::-webkit-scrollbar {
display: none !important;
width: 0 !important;
}
.section-group {
margin-bottom: 24px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--text-main);
display: flex;
align-items: center;
line-height: 1;
margin-bottom: 16px;
padding-top: 2px;
}
.section-title::before {
content: "";
display: inline-block;
width: 4px;
height: 15px;
background-color: var(--primary-color);
margin-right: 8px;
border-radius: 2px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 12px 14px;
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
box-shadow: var(--shadow-card);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-hover);
}
.card-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background-color: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--primary-color);
margin-right: 12px;
flex-shrink: 0;
}
.card-title {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-desc {
font-size: 12px;
color: var(--text-sub);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.back-to-top {
position: fixed;
bottom: 30px;
right: 30px;
width: 40px;
height: 40px;
background-color: #e2e8f0;
border-radius: 50%;
color: #1e293b;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: var(--shadow-card);
transition: all 0.2s ease;
text-decoration: none;
z-index: 90;
}
.back-to-top:hover {
background-color: #cbd5e1;
transform: translateY(-2px);
}
.back-to-top svg {
width: 18px;
height: 18px;
stroke: currentColor;
stroke-width: 2.5;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
</style>
</head>
<body>
<!-- 顶栏 -->
<header class="top-header-bar">
<a href="#" class="site-logo">
<div class="logo-icon">S</div>
<span>SUXING</span>
</a>
<div class="ticker-wrapper">
<div class="ticker-content">
<a href="#" class="ticker-item"><span class="ticker-tag">公告</span> 欢迎使用最新版个人导航站!</a>
<a href="#" class="ticker-item"><span class="ticker-tag">更新</span> WordPress 主题升级至 v3.2 版本</a>
<a href="#" class="ticker-item"><span class="ticker-tag">推荐</span> 常用前端工具箱已更新资源</a>
<a href="#" class="ticker-item"><span class="ticker-tag">公告</span> 欢迎使用最新版个人导航站!</a>
<a href="#" class="ticker-item"><span class="ticker-tag">更新</span> WordPress 主题升级至 v3.2 版本</a>
</div>
</div>
<div class="top-actions">
<button class="action-btn" title="暗黑模式">
<svg viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
</button>
<button class="action-btn" title="快捷方式">
<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
</button>
<button class="action-btn" title="全局搜索">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</button>
</div>
</header>
<!-- 搜索框独立置于全屏水平居中位置 -->
<div class="search-row">
<div class="search-bar card-box">
<button class="engine-btn" title="切换搜索引擎">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</button>
<input type="text" class="search-input" placeholder="输入关键字搜索全网资源...">
</div>
</div>
<!-- 主布局网格 -->
<div class="layout-grid">
<!-- 左侧固定边栏 -->
<div class="left-column">
<div class="user-card card-box">
<div class="avatar">S</div>
<div>
<div class="user-name">Suxing Admin</div>
<div class="user-desc">个人导航站</div>
</div>
</div>
<div class="nav-card card-box">
<ul class="nav-group">
<li class="nav-item-wrapper">
<a href="#common" class="nav-link active">
<span>常用推荐</span>
</a>
</li>
<li class="nav-item-wrapper">
<div class="nav-link has-sub">
<span>素材资源</span>
<span class="arrow-icon">›</span>
</div>
<ul class="sub-menu">
<li><a href="#design" class="nav-link"><span>UI 设计资源</span></a></li>
<li><a href="#icon" class="nav-link"><span>矢量图标</span></a></li>
<li><a href="#photo" class="nav-link"><span>免费壁纸</span></a></li>
</ul>
</li>
<li class="nav-item-wrapper">
<div class="nav-link has-sub">
<span>开发工具</span>
<span class="arrow-icon">›</span>
</div>
<ul class="sub-menu">
<li><a href="#dev" class="nav-link"><span>前端调试工具</span></a></li>
<li><a href="#code" class="nav-link"><span>在线编辑器</span></a></li>
</ul>
</li>
</ul>
</div>
<div class="submit-card card-box">
<a href="#submit" class="submit-btn">提交网站</a>
</div>
</div>
<!-- 右侧区域 -->
<div class="right-column">
<!-- 右侧独立滚动内容 -->
<main class="main-content" id="scrollContainer">
<div id="common" class="section-group">
<div class="section-title">常用推荐</div>
<div class="grid">
<a href="#" class="card">
<div class="card-icon">G</div>
<div>
<div class="card-title">GitHub</div>
<div class="card-desc">代码托管与开源社区</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">Z</div>
<div>
<div class="card-title">知乎</div>
<div class="card-desc">深度内容问答社区</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">B</div>
<div>
<div class="card-title">哔哩哔哩</div>
<div class="card-desc">弹幕视频分享网站</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">V</div>
<div>
<div class="card-title">V2EX</div>
<div class="card-desc">创意工作者讨论社区</div>
</div>
</a>
</div>
</div>
<div id="dev" class="section-group">
<div class="section-title">前端调试工具 (开发工具)</div>
<div class="grid">
<a href="#" class="card">
<div class="card-icon">S</div>
<div>
<div class="card-title">Stack Overflow</div>
<div class="card-desc">程序员技术问答</div>
</div>
</a>
<a href="#" class="card">
<div class="card-icon">J</div>
<div>
<div class="card-title">掘金</div>
<div class="card-desc">开发者技术社区</div>
</div>
</a>
</div>
</div>
<div id="code" class="section-group">
<div class="section-title">在线编辑器</div>
<div class="grid">
<a href="#" class="card">
<div class="card-icon">C</div>
<div>
<div class="card-title">CodePen</div>
<div class="card-desc">前端在线代码演练场</div>
</div>
</a>
</div>
</div>
<div id="design" class="section-group">
<div class="section-title">UI 设计资源</div>
<div class="grid">
<a href="#" class="card">
<div class="card-icon">D</div>
<div>
<div class="card-title">Dribbble</div>
<div class="card-desc">全球设计作品分享社区</div>
</div>
</a>
</div>
</div>
</main>
</div>
</div>
<!-- 回到顶部按钮 -->
<a href="javascript:void(0)" class="back-to-top" id="backToTopBtn" title="回到顶部">
<svg viewBox="0 0 24 24">
<line x1="5" y1="4" x2="19" y2="4"></line>
<path d="M12 20V8M6 14l6-6 6 6"></path>
</svg>
</a>
<script>
document.querySelectorAll('.has-sub').forEach(trigger => {
trigger.addEventListener('click', () => {
trigger.closest('.nav-item-wrapper').classList.toggle('open');
});
});
const scrollContainer = document.getElementById('scrollContainer');
const backToTopBtn = document.getElementById('backToTopBtn');
backToTopBtn.addEventListener('click', () => {
scrollContainer.scrollTo({
top: 0,
behavior: 'smooth'
});
});
document.querySelectorAll('a.nav-link[href^="#"]').forEach(link => {
link.addEventListener('click', function(e) {
const targetId = this.getAttribute('href');
if (targetId === '#') return;
const targetElement = document.querySelector(targetId);
if (targetElement) {
e.preventDefault();
const containerTop = scrollContainer.getBoundingClientRect().top;
const elementTop = targetElement.getBoundingClientRect().top;
const currentScroll = scrollContainer.scrollTop;
const offsetPosition = currentScroll + (elementTop - containerTop) - 24;
scrollContainer.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
document.querySelectorAll('.nav-link').forEach(el => el.classList.remove('active'));
this.classList.add('active');
}
});
});
</script>
</body>
</html>