Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7d0630fe6 | ||
|
|
733c1552ce | ||
|
|
fdfd17657c | ||
|
|
281f04b060 | ||
|
|
9f1a8863f4 | ||
|
|
a0be02b4f4 | ||
|
|
ff5c6f05a2 | ||
|
|
9948e43e6c | ||
|
|
9c8e05c0ee | ||
|
|
6e17169ac4 | ||
|
|
960ea1c5eb | ||
|
|
0aebd610fe | ||
|
|
97b9d00cf3 | ||
|
|
0bb4cc354a | ||
|
|
e243a20b69 | ||
|
|
21825431c7 | ||
|
|
65c7fe79dd | ||
|
|
93f80af893 | ||
|
|
4ce1b7e7c1 | ||
|
|
a9a913942a | ||
|
|
1d1ece6a2a | ||
|
|
9d1e5dcb60 | ||
|
|
4862f8fb6b | ||
|
|
aa6f341505 | ||
|
|
5ae2088826 | ||
|
|
200908cef6 | ||
|
|
56f362f921 | ||
|
|
8c1c658b15 | ||
|
|
038b7ecb42 | ||
|
|
a5d7859c6e | ||
|
|
5439ebf3be | ||
|
|
b9005dd514 | ||
|
|
1f8147dd73 | ||
|
|
5736bbc101 | ||
|
|
3330af818e | ||
|
|
dacb30e548 | ||
|
|
14938a9c83 | ||
|
|
6dabeabaf2 | ||
|
|
ffa123d2cd | ||
|
|
27e411fd2c | ||
|
|
4621f8cb22 | ||
|
|
8889515373 | ||
|
|
bbfbc77b30 | ||
|
|
b1f7589538 | ||
|
|
f25fc8b4ba | ||
|
|
dec82080d3 | ||
|
|
1effbea3dd | ||
|
|
259eb20411 | ||
|
|
b1fd82a0b6 | ||
|
|
6e773374bb | ||
|
|
4ae69b29fe | ||
|
|
a67ab7bfe0 | ||
|
|
883504222b | ||
|
|
27a28ab583 | ||
|
|
036551ee09 | ||
|
|
0375669a6e | ||
|
|
10b653e30f | ||
|
|
8271adc307 | ||
|
|
3f00a64b3b | ||
|
|
a7391d7b1a | ||
|
|
30baa96237 | ||
|
|
d0a79283ed | ||
|
|
54d2025a6e | ||
|
|
784a7c54ac | ||
|
|
923903b4b2 | ||
|
|
84fc148a39 | ||
|
|
5c170f8819 | ||
|
|
18e9543607 | ||
|
|
2f2ceb9ea0 | ||
|
|
8a782099f5 | ||
|
|
59fd61d832 | ||
|
|
bb5a615ed2 | ||
|
|
6c8eaa9f76 | ||
|
|
762de4a4e1 | ||
|
|
2a344ae534 | ||
|
|
ddef75f862 | ||
|
|
e13c2b2ce6 | ||
|
|
a2155a4c89 | ||
|
|
7a61acfa02 | ||
|
|
50aea29ea6 | ||
|
|
4304fd75b4 | ||
|
|
54fd0edf84 | ||
|
|
57ab90a1fa | ||
|
|
0e6c772466 | ||
|
|
11f992cdad | ||
|
|
8d094175fd | ||
|
|
2c126b410c | ||
|
|
c1a4da5458 | ||
|
|
87b3c3b35a | ||
|
|
b04842978c | ||
|
|
c249fbd826 | ||
|
|
165d3f112e | ||
|
|
2ccfb9b623 | ||
|
|
6340e435e9 | ||
|
|
eb4fded8db | ||
|
|
e52db54bdc | ||
|
|
22c73c1145 | ||
|
|
be7a0747ed | ||
|
|
8df9ecdfda | ||
|
|
261d202ce0 | ||
|
|
1a4a6a3655 | ||
|
|
58725caa95 | ||
|
|
c84c41dc7d | ||
|
|
cc08188c1e | ||
|
|
71061c6b87 | ||
|
|
26042c3e3f | ||
|
|
5982e0db5a | ||
|
|
f56614489a | ||
|
|
94e16b783c | ||
|
|
00e0360c85 | ||
|
|
5a411fdda8 | ||
|
|
fe9d1b2659 | ||
|
|
5de1382262 | ||
|
|
f9915535b6 | ||
|
|
6d43b191c9 | ||
|
|
427338b329 | ||
|
|
3c6c20e534 | ||
|
|
8f688885ed | ||
|
|
9ae5a455c5 | ||
|
|
dea7781b86 | ||
|
|
1aac8bcbe7 | ||
|
|
fcba8274c8 | ||
|
|
f456829d16 | ||
|
|
22f7853bdd | ||
|
|
2724d6f3ff | ||
|
|
de1af92ee5 | ||
|
|
43bf742df6 | ||
|
|
ffb96ec7d1 | ||
|
|
95b6cedb06 | ||
|
|
1942e201e9 | ||
|
|
6b0c0a62e9 | ||
|
|
1bdcca025b | ||
|
|
64f1fbf906 | ||
|
|
19ead7b716 | ||
|
|
41fed5e64d | ||
|
|
386c0f77c7 |
+27
-1
@@ -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
|
||||
|
||||
@@ -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(霞鹜文楷)** | 内置默认,`cdn.staticfile.net`;后台「系统管理 → 字体」可换成 `font/` 目录里的字体或外部字体链接 |
|
||||
| 回退 | `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif` | 字体加载失败时 |
|
||||
| 数字(主题) | `Outfit-Medium` | 仅主题的 `font-price` / `font-number` 使用,Stash 未引入 |
|
||||
| 等宽 | `ui-monospace, Consolas, monospace` | 路由地址等路径文本 |
|
||||
|
||||
引入位置:[`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
@@ -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
@@ -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
|
||||
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
# Stash 交接文档
|
||||
|
||||
> 更新于 2026-11-04 19:30(Asia/Shanghai)。本文件是当前状态,取代旧版历史快照。
|
||||
> 新会话先读这份,再按需看 `README.md`、`docs/ADMIN-PLAN.md`、`stash-ui/README.md`。
|
||||
|
||||
## 1. 一句话现状
|
||||
|
||||
Stash 是「本地项目托管 + 可配置导航首页」:Rust 单 crate 内嵌 React 前端,单管理员登录,内容库为 SQLite,分组 / 条目 / 首页卡片 / 站点设置都在 `/admin` 管理。
|
||||
|
||||
当前开发分支 `develop`,本地服务:
|
||||
|
||||
- 前台 + 后台:<http://127.0.0.1:8080>
|
||||
- 管理后台:<http://127.0.0.1:8080/admin>
|
||||
- 接口一览:<http://127.0.0.1:8080/interface>
|
||||
- Vite 开发服务器 `5173` 当前**未运行**;需要时 `cd stash-web && npm run dev`
|
||||
|
||||
## 2. 仓库与分支
|
||||
|
||||
| 仓库 | 路径 | 分支 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 主项目 | `D:\CNWei\CNW\Rust\stash` | `develop` | 工作区干净;功能最新 `1d1ece6`,本文件随后单独提交 |
|
||||
| 组件库 | `D:\CNWei\CNW\Rust\stash-ui` | `master` | 工作区只有忽略的 `.idea/`;`994436a` 最新 |
|
||||
|
||||
- 主项目 remote 名为 `master`(不是 `origin`),地址 `https://gitea.ngmao.com/CNWei/stash.git`。
|
||||
- `develop` 比 `master/develop` **领先 20 个提交,尚未推送**;此前 HTTPS 凭据不可用,需要时先准备 Gitea 凭据。
|
||||
- `stash-ui` 也有 3 个未推送提交:`b97c77e`(SearchField suffix)、`46829cd`(Input leading)、`994436a`(EngineIcon)。
|
||||
- `@stash/ui` 以 `file:../../stash-ui` 引入,`stash-web` 使用其 `dist/`;改组件库后必须重新 `npm run build`。
|
||||
|
||||
## 3. 启动 / 构建 / 测试
|
||||
|
||||
前端产物会被 `build.rs` 内嵌,顺序不能反:
|
||||
|
||||
```powershell
|
||||
cd D:\CNWei\CNW\Rust\stash\stash-web
|
||||
npm run build # tsc -b + vite,产物 dist/
|
||||
|
||||
cd D:\CNWei\CNW\Rust\stash
|
||||
Get-Process stash -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Path -like '*stash\target*' } | Stop-Process -Force # 否则 exe 被占用
|
||||
cargo build
|
||||
Start-Process .\target\debug\stash.exe `
|
||||
-RedirectStandardOutput "$env:TEMP\stash.log" `
|
||||
-RedirectStandardError "$env:TEMP\stash-err.log" -WindowStyle Hidden
|
||||
```
|
||||
|
||||
测试(Windows 下覆盖 TEMP 权限):
|
||||
|
||||
```powershell
|
||||
$env:TEMP = "D:\CNWei\CNW\Rust\stash\target\tmp"; $env:TMP = $env:TEMP
|
||||
cargo test # 交接时 52 passed
|
||||
```
|
||||
|
||||
组件库:
|
||||
|
||||
```powershell
|
||||
cd D:\CNWei\CNW\Rust\stash-ui
|
||||
npm run build # dist/stash-ui.js + .css + types
|
||||
```
|
||||
|
||||
## 4. 代码地图
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs / lib.rs 启动、参数、日志
|
||||
config.rs 端口 / 数据目录 / 托管目录
|
||||
server.rs 全部 API + 内嵌前端 + SPA 回落
|
||||
listing.rs 托管目录扫描
|
||||
watcher.rs 文件监听 + SSE 防抖
|
||||
db/
|
||||
mod.rs Db 门面 + 迁移/种子测试
|
||||
schema.rs 建表、种子、v15→…→v23 迁移
|
||||
auth.rs admin / session / argon2id
|
||||
settings.rs settings JSON + 校验
|
||||
entries.rs 条目 CRUD、浏览、点赞、审计
|
||||
groups.rs 分组树 CRUD / 移动 / 排序
|
||||
cards.rs 卡片与勾选分组
|
||||
catalog.rs 分组树 + 条目查询组装
|
||||
stash-web/src/
|
||||
AppNext.tsx 前台壳:路由、卡片、侧栏、搜索框、主题
|
||||
main.tsx /admin 与普通前台分流
|
||||
settings.ts SiteSettings 类型 / 默认值 / API 客户端
|
||||
manage.ts 分组、卡片、catalog2 客户端
|
||||
items.ts 条目编辑客户端
|
||||
api.ts 项目扫描 / SSE 客户端
|
||||
components/
|
||||
admin/AdminNext.tsx 后台全部面板
|
||||
header/SiteHeader.tsx 品牌 / 滚动信息 / 操作图标
|
||||
content/ 卡片、网格、详情、编辑器
|
||||
sidebar/ 资料卡、本地服务、导航
|
||||
stash-ui/src/components/ SearchField / Input / EngineIcon 等
|
||||
```
|
||||
|
||||
## 5. 数据库(v23)
|
||||
|
||||
路径:`target/debug/data/stash.db`(`--data` 可覆盖)。
|
||||
|
||||
- `PRAGMA user_version = 23`;支持 0 建全量、15→16→…→23 迁移,高于程序版本拒绝启动。
|
||||
- 当前表:`admin`、`audit`、`card`、`card_group`、`entry`、`entry_tag`、`node`、`session`、`setting`。
|
||||
- 当前数据(交接时):15 个 node、44 条 entry、5 张 card;`PRAGMA foreign_key_check` 无异常。
|
||||
- `section_block` 与 `node.view_mode / empty_text / show_in_sidebar / allow_groups` 已在 v23 删除。
|
||||
- `entry.format` 是每条内容自己的 `apps / posters / articles`,同一分组可混合。
|
||||
- `node.kind = 'section'` 是根分组,`group` 是子分组;`enabled` 控制整棵子树显示。
|
||||
- 条目只存 `node_key`;格式、分组、卡片都通过业务 key 关联。
|
||||
- 备份:`target/debug/data/stash.db.bak-pre-cleanup-v22` 是 v23 改造前快照;`/api/backup` 可下载当前快照。
|
||||
- **不要直接 `cargo clean`/删除整个 `target`**:运行库和备份在 `target/debug/data/`。
|
||||
|
||||
## 6. settings JSON
|
||||
|
||||
`setting` 表 `key='site'`,API `GET/PATCH /api/settings`,`version` 乐观锁。当前字段:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"header": { "enabled": true, "speed": 24, "gap": 26, "items": [...] },
|
||||
"sidebar": { "show_profile": true, "show_service": false, "show_nav": true },
|
||||
"background": { "light": "", "dark": "" }, // 空 = 内置背景
|
||||
"font": { "source": "builtin", "current": "", "items": [] }, // builtin = 文楷;custom = 用 items 里 current 那条
|
||||
"search": {
|
||||
"default": "internal",
|
||||
"engines": [
|
||||
{ "id": "google", "name": "Google", "url": "https://www.google.com/search?q={query}", "icon": "", "enabled": true },
|
||||
{ "id": "bing", "name": "Bing", "url": "https://www.bing.com/search?q={query}", "enabled": true },
|
||||
{ "id": "baidu", "name": "百度", "url": "https://www.baidu.com/s?wd={query}", "enabled": true },
|
||||
{ "id": "sogou", "name": "搜狗", "url": "https://www.sogou.com/web?query={query}", "enabled": true },
|
||||
{ "id": "360", "name": "360", "url": "https://www.so.com/s?q={query}", "enabled": true },
|
||||
{ "id": "duckduckgo", "name": "DuckDuckGo", "url": "https://duckduckgo.com/?q={query}", "enabled": true }
|
||||
]
|
||||
},
|
||||
"profile": { "name": "...", "role": "...", "description": "...", "location": "...", "avatar": "" },
|
||||
"site": { "title": "...", "favicon": "", "theme": "light", "brand": {...}, "actions": {...} }
|
||||
}
|
||||
```
|
||||
|
||||
- `search.default` 可以是 `internal` 或某个引擎 id;默认默认值是 `internal`。
|
||||
- 上面是字段结构示例;实际值以 `GET /api/settings` 为准(交接时 `search.default=internal`、`site.theme=light`,background 两项使用自定义 picsum 图)。
|
||||
- 外部引擎 URL 必须以 `http(s)://` 开头并包含 `{query}`,否则后端 400。
|
||||
- 引擎 id 只能用小写字母 / 数字 / `-` / `_`(1–30 位,`internal` 保留);内置六个(含 DuckDuckGo)用品牌图标,
|
||||
且**不能删除**(只能改名 / 改地址 / 停用)。不做老库兼容:库里的设置缺内置引擎时,保存会 400;
|
||||
后台新加的引擎 id 自动生成为 `custom-xxx`、图标用通用地球(组件库 EngineIcon 自动回落)。
|
||||
- 每个引擎可带 `icon`(png / svg 地址,`/` 开头的托管路径或 http(s) 链接):给了就用 `<img>` 渲染自己
|
||||
的图标,留空回落到品牌图标 / 地球;SVG 走 img 是静态的,不执行脚本。
|
||||
- `background.light/dark` 为空时用内置 `stash-web/src/assets/site-bg.jpg` / `site-bg-dark.jpg`;否则填托管目录 `public/` 路径或完整 URL。
|
||||
- 旧 settings JSON 缺失字段会自动回落默认值;多余旧字段在下次保存时被丢弃。
|
||||
|
||||
## 7. 当前功能 / 后台 IA
|
||||
|
||||
后台左侧导航:
|
||||
|
||||
- **分组管理**:根分组 / 子分组树、编辑、移动、排序、启停、删除;
|
||||
- **数据管理**:按主分组列出条目(含子分组),每条可选格式;
|
||||
- **首页管理**
|
||||
- 页头:开关、速度、滚动条目、品牌 / 图标 / 配色;
|
||||
- 背景管理:亮色 / 暗色背景 URL + 预览 + 恢复内置;
|
||||
- 搜索框:默认搜索项、外部引擎的名称 / URL / 启停、添加 / 删除自定义引擎、恢复默认;
|
||||
- 侧边栏:资料卡 / 本地服务 / 分组导航显隐;
|
||||
- 内容卡片:卡片 + 勾选分组 + 预览条数 + 排序;
|
||||
- **资料管理**:头像、名称、角色、简介、位置;
|
||||
- **系统管理**:改密码、下载备份、正文字体(先选内置 / 字体链接;链接模式下可存多套字体,启用其中一条)。
|
||||
|
||||
搜索框行为:
|
||||
|
||||
- 顶部悬浮标签显示当前引擎图标 + 名称,点击展开下拉;
|
||||
- 第一项固定「站内搜索」,分隔线后是启用的外部引擎;
|
||||
- 站内模式:输入过滤本地内容;回车打开第一个匹配条目/分组;
|
||||
- 外部模式:输入**不再过滤**本地内容;回车用该引擎 URL 新标签页搜索;
|
||||
- 当前选择存在本机 localStorage(`stash-search-engine`),刷新后仍用它;引擎被停用 / 删除时回落到后台默认项。
|
||||
|
||||
主题:
|
||||
|
||||
- 深色背景来自 Alright 演示站,资源 `site-bg-dark.jpg`;
|
||||
- 明暗切换优先用 View Transition 整页快照交叉淡入(160ms),不支持时回退到 CSS 颜色过渡;
|
||||
- 首帧由 `index.html` 内联脚本按 localStorage / 系统偏好先上色;
|
||||
- 背景地址也缓存在 localStorage(`stash-background`),刷新首帧直接用它,设置回来再校准,不会先闪内置背景;
|
||||
- 页头图标显示**当前**实际明暗(深色月亮 / 浅色太阳),点它是切到另一侧;
|
||||
- 背景图两层(light/dark)交叉淡入。
|
||||
|
||||
## 8. HTTP API(当前保留)
|
||||
|
||||
```text
|
||||
GET /api/health
|
||||
GET /api/projects GET /api/projects/events (SSE)
|
||||
GET /api/catalog2 分组树 + 卡片
|
||||
GET /api/catalog/events SSE:catalog 变化
|
||||
GET /api/settings
|
||||
PATCH /api/settings 需登录,{version,settings}
|
||||
GET /api/fonts font/ 目录里的字体清单(公开读)
|
||||
POST /api/nodes 需登录
|
||||
PATCH /api/nodes/{key} 需登录
|
||||
DELETE /api/nodes/{key} 需登录
|
||||
POST /api/cards 需登录
|
||||
PATCH /api/cards/{id} 需登录
|
||||
DELETE /api/cards/{id} 需登录
|
||||
PUT /api/cards/{id}/groups 需登录
|
||||
GET /api/items/{key}
|
||||
POST /api/items 需登录
|
||||
PATCH /api/items/{key} 需登录
|
||||
DELETE /api/items/{key} 需登录
|
||||
POST /api/items/{key}/view
|
||||
POST /api/items/{key}/like
|
||||
GET /api/backup 需登录
|
||||
GET /api/auth/session
|
||||
POST /api/auth/setup / login / logout
|
||||
PATCH /api/auth/password
|
||||
```
|
||||
|
||||
已删除:`/api/catalog`、`/api/categories/*`、旧 `/api/groups/{id}`。
|
||||
|
||||
## 9. 组件库改动(stash-ui)
|
||||
|
||||
- `SearchField` 新增可选 `suffix` 与 `leading` 插槽;
|
||||
- `Input` 新增 `leading` ReactNode,优先于 `icon`;
|
||||
- 新增 `EngineIcon`(`internal/google/bing/baidu/sogou/360`),图标路径来自参考稿;`18px` 默认、`14px` 用于顶部小标签;
|
||||
- 构建产物已更新,`stash-web` 通过 file 依赖读取;改库后必须重建 dist,否则主项目构建仍用旧类型/资源。
|
||||
|
||||
## 10. 已知限制 / 坑
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 |
|
||||
| 搜索引擎 | 内置六个(含 DuckDuckGo,有品牌图标,不能删只能停用)+ 后台添加的自定义引擎(最多 10 个,通用地球图标) |
|
||||
| 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 |
|
||||
| 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 |
|
||||
| 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 |
|
||||
| 备份恢复 | 只有 `/api/backup` 下载;恢复需停机替换库文件 |
|
||||
| 旧接口 | 已删;不要再按旧文档调用 `/api/catalog` 或 categories |
|
||||
| 双份示例 | `public/git_helper/` 是 Rust 托管示例;`stash-web/public/git_helper/` 是 Vite dev 示例,**两份都保留** |
|
||||
| exe 占用 | 重启/构建前先杀 stash.exe,否则 `cargo build` 拒绝访问 |
|
||||
| 编码 | 源码 UTF-8;PowerShell 控制台可能显示乱码,以 read 工具为准,别用 Set-Content 改中文源码 |
|
||||
|
||||
## 11. 最近提交(主项目,倒序)
|
||||
|
||||
```text
|
||||
1d1ece6 fix(group): 分组面包屑保留详情页外框
|
||||
9d1e5dc fix(group): 分组页面包屑与详情页统一
|
||||
4862f8f fix(search): 外部引擎模式不再过滤本地内容
|
||||
aa6f341 feat(search): 按参考稿重做搜索框并支持后台配置
|
||||
5ae2088 style(search): 引擎图标恢复 18px
|
||||
200908c style(search): 引擎图标统一使用组件库图标
|
||||
56f362f feat(search): 点击放大镜切换搜索引擎
|
||||
8c1c658 feat(search): 效果五快捷栏加入本地搜索
|
||||
038b7ec feat(search): 搜索框支持切换搜索引擎
|
||||
a5d7859 fix(theme): 用整页快照过渡消除明暗割裂
|
||||
5439ebf feat(admin): 增加背景管理
|
||||
b9005dd style(theme): 暗色使用 Alright 同款深色背景
|
||||
```
|
||||
|
||||
组件库最近:
|
||||
|
||||
```text
|
||||
994436a feat(icons): 增加搜索引擎图标
|
||||
46829cd feat(input): 增加左侧 leading 插槽
|
||||
b97c77e feat(search): SearchField 增加 suffix 插槽
|
||||
```
|
||||
|
||||
## 12. 新会话建议顺序
|
||||
|
||||
1. 打开 <http://127.0.0.1:8080> 与 `/admin` 冒烟;确认 8080 在跑(当前 PID 会变,不必依赖 PID)。
|
||||
2. 改前端:`cd stash-web && npm run build` → 杀 stash → `cargo build` → 启动。
|
||||
3. 改组件库:先 `cd stash-ui && npm run build`,再构建 `stash-web`,最后 Rust。
|
||||
4. 改设置结构:同时改 `src/db/settings.rs`、`stash-web/src/settings.ts`、后台面板和默认值;旧 JSON 由 serde 默认值兜底。
|
||||
5. 改数据库结构:升 `SCHEMA_VERSION` 并补迁移与迁移测试;先备份 `target/debug/data/stash.db`。
|
||||
6. 视觉改动用 Playwright 截图到 `stash-ui/shots/` 验证;脚本在 `stash-ui/.ref/`(不提交)。
|
||||
7. 收尾跑 `cargo test`(52 条)和 `npm run build`,确认工作区干净。
|
||||
@@ -1,151 +1,444 @@
|
||||
# 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 图标名)、排序权重、启停、删除;
|
||||
- **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除;
|
||||
- **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示);
|
||||
- **资料管理**与**系统管理**(改密码、下载整库备份;系统管理里还能设正文字体:内置文楷 / 字体链接二选一,链接模式下可存多套字体(`font/` 目录里的文件或外部链接)并随时启用其中一套);
|
||||
- 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效;
|
||||
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
|
||||
- `/admin` 的「系统管理」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
||||
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
|
||||
- 读接口公开(本地工具可只读使用),写接口(分组、条目、卡片、设置、改密)要求登录并校验同源 `Origin`;
|
||||
- 明暗配色:站点默认值在「页头 → 配色」(跟随系统 / 浅色 / 深色),前台页头的月亮按钮写的是**本机偏好**(localStorage),不需要登录;
|
||||
- 完整接口清单可以直接开站内页面 `/interface` 查看;
|
||||
- 忘记密码:删除 `admin` 表里的那一行(或整个库)后重启,会回到「设置密码」;
|
||||
- 方案与后续阶段见 [`docs/ADMIN-PLAN.md`](docs/ADMIN-PLAN.md)。
|
||||
|
||||
## 工作方式
|
||||
|
||||
请求按下面的顺序命中:
|
||||
|
||||
| 路径 | 处理方式 |
|
||||
| --- | --- |
|
||||
| `/api/*` | JSON / SSE 接口 |
|
||||
| `/`、`/index.html` | 内嵌前端的 `index.html`(`no-cache`) |
|
||||
| `/c/**`、`/g/**` | 前端路由地址(分组页 / 条目详情),没有实体文件时同样返回 `index.html` |
|
||||
| `/admin`、`/login`、`/interface` | 固定页面地址,同样返回 `index.html` |
|
||||
| `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) |
|
||||
| `/fonts/**` | 程序同级 `font/` 目录里的字体文件(后台字体设置用,`no-cache`) |
|
||||
| `/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,渲染层只支持正文实际用到的子集:
|
||||
|
||||
| 写法 | 效果 |
|
||||
| --- | --- |
|
||||
| 空行分段 | 一个段落 |
|
||||
| `## 小标题` | 正文小标题(`#`~`######` 都渲染成同级) |
|
||||
| 单独一行 `` | 配图 |
|
||||
| `**粗体**`、`` `代码` ``、`[文字](链接)` | 行内格式 |
|
||||
|
||||
列表、表格、引用块暂不支持;需要完整语法时把 [`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 +448,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 +474,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/fonts`(`font/` 目录里的字体清单)、`GET /api/backup` |
|
||||
| 认证 | `GET /api/auth/session`、`POST /api/auth/setup`、`POST /api/auth/login`、`POST /api/auth/logout`、`PATCH /api/auth/password` |
|
||||
|
||||
写接口(POST / PATCH / PUT / DELETE)都要求登录会话,并校验请求的 `Origin` 与本站同源。
|
||||
|
||||
## 测试
|
||||
|
||||
运行核心库测试:
|
||||
|
||||
```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),换设备不会跟随;站点默认值在后台改。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
});
|
||||
})();
|
||||
@@ -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:修改密码(成功后所有会话失效)、下载备份;
|
||||
- **未做**:恢复/导入(需要停机替换文件或在线校验导入流程)、回收站/软删除、图片上传、拖拽排序(当前用上移/下移按钮替代)。
|
||||
|
||||
|
||||
|
||||
+228
-1
@@ -1,9 +1,24 @@
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::error::StashError;
|
||||
|
||||
/// 数据目录名:与二进制同级,存放可被用户直接修改的内容库。
|
||||
pub const DATA_DIR_NAME: &str = "data";
|
||||
/// 字体目录名:与二进制同级,后台「系统管理 → 字体」只从这个目录列字体。
|
||||
pub const FONT_DIR_NAME: &str = "font";
|
||||
/// 内容库文件名:固定为这个名字,一个实例一个库。
|
||||
pub const DB_FILE_NAME: &str = "stash.db";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// 静态页面托管根目录(存放一个或多个前端项目)
|
||||
pub root: PathBuf,
|
||||
/// 数据目录:存放内容库与将来的备份 / 导出文件
|
||||
pub data_dir: PathBuf,
|
||||
/// 字体目录:后台字体下拉只列这里的文件,通过 `/fonts/<文件名>` 对外提供
|
||||
pub font_dir: PathBuf,
|
||||
/// 内容库路径,固定为 `data/stash.db`
|
||||
pub db_path: PathBuf,
|
||||
/// 监听端口
|
||||
pub port: u16,
|
||||
/// 监听地址,默认 127.0.0.1
|
||||
@@ -16,8 +31,14 @@ 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,
|
||||
font_dir: PathBuf::from(FONT_DIR_NAME),
|
||||
port: 8080,
|
||||
host: "127.0.0.1".to_string(),
|
||||
directory_listing: true,
|
||||
@@ -25,3 +46,209 @@ 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)
|
||||
}
|
||||
|
||||
/// 字体目录的默认位置:**二进制所在目录**下的 `font/`,理由同 `data/`。
|
||||
pub fn default_font_dir() -> PathBuf {
|
||||
default_font_dir_from(std::env::current_exe().ok().as_deref())
|
||||
}
|
||||
|
||||
fn default_font_dir_from(exe: Option<&Path>) -> PathBuf {
|
||||
match exe.and_then(Path::parent) {
|
||||
Some(dir) => dir.join(FONT_DIR_NAME),
|
||||
None => PathBuf::from(FONT_DIR_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验内容库没有落在静态托管目录里。
|
||||
///
|
||||
/// `ServeDir` 会把 `--root` 下的所有文件当静态资源对外提供,
|
||||
/// 一旦库文件在其中,任何人都能直接下载整个数据库,属于必须拦截的配置错误。
|
||||
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 font_dir_follows_the_binary_directory() {
|
||||
assert_eq!(
|
||||
default_font_dir_from(Some(Path::new(r"D:\apps\stash\stash.exe"))),
|
||||
PathBuf::from(r"D:\apps\stash\font")
|
||||
);
|
||||
assert_eq!(default_font_dir_from(None), PathBuf::from(FONT_DIR_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_path_is_fixed_inside_the_data_dir() {
|
||||
assert_eq!(
|
||||
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
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(), "重复删除应失败");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 必须变化");
|
||||
}
|
||||
}
|
||||
@@ -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 §ion.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, §ion.view, index, entry, &mut report)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 海报与图文没有分组,直接挂在分区上(node_key 就是分区自己)
|
||||
for (index, entry) in section.entries.iter().enumerate() {
|
||||
insert_entry(&tx, §ion.key, §ion.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>,
|
||||
}
|
||||
@@ -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\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\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\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\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\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\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\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\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\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\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\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\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
//! 站点级设置(页头滚动信息、侧栏顺序/显隐)。
|
||||
//!
|
||||
//! 存 `setting` 表:`key = 'site'`,`value` 是一段 JSON,`version` 做乐观锁。
|
||||
//! 读的时候任何字段缺失都回落默认值,坏 JSON 也回落默认值——
|
||||
//! 设置坏掉不应该让首页白屏。
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{db::map_error, error::StashError};
|
||||
|
||||
pub const SETTINGS_KEY: &str = "site";
|
||||
pub const MAX_HEADER_ITEMS: usize = 8;
|
||||
pub const MIN_SPEED: u32 = 8;
|
||||
pub const MAX_SPEED: u32 = 120;
|
||||
pub const MAX_FONT_LINKS: usize = 20;
|
||||
/// 内置搜索引擎:有品牌图标,后台只能改名称 / 地址 / 启停,不能删除。
|
||||
pub const BUILTIN_ENGINE_IDS: [&str; 6] =
|
||||
["google", "bing", "baidu", "sogou", "360", "duckduckgo"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Tone {
|
||||
#[default]
|
||||
Plain,
|
||||
Primary,
|
||||
Success,
|
||||
Neutral,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct HeaderItem {
|
||||
/// 文本,支持占位符 `{count}` / `{project}` / `{status}`
|
||||
pub text: String,
|
||||
/// 前置小标签,如「本地」「状态」
|
||||
pub tag: Option<String>,
|
||||
pub tone: Tone,
|
||||
}
|
||||
|
||||
impl Default for HeaderItem {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
text: String::new(),
|
||||
tag: None,
|
||||
tone: Tone::Plain,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct HeaderSettings {
|
||||
pub enabled: bool,
|
||||
/// 滚动一轮的秒数
|
||||
pub speed: u32,
|
||||
/// 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽
|
||||
pub gap: u32,
|
||||
pub items: Vec<HeaderItem>,
|
||||
}
|
||||
|
||||
impl Default for HeaderSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
speed: 24,
|
||||
gap: 26,
|
||||
items: vec![
|
||||
HeaderItem {
|
||||
text: "共 {count} 个本地应用".into(),
|
||||
tag: None,
|
||||
tone: Tone::Plain,
|
||||
},
|
||||
HeaderItem {
|
||||
text: "{project}".into(),
|
||||
tag: Some("本地".into()),
|
||||
tone: Tone::Primary,
|
||||
},
|
||||
HeaderItem {
|
||||
text: "{status}".into(),
|
||||
tag: Some("状态".into()),
|
||||
tone: Tone::Success,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for SidebarSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_profile: true,
|
||||
show_service: true,
|
||||
show_nav: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct SidebarSettings {
|
||||
/// 是否显示「本地管理员」资料卡
|
||||
#[serde(default = "default_true")]
|
||||
pub show_profile: bool,
|
||||
/// 是否显示「本地服务」面板
|
||||
#[serde(default = "default_true")]
|
||||
pub show_service: bool,
|
||||
/// 是否显示「分组导航」卡片(首页 / 分组树)
|
||||
#[serde(default = "default_true")]
|
||||
pub show_nav: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct BackgroundSettings {
|
||||
/// 亮色背景图地址;空表示用内置图
|
||||
pub light: String,
|
||||
/// 暗色背景图地址;空表示用内置图
|
||||
pub dark: String,
|
||||
}
|
||||
|
||||
/// 正文字体来源:内置霞鹜文楷,或用户存的字体链接。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FontSource {
|
||||
#[default]
|
||||
Builtin,
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// 一条存下来的字体配置:名字只用于后台下拉,url 才是真正加载的东西。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct FontLink {
|
||||
/// 唯一编码,由前端生成(删除 / 重排都不影响当前选择)
|
||||
pub key: String,
|
||||
/// 后台下拉里显示的名字
|
||||
pub name: String,
|
||||
/// 字体文件或 CSS 链接:`/fonts/xxx` 或 `http(s)://…`
|
||||
pub url: String,
|
||||
/// 字体族名:链接到 CSS 时填 CSS 里声明的名字;字体文件留空则用 `Stash Font`
|
||||
pub family: String,
|
||||
}
|
||||
|
||||
/// 正文字体设置:先选来源(内置 / 字体链接),选「字体链接」时用 `current` 指向启用的那条。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct FontSettings {
|
||||
/// 字体来源:内置文楷,或下面保存的字体链接
|
||||
pub source: FontSource,
|
||||
/// 当前启用的 `FontLink.key`;为空时用第一条
|
||||
pub current: String,
|
||||
/// 存下来的字体:常用字体放这里,切换时不用重新填
|
||||
pub items: Vec<FontLink>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct SearchEngineSettings {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// 搜索地址模板,用 `{query}` 表示关键词
|
||||
pub url: String,
|
||||
/// 自定义图标地址(png / svg);空表示用内置品牌图标或通用地球
|
||||
pub icon: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct SearchSettings {
|
||||
/// `internal` 或某个引擎 id
|
||||
pub default: String,
|
||||
pub engines: Vec<SearchEngineSettings>,
|
||||
}
|
||||
|
||||
impl Default for SearchSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default: "internal".into(),
|
||||
engines: vec![
|
||||
SearchEngineSettings {
|
||||
id: "google".into(),
|
||||
name: "Google".into(),
|
||||
url: "https://www.google.com/search?q={query}".into(),
|
||||
icon: String::new(),
|
||||
enabled: true,
|
||||
},
|
||||
SearchEngineSettings {
|
||||
id: "bing".into(),
|
||||
name: "Bing".into(),
|
||||
url: "https://www.bing.com/search?q={query}".into(),
|
||||
icon: String::new(),
|
||||
enabled: true,
|
||||
},
|
||||
SearchEngineSettings {
|
||||
id: "baidu".into(),
|
||||
name: "百度".into(),
|
||||
url: "https://www.baidu.com/s?wd={query}".into(),
|
||||
icon: String::new(),
|
||||
enabled: true,
|
||||
},
|
||||
SearchEngineSettings {
|
||||
id: "sogou".into(),
|
||||
name: "搜狗".into(),
|
||||
url: "https://www.sogou.com/web?query={query}".into(),
|
||||
icon: String::new(),
|
||||
enabled: true,
|
||||
},
|
||||
SearchEngineSettings {
|
||||
id: "360".into(),
|
||||
name: "360".into(),
|
||||
url: "https://www.so.com/s?q={query}".into(),
|
||||
icon: String::new(),
|
||||
enabled: true,
|
||||
},
|
||||
SearchEngineSettings {
|
||||
id: "duckduckgo".into(),
|
||||
name: "DuckDuckGo".into(),
|
||||
url: "https://duckduckgo.com/?q={query}".into(),
|
||||
icon: String::new(),
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct ProfileSettings {
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub description: String,
|
||||
pub location: String,
|
||||
/// 头像地址;空字符串表示用首字占位
|
||||
pub avatar: String,
|
||||
}
|
||||
|
||||
impl Default for ProfileSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "本地管理员".into(),
|
||||
role: "管理员".into(),
|
||||
description: "本地静态站点托管与预览,所有项目都运行在这台机器上。".into(),
|
||||
location: "IP属地:局域网".into(),
|
||||
avatar: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct BrandSettings {
|
||||
/// 品牌文字(页头左侧,可留空只显示 logo)
|
||||
pub name: String,
|
||||
/// 没有 logo 图片时,圆形标记里显示的首字
|
||||
pub mark: String,
|
||||
/// logo 图片地址;空字符串表示用首字
|
||||
pub logo: String,
|
||||
}
|
||||
|
||||
impl Default for BrandSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "STASH".into(),
|
||||
mark: "S".into(),
|
||||
logo: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct ActionSettings {
|
||||
/// 明暗切换图标
|
||||
#[serde(default = "default_true")]
|
||||
pub theme: bool,
|
||||
/// 刷新图标(重扫本地项目并重拉内容库)
|
||||
pub refresh: bool,
|
||||
/// 站点设置图标(仅登录后出现)
|
||||
pub settings: bool,
|
||||
/// 登录 / 退出登录
|
||||
pub session: bool,
|
||||
}
|
||||
|
||||
impl Default for ActionSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: true,
|
||||
refresh: true,
|
||||
settings: true,
|
||||
session: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 配色:浅色 / 深色 / 跟随系统。
|
||||
/// 落到 `<html data-scheme>` 上,深色由组件库的令牌块接管,页面不用改组件。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThemeSetting {
|
||||
Light,
|
||||
Dark,
|
||||
#[default]
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct SiteMetaSettings {
|
||||
/// 浏览器标签标题
|
||||
pub title: String,
|
||||
/// favicon 地址;空字符串表示用内置图标
|
||||
pub favicon: String,
|
||||
/// 配色
|
||||
pub theme: ThemeSetting,
|
||||
pub brand: BrandSettings,
|
||||
pub actions: ActionSettings,
|
||||
}
|
||||
|
||||
impl Default for SiteMetaSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: "Stash · 本地应用工作区".into(),
|
||||
favicon: String::new(),
|
||||
theme: ThemeSetting::Auto,
|
||||
brand: BrandSettings::default(),
|
||||
actions: ActionSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
pub header: HeaderSettings,
|
||||
pub sidebar: SidebarSettings,
|
||||
pub background: BackgroundSettings,
|
||||
pub font: FontSettings,
|
||||
pub search: SearchSettings,
|
||||
pub profile: ProfileSettings,
|
||||
pub site: SiteMetaSettings,
|
||||
}
|
||||
|
||||
/// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SettingsDocument {
|
||||
pub version: i64,
|
||||
pub settings: Settings,
|
||||
}
|
||||
|
||||
/// 引擎 id:小写字母 / 数字 / `-` / `_`,1–30 位;`internal` 是站内搜索的保留 id。
|
||||
fn valid_engine_id(id: &str) -> bool {
|
||||
let count = id.chars().count();
|
||||
if count == 0 || count > 30 || id == "internal" {
|
||||
return false;
|
||||
}
|
||||
id.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// 校验:条数、长度、速度范围。
|
||||
pub fn validate(settings: &Settings) -> Result<(), StashError> {
|
||||
if settings.header.items.is_empty() {
|
||||
return Err(StashError::InvalidDirectory("页头至少保留一条信息".into()));
|
||||
}
|
||||
if settings.header.items.len() > MAX_HEADER_ITEMS {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"页头最多 {MAX_HEADER_ITEMS} 条信息"
|
||||
)));
|
||||
}
|
||||
if !(MIN_SPEED..=MAX_SPEED).contains(&settings.header.speed) {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"滚动速度需在 {MIN_SPEED}–{MAX_SPEED} 秒之间"
|
||||
)));
|
||||
}
|
||||
if settings.header.gap > 120 {
|
||||
return Err(StashError::InvalidDirectory("信息间距最多 120".into()));
|
||||
}
|
||||
for item in &settings.header.items {
|
||||
let text = item.text.trim();
|
||||
if text.is_empty() {
|
||||
return Err(StashError::InvalidDirectory("信息文本不能为空".into()));
|
||||
}
|
||||
if text.chars().count() > 60 {
|
||||
return Err(StashError::InvalidDirectory("单条信息最多 60 个字".into()));
|
||||
}
|
||||
if let Some(tag) = &item.tag {
|
||||
if tag.chars().count() > 8 {
|
||||
return Err(StashError::InvalidDirectory("标签最多 8 个字".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if settings.profile.name.chars().count() > 40 {
|
||||
return Err(StashError::InvalidDirectory("名称最多 40 个字".into()));
|
||||
}
|
||||
if settings.profile.role.chars().count() > 20 {
|
||||
return Err(StashError::InvalidDirectory("角色最多 20 个字".into()));
|
||||
}
|
||||
if settings.profile.description.chars().count() > 200 {
|
||||
return Err(StashError::InvalidDirectory("描述最多 200 个字".into()));
|
||||
}
|
||||
if settings.profile.location.chars().count() > 40 {
|
||||
return Err(StashError::InvalidDirectory("位置最多 40 个字".into()));
|
||||
}
|
||||
if settings.background.light.chars().count() > 500 {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"亮色背景地址最多 500 个字符".into(),
|
||||
));
|
||||
}
|
||||
if settings.background.dark.chars().count() > 500 {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"暗色背景地址最多 500 个字符".into(),
|
||||
));
|
||||
}
|
||||
if settings.font.items.len() > MAX_FONT_LINKS {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"字体最多保存 {MAX_FONT_LINKS} 条"
|
||||
)));
|
||||
}
|
||||
let mut font_keys = std::collections::HashSet::new();
|
||||
for link in &settings.font.items {
|
||||
let key = link.key.trim();
|
||||
if key.is_empty() || key.chars().count() > 60 {
|
||||
return Err(StashError::InvalidDirectory("字体编码需为 1–60 个字符".into()));
|
||||
}
|
||||
if !font_keys.insert(key) {
|
||||
return Err(StashError::InvalidDirectory("字体编码不能重复".into()));
|
||||
}
|
||||
let name = link.name.trim();
|
||||
if name.is_empty() || name.chars().count() > 60 {
|
||||
return Err(StashError::InvalidDirectory("字体名称需为 1–60 个字".into()));
|
||||
}
|
||||
let url = link.url.trim();
|
||||
if url.chars().count() > 500 {
|
||||
return Err(StashError::InvalidDirectory("字体链接最多 500 个字符".into()));
|
||||
}
|
||||
if url.is_empty()
|
||||
|| !(url.starts_with("http://")
|
||||
|| url.starts_with("https://")
|
||||
|| url.starts_with("/fonts/"))
|
||||
{
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"字体链接需以 http(s):// 或 /fonts/ 开头".into(),
|
||||
));
|
||||
}
|
||||
if link.family.chars().count() > 100 {
|
||||
return Err(StashError::InvalidDirectory("字体族名最多 100 个字符".into()));
|
||||
}
|
||||
}
|
||||
if settings.font.source == FontSource::Custom {
|
||||
let current = settings.font.current.trim();
|
||||
if !current.is_empty() && !font_keys.contains(current) {
|
||||
return Err(StashError::InvalidDirectory("当前启用的字体不存在".into()));
|
||||
}
|
||||
}
|
||||
if settings.search.engines.len() > 10 {
|
||||
return Err(StashError::InvalidDirectory("搜索引擎最多 10 个".into()));
|
||||
}
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for engine in &settings.search.engines {
|
||||
let id = engine.id.trim();
|
||||
if !valid_engine_id(id) {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"搜索引擎标识只能用小写字母 / 数字 / - / _(1–30 位,internal 为保留字)".into(),
|
||||
));
|
||||
}
|
||||
if !ids.insert(id) {
|
||||
return Err(StashError::InvalidDirectory("搜索引擎标识不能重复".into()));
|
||||
}
|
||||
let name = engine.name.trim();
|
||||
if name.is_empty() || name.chars().count() > 20 {
|
||||
return Err(StashError::InvalidDirectory("搜索引擎名称需为 1–20 个字".into()));
|
||||
}
|
||||
let url = engine.url.trim();
|
||||
if url.chars().count() > 500
|
||||
|| !(url.starts_with("http://") || url.starts_with("https://"))
|
||||
|| !url.contains("{query}")
|
||||
{
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"搜索地址需以 http(s):// 开头并包含 {query}".into(),
|
||||
));
|
||||
}
|
||||
let icon = engine.icon.trim();
|
||||
if icon.chars().count() > 500 {
|
||||
return Err(StashError::InvalidDirectory("引擎图标地址最多 500 个字符".into()));
|
||||
}
|
||||
if !icon.is_empty()
|
||||
&& !(icon.starts_with("http://")
|
||||
|| icon.starts_with("https://")
|
||||
|| icon.starts_with('/'))
|
||||
{
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"引擎图标需为空、/ 开头的托管路径或 http(s):// 链接".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
for builtin in BUILTIN_ENGINE_IDS {
|
||||
if !ids.contains(builtin) {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"内置搜索引擎不能删除(缺少 {builtin})"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let default = settings.search.default.trim();
|
||||
if default != "internal" {
|
||||
let engine = settings
|
||||
.search
|
||||
.engines
|
||||
.iter()
|
||||
.find(|engine| engine.id == default)
|
||||
.ok_or_else(|| StashError::InvalidDirectory("默认搜索引擎不存在".into()))?;
|
||||
if !engine.enabled {
|
||||
return Err(StashError::InvalidDirectory("默认搜索引擎未启用".into()));
|
||||
}
|
||||
}
|
||||
if settings.site.title.chars().count() > 120 {
|
||||
return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into()));
|
||||
}
|
||||
if settings.site.favicon.chars().count() > 500 {
|
||||
return Err(StashError::InvalidDirectory("favicon 地址最多 500 个字符".into()));
|
||||
}
|
||||
if settings.site.brand.name.chars().count() > 20 {
|
||||
return Err(StashError::InvalidDirectory("品牌文字最多 20 个字".into()));
|
||||
}
|
||||
if settings.site.brand.mark.chars().count() > 4 {
|
||||
return Err(StashError::InvalidDirectory("logo 标记最多 4 个字".into()));
|
||||
}
|
||||
if settings.site.brand.logo.chars().count() > 500 {
|
||||
return Err(StashError::InvalidDirectory("logo 地址最多 500 个字符".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 读设置;没有记录时返回默认值与 version = 0。
|
||||
pub fn load(conn: &Connection) -> Result<SettingsDocument, StashError> {
|
||||
let row: Option<(String, i64)> = conn
|
||||
.query_row(
|
||||
"SELECT value, version FROM setting WHERE key = ?1",
|
||||
[SETTINGS_KEY],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let Some((raw, version)) = row else {
|
||||
return Ok(SettingsDocument {
|
||||
version: 0,
|
||||
settings: Settings::default(),
|
||||
});
|
||||
};
|
||||
|
||||
let settings = serde_json::from_str::<Settings>(&raw).unwrap_or_else(|error| {
|
||||
tracing::warn!("设置 JSON 解析失败,使用默认值: {error}");
|
||||
Settings::default()
|
||||
});
|
||||
|
||||
Ok(SettingsDocument { version, settings })
|
||||
}
|
||||
|
||||
/// 保存设置;`expected_version` 与库里不一致时返回冲突。
|
||||
pub fn save(
|
||||
conn: &mut Connection,
|
||||
settings: Settings,
|
||||
expected_version: i64,
|
||||
) -> Result<SettingsDocument, StashError> {
|
||||
validate(&settings)?;
|
||||
let value = serde_json::to_string(&settings)
|
||||
.map_err(|error| StashError::Database(format!("设置序列化失败: {error}")))?;
|
||||
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
let updated = if expected_version == 0 {
|
||||
tx.execute(
|
||||
"INSERT INTO setting (key, value, version, updated_at) VALUES (?1, ?2, 1, ?3)
|
||||
ON CONFLICT(key) DO NOTHING",
|
||||
params![SETTINGS_KEY, value, now()],
|
||||
)
|
||||
.map_err(map_error)?
|
||||
} else {
|
||||
tx.execute(
|
||||
"UPDATE setting SET value = ?1, version = version + 1, updated_at = ?2
|
||||
WHERE key = ?3 AND version = ?4",
|
||||
params![value, now(), SETTINGS_KEY, expected_version],
|
||||
)
|
||||
.map_err(map_error)?
|
||||
};
|
||||
|
||||
if updated == 0 {
|
||||
return Err(StashError::Conflict(
|
||||
"设置已被其它页面修改,请刷新后重试".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let (version,): (i64,) = tx
|
||||
.query_row(
|
||||
"SELECT version FROM setting WHERE key = ?1",
|
||||
[SETTINGS_KEY],
|
||||
|row| Ok((row.get(0)?,)),
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
tx.commit().map_err(map_error)?;
|
||||
|
||||
Ok(SettingsDocument { version, settings })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE setting (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("应能建表");
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_stable_and_valid() {
|
||||
let settings = Settings::default();
|
||||
assert!(settings.header.enabled);
|
||||
assert_eq!(settings.header.items.len(), 3);
|
||||
assert!(validate(&settings).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_and_version_bump() {
|
||||
let mut conn = db();
|
||||
let saved = save(&mut conn, Settings::default(), 0).expect("首次保存应成功");
|
||||
assert_eq!(saved.version, 1);
|
||||
|
||||
let loaded = load(&conn).expect("应能读");
|
||||
assert_eq!(loaded, saved);
|
||||
|
||||
let mut next = saved.settings.clone();
|
||||
next.header.speed = 40;
|
||||
let saved2 = save(&mut conn, next, saved.version).expect("更新应成功");
|
||||
assert_eq!(saved2.version, 2);
|
||||
assert_eq!(saved2.settings.header.speed, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_version_conflicts() {
|
||||
let mut conn = db();
|
||||
let saved = save(&mut conn, Settings::default(), 0).expect("首次保存应成功");
|
||||
let error = save(&mut conn, Settings::default(), saved.version - 1).expect_err("应冲突");
|
||||
assert!(matches!(error, StashError::Conflict(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_search_engines_use_safe_ids() {
|
||||
let mut conn = db();
|
||||
let mut settings = Settings::default();
|
||||
settings.search.engines.push(SearchEngineSettings {
|
||||
id: "my-engine_1".into(),
|
||||
name: "自定义".into(),
|
||||
url: "https://example.com/search?q={query}".into(),
|
||||
icon: "/favicon.svg".into(),
|
||||
enabled: true,
|
||||
});
|
||||
assert!(save(&mut conn, settings, 0).is_ok(), "合法自定义 id 应能保存");
|
||||
|
||||
for bad_id in ["Bad Id", "internal", "", "引擎"] {
|
||||
let mut bad = Settings::default();
|
||||
bad.search.engines.push(SearchEngineSettings {
|
||||
id: bad_id.into(),
|
||||
name: "坏".into(),
|
||||
url: "https://example.com/?q={query}".into(),
|
||||
icon: String::new(),
|
||||
enabled: true,
|
||||
});
|
||||
assert!(save(&mut conn, bad, 0).is_err(), "{bad_id} 应被拒绝");
|
||||
}
|
||||
|
||||
let mut bad_icon = Settings::default();
|
||||
bad_icon.search.engines.push(SearchEngineSettings {
|
||||
id: "custom-x".into(),
|
||||
name: "坏图标".into(),
|
||||
url: "https://example.com/?q={query}".into(),
|
||||
icon: "javascript:alert(1)".into(),
|
||||
enabled: true,
|
||||
});
|
||||
assert!(save(&mut conn, bad_icon, 0).is_err(), "非法图标地址应被拒绝");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_engines_cannot_be_removed() {
|
||||
let mut conn = db();
|
||||
let mut missing = Settings::default();
|
||||
missing.search.engines.retain(|engine| engine.id != "duckduckgo");
|
||||
assert!(save(&mut conn, missing, 0).is_err(), "缺内置引擎应被拒绝");
|
||||
|
||||
// 改名 / 停用不算删除,允许
|
||||
let mut renamed = Settings::default();
|
||||
renamed.search.engines[0].name = "改过的名字".into();
|
||||
renamed.search.engines[0].enabled = false;
|
||||
assert!(save(&mut conn, renamed, 0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_settings_are_rejected() {
|
||||
let mut conn = db();
|
||||
let mut bad = Settings::default();
|
||||
bad.header.speed = 1;
|
||||
assert!(save(&mut conn, bad, 0).is_err());
|
||||
|
||||
let mut empty = Settings::default();
|
||||
empty.header.items.clear();
|
||||
assert!(save(&mut conn, empty, 0).is_err());
|
||||
|
||||
let mut bad_font = Settings::default();
|
||||
bad_font.font.items.push(FontLink {
|
||||
key: "font-1".into(),
|
||||
name: "测试".into(),
|
||||
url: "javascript:alert(1)".into(),
|
||||
family: String::new(),
|
||||
});
|
||||
assert!(save(&mut conn, bad_font, 0).is_err());
|
||||
|
||||
let mut ok_font = Settings::default();
|
||||
ok_font.font.items.push(FontLink {
|
||||
key: "font-1".into(),
|
||||
name: "测试".into(),
|
||||
url: "/fonts/x.woff2".into(),
|
||||
family: String::new(),
|
||||
});
|
||||
ok_font.font.source = FontSource::Custom;
|
||||
ok_font.font.current = "font-1".into();
|
||||
assert!(save(&mut conn, ok_font, 0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_json_falls_back_to_defaults() {
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"INSERT INTO setting (key, value, version, updated_at) VALUES (?1, '{bad json', 3, 0)",
|
||||
[SETTINGS_KEY],
|
||||
)
|
||||
.expect("应能写入");
|
||||
let loaded = load(&conn).expect("坏 JSON 不应报错");
|
||||
assert_eq!(loaded.version, 3);
|
||||
assert_eq!(loaded.settings, Settings::default());
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
};
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -7,17 +7,6 @@ pub struct AppProject {
|
||||
pub route_path: String,
|
||||
}
|
||||
|
||||
impl AppProject {
|
||||
pub fn initial(&self) -> String {
|
||||
self.name
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('A')
|
||||
.to_uppercase()
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan_projects(root: &Path) -> Vec<AppProject> {
|
||||
let mut projects = Vec::new();
|
||||
|
||||
|
||||
+41
-5
@@ -1,7 +1,11 @@
|
||||
use std::path::PathBuf;
|
||||
use clap::Parser;
|
||||
use colored::*;
|
||||
use stash::{Config, start};
|
||||
use stash::{
|
||||
Config,
|
||||
config::{default_data_dir, default_font_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,35 @@ 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);
|
||||
|
||||
// 字体目录:跟 data/ 一样放在二进制旁边,后台只从这里列字体
|
||||
let font_dir = default_font_dir();
|
||||
std::fs::create_dir_all(&font_dir)?;
|
||||
let font_dir = font_dir.canonicalize().unwrap_or(font_dir);
|
||||
let font_dir_display = display_path(&font_dir);
|
||||
|
||||
// 4. 构造配置并启动
|
||||
let config = Config {
|
||||
root: abs_root.clone(),
|
||||
data_dir: data_dir.clone(),
|
||||
font_dir,
|
||||
db_path: db_path.clone(),
|
||||
port: cli.port,
|
||||
host: cli.host,
|
||||
directory_listing: !cli.no_listing,
|
||||
@@ -86,20 +120,22 @@ 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!(" ➜ 字体目录: {}", font_dir_display.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());
|
||||
|
||||
+1080
-32
File diff suppressed because it is too large
Load Diff
+25
-28
@@ -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% 由库组件组装)。
|
||||
|
||||
+16
-2
@@ -4,8 +4,22 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>Stash · 本地应用工作区</title>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<script>
|
||||
// 首帧先按本机偏好上色,避免先浅色再切深色的闪动;站点默认值随后由设置接管。
|
||||
try {
|
||||
var saved = localStorage.getItem('stash-theme')
|
||||
var dark = saved ? saved === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches
|
||||
document.documentElement.dataset.scheme = dark ? 'dark' : 'light'
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<!-- 内置正文字体:霞鹜文楷;后台「系统管理 → 字体」可以换成 font 目录里的字体或外部链接 -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/style.min.css"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<title>Stash</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+24
@@ -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",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stash/ui": "file:../../stash-ui",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1"
|
||||
},
|
||||
|
||||
+1507
-776
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+19
-2
@@ -9,7 +9,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, {
|
||||
@@ -25,12 +24,30 @@ export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
|
||||
return Array.isArray(payload.projects) ? payload.projects : []
|
||||
}
|
||||
|
||||
export type ProjectEventsHandlers = {
|
||||
type ProjectEventsHandlers = {
|
||||
onProjects: (projects: Project[]) => void
|
||||
/** SSE 连接建立 / 断开时回调,浏览器的 EventSource 会自行重连。 */
|
||||
onConnectionChange?: (connected: boolean) => void
|
||||
}
|
||||
|
||||
export type FontFile = {
|
||||
name: string
|
||||
/** `/fonts/<文件名>`,直接当作字体文件地址用 */
|
||||
url: string
|
||||
}
|
||||
|
||||
/** 读取服务端 `font/` 目录里的字体文件(后台字体下拉用)。 */
|
||||
export async function fetchFonts(): Promise<FontFile[]> {
|
||||
try {
|
||||
const response = await fetch('/api/fonts', { headers: { Accept: 'application/json' } })
|
||||
if (!response.ok) return []
|
||||
const payload = (await response.json()) as { fonts?: FontFile[] }
|
||||
return Array.isArray(payload.fonts) ? payload.fonts : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅服务端的项目快照推送。
|
||||
* 浏览器不支持 SSE 时静默降级为空清理函数,页面仍可用手动刷新。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -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 })
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,124 @@
|
||||
import { Card, Space, Table, Tag, Text, Title } from '@stash/ui'
|
||||
|
||||
/**
|
||||
* 接口一览:把 `/api/*` 按用途分组列出来。
|
||||
*
|
||||
* 页面只做展示,不请求后端——清单跟 `src/server.rs` 的路由表人工对齐,
|
||||
* 改接口时顺手改这里(文件顶部有分组与权限说明)。
|
||||
*/
|
||||
type Endpoint = {
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
path: string
|
||||
summary: string
|
||||
/** 需要登录会话 */
|
||||
auth?: boolean
|
||||
}
|
||||
|
||||
const GROUPS: { title: string; desc: string; items: Endpoint[] }[] = [
|
||||
{
|
||||
title: '健康与项目',
|
||||
desc: '托管目录的扫描结果与实时推送。',
|
||||
items: [
|
||||
{ method: 'GET', path: '/api/health', summary: '存活检查,返回服务名与状态' },
|
||||
{ method: 'GET', path: '/api/projects', summary: '扫描托管目录(每次请求都重新扫描)' },
|
||||
{ method: 'GET', path: '/api/projects/events', summary: 'SSE:目录变化时推送最新项目列表' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '内容库',
|
||||
desc: '分组树 / 条目 / 卡片。写操作都需要登录会话与同源 Origin。',
|
||||
items: [
|
||||
{ method: 'GET', path: '/api/catalog2', summary: '分组树 + 卡片,前台与后台共用' },
|
||||
{ method: 'GET', path: '/api/catalog/events', summary: 'SSE:内容库变更时推送「该重拉」' },
|
||||
{ method: 'POST', path: '/api/nodes', summary: '新建根分组 / 子分组', auth: true },
|
||||
{ method: 'PATCH', path: '/api/nodes/{key}', summary: '改标题、所属、图标、排序、启停', auth: true },
|
||||
{ method: 'DELETE', path: '/api/nodes/{key}', summary: '删除分组(级联子分组与条目)', auth: true },
|
||||
{ method: 'GET', path: '/api/items/{key}', summary: '条目详情' },
|
||||
{ method: 'POST', path: '/api/items', summary: '新建条目', auth: true },
|
||||
{ method: 'PATCH', path: '/api/items/{key}', summary: '就地编辑(带 updatedAt 乐观锁)', auth: true },
|
||||
{ method: 'DELETE', path: '/api/items/{key}', summary: '删除条目', auth: true },
|
||||
{ method: 'POST', path: '/api/items/{key}/view', summary: '浏览数 +1' },
|
||||
{ method: 'POST', path: '/api/items/{key}/like', summary: '点赞 / 取消点赞' },
|
||||
{ method: 'POST', path: '/api/cards', summary: '新建首页卡片', auth: true },
|
||||
{ method: 'PATCH', path: '/api/cards/{id}', summary: '改卡片标题 / 顺序', auth: true },
|
||||
{ method: 'DELETE', path: '/api/cards/{id}', summary: '删除卡片', auth: true },
|
||||
{ method: 'PUT', path: '/api/cards/{id}/groups', summary: '整组替换卡片包含的分组与顺序', auth: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '站点设置',
|
||||
desc: '页头、侧边栏、卡片、资料、站点信息等整份 JSON。',
|
||||
items: [
|
||||
{ method: 'GET', path: '/api/settings', summary: '读取设置(含 version 乐观锁)' },
|
||||
{ method: 'PATCH', path: '/api/settings', summary: '整份保存设置', auth: true },
|
||||
{ method: 'GET', path: '/api/backup', summary: '下载 SQLite 快照', auth: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '认证',
|
||||
desc: '单管理员账号;会话 token 只存 sha256,Cookie 为 HttpOnly + SameSite=Strict。',
|
||||
items: [
|
||||
{ method: 'GET', path: '/api/auth/session', summary: '当前会话状态(是否登录、是否已设密码)' },
|
||||
{ method: 'POST', path: '/api/auth/setup', summary: '首次设置管理员密码(仅空库可用)' },
|
||||
{ method: 'POST', path: '/api/auth/login', summary: '登录;连续失败 5 次锁 60 秒' },
|
||||
{ method: 'POST', path: '/api/auth/logout', summary: '退出并清掉会话' },
|
||||
{ method: 'PATCH', path: '/api/auth/password', summary: '改密码(需当前密码)', auth: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const METHOD_TONE = {
|
||||
GET: 'neutral',
|
||||
POST: 'primary',
|
||||
PATCH: 'warning',
|
||||
PUT: 'warning',
|
||||
DELETE: 'danger',
|
||||
} as const
|
||||
|
||||
export function ApiDocs() {
|
||||
return (
|
||||
<div className="api-docs">
|
||||
<Space direction="column" gap="sm" align="stretch">
|
||||
<Title level={2}>接口一览</Title>
|
||||
<Text size="sm" tone="muted">
|
||||
说明:这是 Stash 自带的 HTTP 接口清单,全部挂在同一个端口上。读接口公开,写接口需要登录会话并校验同源
|
||||
Origin;带 <Tag tone="primary">需登录</Tag> 标记的需要登录。
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
{GROUPS.map((group) => (
|
||||
<Card key={group.title} padding="lg">
|
||||
<Space direction="column" gap="sm" align="stretch">
|
||||
<Space gap="sm" align="center">
|
||||
<Title level={4} as="h2">
|
||||
{group.title}
|
||||
</Title>
|
||||
<Text size="sm" tone="muted">
|
||||
{group.items.length} 个
|
||||
</Text>
|
||||
</Space>
|
||||
<Text size="sm" tone="muted">
|
||||
{group.desc}
|
||||
</Text>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={[
|
||||
{ key: 'method', title: '方法', width: 90 },
|
||||
{ key: 'path', title: '路径', width: 280 },
|
||||
{ key: 'summary', title: '说明' },
|
||||
{ key: 'auth', title: '权限', width: 100 },
|
||||
]}
|
||||
rows={group.items.map((item) => ({
|
||||
id: `${item.method} ${item.path}`,
|
||||
method: <Tag tone={METHOD_TONE[item.method]}>{item.method}</Tag>,
|
||||
path: <code className="api-path">{item.path}</code>,
|
||||
summary: item.summary,
|
||||
auth: item.auth === true ? <Tag tone="primary">需登录</Tag> : <Text tone="muted">公开</Text>,
|
||||
}))}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Button, FormField, Icon, Modal, Notice, PasswordInput, Space, Text } from '@stash/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { login, setup, type SessionState } from '../../auth'
|
||||
|
||||
/**
|
||||
* 登录弹窗:首次使用显示「设置密码 + 确认」,之后只显示密码。
|
||||
*
|
||||
* 成功后由父组件刷新会话状态;弹窗本身不跳转页面。
|
||||
*/
|
||||
type LoginDialogProps = {
|
||||
open: boolean
|
||||
hasAdmin: boolean
|
||||
onClose: () => void
|
||||
onSuccess: (session: SessionState) => void
|
||||
}
|
||||
|
||||
export function LoginDialog({ open, hasAdmin, onClose, onSuccess }: LoginDialogProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
// 每次打开重置,避免上次的错误/输入残留
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setPassword('')
|
||||
setConfirm('')
|
||||
setError('')
|
||||
setBusy(false)
|
||||
}, [open])
|
||||
|
||||
const isSetup = !hasAdmin
|
||||
|
||||
const submit = async () => {
|
||||
setError('')
|
||||
if (isSetup && password !== confirm) {
|
||||
setError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
const result = isSetup ? await setup(password) : await login(password)
|
||||
setBusy(false)
|
||||
|
||||
if (!result.ok) {
|
||||
setError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
onSuccess({
|
||||
authenticated: true,
|
||||
has_admin: true,
|
||||
username: 'admin',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isSetup ? '设置管理员密码' : '登录'}
|
||||
footer={
|
||||
<Space gap="sm" justify="end">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button loading={busy} icon={<Icon name="lock_line" />} onClick={submit}>
|
||||
{isSetup ? '设置并登录' : '登录'}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="column" gap="md" align="stretch">
|
||||
<Text size="sm" tone="muted">
|
||||
{isSetup
|
||||
? '首次使用:设置一个至少 8 位的密码,之后用它登录编辑。'
|
||||
: '登录后可以编辑页头滚动信息、侧栏顺序与显示。'}
|
||||
</Text>
|
||||
|
||||
{error !== '' && (
|
||||
<Notice tone="error" title="操作失败">
|
||||
{error}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<FormField label="密码" htmlFor="login-password" required>
|
||||
<PasswordInput
|
||||
id="login-password"
|
||||
placeholder="输入密码"
|
||||
autoComplete={isSetup ? 'new-password' : 'current-password'}
|
||||
value={password}
|
||||
autoFocus
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void submit()
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{isSetup && (
|
||||
<FormField label="确认密码" htmlFor="login-confirm" required>
|
||||
<PasswordInput
|
||||
id="login-confirm"
|
||||
placeholder="再输入一次"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(event) => setConfirm(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void submit()
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -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 级井号都渲染成同一级小标题,标题层级由版式决定)
|
||||
* - 空行分段
|
||||
* - 单独一行的 `` 作为配图
|
||||
* - 行内 `**粗体**`、`` `代码` ``、`[文字](链接)`
|
||||
*
|
||||
* 不支持列表、表格、引用块——这里渲染的是文章正文,不是通用 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,150 @@
|
||||
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
|
||||
/** 站点设置是否已加载;未加载时不渲染依赖配置的图标,避免先显示再消失 */
|
||||
ready?: boolean
|
||||
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */
|
||||
authenticated: boolean
|
||||
username?: string
|
||||
/** 刷新中:刷新按钮转圈 */
|
||||
refreshing?: boolean
|
||||
/** 当前是否为深色,决定明暗按钮的图标与文案 */
|
||||
dark?: boolean
|
||||
onRefresh: () => void
|
||||
onToggleTheme: () => void
|
||||
onLogin: () => void
|
||||
onLogout: () => void
|
||||
onSettings: () => void
|
||||
/** 小屏打开分组导航抽屉;不传则不显示菜单按钮 */
|
||||
onOpenNav?: () => 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,
|
||||
ready = true,
|
||||
authenticated,
|
||||
username,
|
||||
refreshing = false,
|
||||
dark = false,
|
||||
onRefresh,
|
||||
onToggleTheme,
|
||||
onLogin,
|
||||
onLogout,
|
||||
onSettings,
|
||||
onOpenNav,
|
||||
}: 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">
|
||||
{ready && headerEnabled && (
|
||||
<Ticker speed={headerSpeed} gap={headerGap} label="运行状态" className="header-ticker">
|
||||
{headerItems.map((item, index) => (
|
||||
<span className="header-ticker-item" key={`${index}-${item.text}`}>
|
||||
{item.tag !== undefined && item.tag !== null && item.tag !== '' && (
|
||||
<Tag tone={tagTone[item.tone]} shape="rounded">
|
||||
{item.tag}
|
||||
</Tag>
|
||||
)}
|
||||
{fillHeaderText(item.text, context)}
|
||||
</span>
|
||||
))}
|
||||
</Ticker>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
{onOpenNav !== undefined && (
|
||||
<IconButton
|
||||
className="header-nav-toggle"
|
||||
label="分组导航"
|
||||
icon={<Icon name="menu_line" />}
|
||||
onClick={onOpenNav}
|
||||
/>
|
||||
)}
|
||||
{ready && (
|
||||
<>
|
||||
{site.actions.theme && (
|
||||
<IconButton
|
||||
label={dark ? '切换到浅色' : '切换到深色'}
|
||||
icon={<Icon name={dark ? 'moon_line' : 'sun_line'} />}
|
||||
onClick={onToggleTheme}
|
||||
/>
|
||||
)}
|
||||
{site.actions.refresh && (
|
||||
<IconButton
|
||||
label="刷新(重扫项目并重拉内容)"
|
||||
icon={<Icon name="lightning_line" />}
|
||||
spinning={refreshing}
|
||||
onClick={onRefresh}
|
||||
/>
|
||||
)}
|
||||
{site.actions.settings && authenticated && (
|
||||
<IconButton
|
||||
label="站点设置"
|
||||
icon={<Icon name="settings_1_line" />}
|
||||
onClick={onSettings}
|
||||
/>
|
||||
)}
|
||||
{site.actions.session &&
|
||||
(authenticated ? (
|
||||
<IconButton
|
||||
label={username === undefined ? '退出登录' : `退出登录(${username})`}
|
||||
icon={<Icon name="exit_line" />}
|
||||
onClick={onLogout}
|
||||
/>
|
||||
) : (
|
||||
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,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>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 内容数据模型:应用 / 海报 / 图文三种条目,以及详情页的推荐条目。
|
||||
*/
|
||||
|
||||
/** 一个可点击的应用 / 站点条目 */
|
||||
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 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 视图与地址的对应关系:地址栏是唯一的视图状态源。
|
||||
*
|
||||
* 控制台是单页应用,但「当前在哪一页」写在 URL 里,所以刷新、收藏、
|
||||
* 前进后退都不会丢上下文。服务端对应的规则在 `src/server.rs`:
|
||||
* `/c/**` 没有实体文件时回落到内嵌的 `index.html`,由前端按 pathname 重新出页面。
|
||||
*
|
||||
* 路由形状(各段都是业务键,也就是接口 JSON 里的 `id`):
|
||||
*
|
||||
* / 首页
|
||||
* /c/<分区> 分类明细
|
||||
* /c/<分区>/<分组> 只展示该分组
|
||||
* /c/<分区>/i/<条目> 条目详情,返回 → 分类明细
|
||||
* /c/<分区>/<分组>/i/<条目> 条目详情,返回 → 该分组
|
||||
*
|
||||
* 条目地址带上它自己所在的分组,URL 才是自解释的:刷新之后点「返回」
|
||||
* 仍然落回条目所属的分组,而不是去猜一个返回目标。
|
||||
*
|
||||
* 这里只做「视图 → 地址」的纯转换,读写 history 的部分在 `AppNext.tsx`。
|
||||
*/
|
||||
|
||||
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_SEGMENT = 'c'
|
||||
/** 条目段标记:`/c/<分区>[/<分组>]/i/<条目>` */
|
||||
const ITEM_MARK = 'i'
|
||||
|
||||
/** 把视图转成地址。 */
|
||||
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('/')}`
|
||||
}
|
||||
|
||||
/** 单个路径段编码:id 里可能带斜杠、空格、中文 */
|
||||
function encodeSegment(value: string): string {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
+27
-48
@@ -1,36 +1,20 @@
|
||||
/* 页面级基础:字号 / 字体 / 重置。颜色与间距全部来自 @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;
|
||||
}
|
||||
|
||||
html[data-scheme='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -40,15 +24,7 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 桌面端锁死视口,滚动交给右侧内容区 */
|
||||
body {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -63,20 +39,23 @@ button {
|
||||
background: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: auto;
|
||||
/* 键盘焦点环由组件库统一提供;这里只给「非表单控件」补兜底。
|
||||
表单控件不能加全局 outline:输入框点击时也会命中 :focus-visible,
|
||||
会和组件自己的边框态叠成「双框」(用户实际报过的 bug)。 */
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
[tabindex]:not(input):focus-visible {
|
||||
outline: var(--su-focus-ring);
|
||||
outline-offset: var(--su-focus-ring-offset);
|
||||
}
|
||||
|
||||
body {
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
@@ -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: '局域网',
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 站点设置:页头滚动信息 + 侧栏卡片显隐。
|
||||
*
|
||||
* 接口是公开读、登录后写;写的时候带 `version` 做乐观锁,
|
||||
* 冲突(多标签页同时改)时返回 409,页面提示刷新。
|
||||
*/
|
||||
|
||||
export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral'
|
||||
|
||||
export type HeaderItem = {
|
||||
text: string
|
||||
tag?: string | null
|
||||
tone: SettingsTone
|
||||
}
|
||||
|
||||
export type ProfileSettings = {
|
||||
name: string
|
||||
role: string
|
||||
description: string
|
||||
/** 完整文案,如「IP属地:局域网」 */
|
||||
location: string
|
||||
/** 头像地址;空表示用图标占位 */
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export type BackgroundSettings = {
|
||||
/** 亮色背景图地址;空表示使用内置图 */
|
||||
light: string
|
||||
/** 暗色背景图地址;空表示使用内置图 */
|
||||
dark: string
|
||||
}
|
||||
|
||||
export type FontSource = 'builtin' | 'custom'
|
||||
|
||||
/** 一条存下来的字体配置:名字只用于后台下拉,url 才是真正加载的东西 */
|
||||
export type FontLink = {
|
||||
key: string
|
||||
name: string
|
||||
url: string
|
||||
/** 字体族名:CSS 链接时填 CSS 里声明的名字;字体文件留空则用 Stash Font */
|
||||
family: string
|
||||
}
|
||||
|
||||
export type FontSettings = {
|
||||
/** 字体来源:内置霞鹜文楷,或下面保存的字体链接 */
|
||||
source: FontSource
|
||||
/** 当前启用的 FontLink.key;为空时用第一条 */
|
||||
current: string
|
||||
/** 存下来的字体:常用字体放这里,切换时不用重新填 */
|
||||
items: FontLink[]
|
||||
}
|
||||
|
||||
const BACKGROUND_CACHE_KEY = 'stash-background'
|
||||
|
||||
/**
|
||||
* 上次拿到的背景地址。
|
||||
*
|
||||
* 设置要等 `/api/settings` 回来,刷新时首帧只能先画内置背景,自定义背景随后才换上来,
|
||||
* 会闪一下。先用上次缓存上屏,等接口回来再校准。
|
||||
*/
|
||||
export function readCachedBackground(): BackgroundSettings | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(BACKGROUND_CACHE_KEY)
|
||||
if (raw === null) return null
|
||||
const value = JSON.parse(raw) as Partial<BackgroundSettings>
|
||||
if (typeof value.light !== 'string' || typeof value.dark !== 'string') return null
|
||||
return { light: value.light, dark: value.dark }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedBackground(background: BackgroundSettings): void {
|
||||
try {
|
||||
window.localStorage.setItem(BACKGROUND_CACHE_KEY, JSON.stringify(background))
|
||||
} catch {
|
||||
// 隐私模式下 localStorage 不可写,忽略即可
|
||||
}
|
||||
}
|
||||
|
||||
export type SearchEngineSettings = {
|
||||
/** 内置引擎用固定 id(对应品牌图标);自定义引擎由后台生成 custom-xxx */
|
||||
id: string
|
||||
name: string
|
||||
/** 搜索地址模板,用 {query} 表示关键词 */
|
||||
url: string
|
||||
/** 自定义图标地址(png / svg);空表示用品牌图标或通用地球 */
|
||||
icon: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type SearchSettings = {
|
||||
/** `internal` 或某个引擎 id */
|
||||
default: string
|
||||
engines: SearchEngineSettings[]
|
||||
}
|
||||
|
||||
/** 首次启动 / 恢复默认时的搜索引擎清单 */
|
||||
export const DEFAULT_SEARCH_ENGINES: SearchEngineSettings[] = [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
url: 'https://www.google.com/search?q={query}',
|
||||
icon: '',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'bing',
|
||||
name: 'Bing',
|
||||
url: 'https://www.bing.com/search?q={query}',
|
||||
icon: '',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'baidu',
|
||||
name: '百度',
|
||||
url: 'https://www.baidu.com/s?wd={query}',
|
||||
icon: '',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'sogou',
|
||||
name: '搜狗',
|
||||
url: 'https://www.sogou.com/web?query={query}',
|
||||
icon: '',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: '360',
|
||||
name: '360',
|
||||
url: 'https://www.so.com/s?q={query}',
|
||||
icon: '',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'duckduckgo',
|
||||
name: 'DuckDuckGo',
|
||||
url: 'https://duckduckgo.com/?q={query}',
|
||||
icon: '',
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
/** 内置引擎:有品牌图标、后台不能删除(只能改名 / 改地址 / 停用) */
|
||||
export const BUILTIN_SEARCH_ENGINE_IDS = [
|
||||
'google',
|
||||
'bing',
|
||||
'baidu',
|
||||
'sogou',
|
||||
'360',
|
||||
'duckduckgo',
|
||||
] as const
|
||||
|
||||
export type SiteSettings = {
|
||||
header: {
|
||||
enabled: boolean
|
||||
/** 滚动一轮的秒数(8–120) */
|
||||
speed: number
|
||||
/** 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽 */
|
||||
gap: number
|
||||
items: HeaderItem[]
|
||||
}
|
||||
sidebar: {
|
||||
/** 是否显示「本地管理员」资料卡 */
|
||||
show_profile: boolean
|
||||
/** 是否显示「本地服务」面板 */
|
||||
show_service: boolean
|
||||
/** 是否显示「分组导航」卡片(首页 / 分组树) */
|
||||
show_nav: boolean
|
||||
}
|
||||
background: BackgroundSettings
|
||||
font: FontSettings
|
||||
search: SearchSettings
|
||||
profile: ProfileSettings
|
||||
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
|
||||
site: SiteMetaSettings
|
||||
}
|
||||
|
||||
export type SiteTheme = 'light' | 'dark' | 'auto'
|
||||
|
||||
export type SiteMetaSettings = {
|
||||
/** 浏览器标签标题 */
|
||||
title: string
|
||||
/** favicon 地址;空表示用内置图标 */
|
||||
favicon: string
|
||||
/** 配色:浅色 / 深色 / 跟随系统 */
|
||||
theme: SiteTheme
|
||||
brand: {
|
||||
/** 页头品牌文字,可留空 */
|
||||
name: string
|
||||
/** 没有图片时圆形标记里的首字 */
|
||||
mark: string
|
||||
/** logo 图片地址;空表示用首字 */
|
||||
logo: string
|
||||
}
|
||||
actions: {
|
||||
/** 明暗切换 */
|
||||
theme: boolean
|
||||
/** 刷新图标(重扫本地项目并重拉内容库) */
|
||||
refresh: boolean
|
||||
/** 站点设置图标 */
|
||||
settings: boolean
|
||||
/** 登录 / 退出登录 */
|
||||
session: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type SettingsDocument = {
|
||||
version: number
|
||||
settings: SiteSettings
|
||||
}
|
||||
|
||||
export type SettingsResult =
|
||||
| { ok: true; document: SettingsDocument }
|
||||
| { ok: false; status: number; error: string }
|
||||
|
||||
export const TONE_OPTIONS: { value: SettingsTone; label: string }[] = [
|
||||
{ value: 'plain', label: '无底色' },
|
||||
{ value: 'neutral', label: '灰色' },
|
||||
{ value: 'primary', label: '品牌色' },
|
||||
{ value: 'success', label: '成功绿' },
|
||||
]
|
||||
|
||||
export function defaultSettings(): SiteSettings {
|
||||
return {
|
||||
header: {
|
||||
enabled: true,
|
||||
speed: 24,
|
||||
gap: 26,
|
||||
items: [
|
||||
{ text: '共 {count} 个本地应用', tone: 'plain' },
|
||||
{ text: '{project}', tag: '本地', tone: 'primary' },
|
||||
{ text: '{status}', tag: '状态', tone: 'success' },
|
||||
],
|
||||
},
|
||||
sidebar: {
|
||||
show_profile: true,
|
||||
show_service: true,
|
||||
show_nav: true,
|
||||
},
|
||||
background: { light: '', dark: '' },
|
||||
font: { source: 'builtin', current: '', items: [] },
|
||||
search: {
|
||||
default: 'internal',
|
||||
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
|
||||
},
|
||||
profile: {
|
||||
name: '本地管理员',
|
||||
role: '管理员',
|
||||
description: '本地静态站点托管与预览,所有项目都运行在这台机器上。',
|
||||
location: 'IP属地:局域网',
|
||||
avatar: '',
|
||||
},
|
||||
site: {
|
||||
title: 'Stash · 本地应用工作区',
|
||||
favicon: '',
|
||||
theme: 'auto',
|
||||
brand: { name: 'STASH', mark: 'S', logo: '' },
|
||||
actions: { theme: true, refresh: true, settings: true, session: true },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 占位符替换:`{count}` / `{project}` / `{status}` */
|
||||
export function fillHeaderText(
|
||||
text: string,
|
||||
context: { count: number; project?: string; live: boolean },
|
||||
): string {
|
||||
return text
|
||||
.replaceAll('{count}', String(context.count))
|
||||
.replaceAll('{project}', context.project ?? '—')
|
||||
.replaceAll('{status}', context.live ? '实时同步已连接' : '等待服务连接')
|
||||
}
|
||||
|
||||
export async function fetchSettings(): Promise<SettingsDocument> {
|
||||
try {
|
||||
const response = await fetch('/api/settings', { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error(String(response.status))
|
||||
return (await response.json()) as SettingsDocument
|
||||
} catch {
|
||||
return { version: 0, settings: defaultSettings() }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
version: number,
|
||||
settings: SiteSettings,
|
||||
): Promise<SettingsResult> {
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ version, settings }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true, document: (await response.json()) as SettingsDocument }
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
error: payload?.error ?? `保存失败(${response.status})`,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: error instanceof Error ? error.message : '网络错误',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user