Compare commits
3
Commits
master
..
19ead7b716
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,328 @@
|
||||
# Alright 主题设计规范(Stash 前端)
|
||||
|
||||
本文件记录 Stash 控制台在视觉上对齐 [Alright 主题](https://alright.demo.nicetheme.xyz/) 的各项标准,
|
||||
用于后续新增组件、调整样式时保持一致。
|
||||
|
||||
- 参考站点:<https://alright.demo.nicetheme.xyz/>
|
||||
- 生效范围:`stash-web/`(React 控制台)
|
||||
- 令牌定义处:[`stash-web/src/index.css`](stash-web/src/index.css)
|
||||
- 组件样式:[`stash-web/src/App.css`](stash-web/src/App.css)
|
||||
|
||||
> 所有色值、字号、圆角均取自主题的 `:root` 设计变量,不是肉眼取色。
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计令牌
|
||||
|
||||
### 1.1 灰阶
|
||||
|
||||
| 令牌 | 值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `--gray-1` | `#f7f8fa` | 页面背景、浅底块 |
|
||||
| `--gray-2` | `#f6f6f6` | 边框、次级浅底 |
|
||||
| `--gray-3` | `#eeeeee` | hover 底色 |
|
||||
| `--gray-4` | `#c9cdd4` | 分隔线、禁用态 |
|
||||
| `--gray-5` | `#86909c` | 说明文字 |
|
||||
| `--gray-6` | `#4e5969` | 次级文字 |
|
||||
| `--gray-7` | `#2c343c` | 强调文字 |
|
||||
| `--gray-8` | `#1d2129` | 主文字 |
|
||||
|
||||
### 1.2 语义色
|
||||
|
||||
| 令牌 | 值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `--color-primary` | `#3858f6` | 主色:链接、选中态、色条、图标 |
|
||||
| `--color-primary` 10% | `rgba(56, 88, 246, 0.1)` | 主色浅底(`--primary-soft`) |
|
||||
| `--color-danger` | `#ff3e3e` | 数量角标、错误提示 |
|
||||
| `--color-warning` | `#ffba3a` | 橙色强调(CES 色条) |
|
||||
| `--color-success` | `#4abd86` | 在线状态 |
|
||||
| 渐变红 | `linear-gradient(to right, #ff6034, #ff3e3e)` | 热门标记 |
|
||||
| HOT 渐变 | `#ff7b51 → #ff0e0e` | HOT 图形徽标 |
|
||||
| 认证渐变 | `#ffb74a → #fd900f` | 头像认证角标 |
|
||||
|
||||
### 1.3 圆角
|
||||
|
||||
| 令牌 | 值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `--radius-xs` | `4px` | 小标签、徽标 |
|
||||
| `--radius-sm` | `6px` | 小组件 |
|
||||
| `--radius-main`(md) | `12px` | 卡片、面板 |
|
||||
| `--radius-lg` | `24px` | 大容器 |
|
||||
| `max` | `999px` | 胶囊、圆形 |
|
||||
|
||||
### 1.4 阴影
|
||||
|
||||
主题的阴影非常克制,只有两层:
|
||||
|
||||
```css
|
||||
--shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02); /* 常态 */
|
||||
--shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06); /* 悬停 */
|
||||
```
|
||||
|
||||
**注意**:应用条目、作者列表这类「行」不应该有阴影或描边。
|
||||
|
||||
### 1.5 字号阶梯
|
||||
|
||||
| 令牌 | 字号 | 行高 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `xs` | 10px | 14px | 角标 |
|
||||
| `sm` | 12px | 16px | 描述、元信息、HOT 旁注 |
|
||||
| `md` | 14px | 20px | 正文、按钮、导航 |
|
||||
| `lg` | 16px | 24px | 分区标题、条目标题 |
|
||||
| `xl` | 24px | — | 详情页大标题 |
|
||||
|
||||
字重:主题的「加粗」是 `500`,不是 700。
|
||||
|
||||
### 1.6 动效时长
|
||||
|
||||
| 令牌 | 值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `base` | 0.3s | 展开/收起、淡入淡出 |
|
||||
| `fast` | 0.2s | 颜色、背景变化 |
|
||||
| enter | `ease-out` | 出现 |
|
||||
| leave | `ease-in` | 消失 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 字体
|
||||
|
||||
| 角色 | 字体 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 正文 | **LXGW WenKai(霞鹜文楷)** | 与演示站同一份 webfont,`cdn.staticfile.net` |
|
||||
| 回退 | `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif` | 字体加载失败时 |
|
||||
| 数字(主题) | `Outfit-Medium` | 仅主题的 `font-price` / `font-number` 使用,Stash 未引入 |
|
||||
| 等宽 | `ui-monospace, Consolas, monospace` | 路由地址等路径文本 |
|
||||
|
||||
引入位置:[`stash-web/index.html`](stash-web/index.html) 的 `<link>`,
|
||||
CSS 变量 `--font-sans` / `--font-mono` 定义在 `index.css`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 图标
|
||||
|
||||
### 3.1 图标字体
|
||||
|
||||
主题使用自建 iconfont(线性风格,Project id 4809463)。
|
||||
Stash 自托管了一份 **只含所需字形** 的子集:
|
||||
|
||||
- 字体文件:[`stash-web/src/assets/iconfont.woff2`](stash-web/src/assets/iconfont.woff2)
|
||||
- 样式:[`stash-web/src/iconfont.css`](stash-web/src/iconfont.css)
|
||||
- 组件封装:[`stash-web/src/components/common/Icon.tsx`](stash-web/src/components/common/Icon.tsx)
|
||||
|
||||
| 用途 | 主题字形 | 码点 | Stash 类名 |
|
||||
| --- | --- | --- | --- |
|
||||
| 搜索 | `search_3_line` | `\e942` | `.icon-search` |
|
||||
| 用户 / 登录 | `user_3_line` | `\e966` | `.icon-user` |
|
||||
| 重新扫描 | `refresh_3_line` | `\e93d` | `.icon-refresh` |
|
||||
| 查看接口 | `link_line` | `\ea37` | `.icon-link` |
|
||||
| 跳转 | `arrow_right_up_line` | `\e8be` | `.icon-external` |
|
||||
| 浏览量 | `eye_line` | `\e8fd` | `.icon-eye` |
|
||||
| 点赞 | `thumb_up_2_line` | `\e957` | `.icon-thumb-up` |
|
||||
| 分享 | `share_3_line` | `\e946` | `.icon-share` |
|
||||
| 清空搜索 | `close_line` | `\e8e4` | `.icon-close` |
|
||||
| 分区更多 | `more_3_line` | `\e91f` | `.icon-more` |
|
||||
| 回到顶部 | `arrow_to_up_line` | `\e8c1` | `.icon-arrow-up` |
|
||||
| 返回 | `arrow_left_line` | `\e8b5` | `.icon-arrow-left` |
|
||||
| 导航展开 | `arrow_down_line` | `\e8b7` | `.icon-arrow-down`(未使用,见下) |
|
||||
|
||||
用法:`<i className="iconfont icon-search" />`,尺寸用 `font-size`、颜色用 `color` 控制。
|
||||
|
||||
### 3.2 CSS 绘制的箭头
|
||||
|
||||
主题的**导航折叠箭头不是图标字体**,而是一个 6×6 的描边方块旋转而成:
|
||||
|
||||
```css
|
||||
.nav-link.has-children::after {
|
||||
content: '';
|
||||
width: 6px; height: 6px;
|
||||
border-left: 1px solid currentColor;
|
||||
border-bottom: 1px solid currentColor;
|
||||
transform: rotate(315deg); /* 展开时 135deg */
|
||||
opacity: 0.4;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 布局
|
||||
|
||||
### 4.1 整体结构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ SiteHeader(固定 60px,白底、底部 1px 分隔) │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ 搜索框(固定,480×44,居中) │
|
||||
│ ┌──────────┬─────────────────────────────┐ │
|
||||
│ │ Sidebar │ 内容区(内部滚动) │ │
|
||||
│ │ 240px │ 分区卡片 / 明细页 │ │
|
||||
│ └──────────┴─────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 项 | 值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 版心宽度 | `1380px` | `--layout-max-width`,超宽屏居中 |
|
||||
| 顶栏高度 | `60px` | `--top-nav-height` |
|
||||
| 侧栏宽度 | `240px` | `--sidebar-width`(主题为 260px,Stash 收窄) |
|
||||
| 栅格间距 | `20px` | 侧栏与内容区之间 |
|
||||
| 内容区顶部让位 | `52px` | `--content-top-offset`,给悬浮搜索框 |
|
||||
| 搜索框 | `480 × 44`,距顶 `76px` | 相对整页居中 |
|
||||
|
||||
### 4.2 页面背景
|
||||
|
||||
主题在 body 上叠了一层固定背景图:
|
||||
|
||||
```css
|
||||
.site-background {
|
||||
position: fixed; inset: 0 auto auto 0;
|
||||
width: 100%; height: 65vh;
|
||||
background: url('./assets/site-bg.jpg') top center / cover no-repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
.site-background::after { /* 底部 80% 渐隐到页面底色 */
|
||||
content: ''; position: absolute; inset: auto 0 0 0; height: 80%;
|
||||
background-image: linear-gradient(0deg, var(--bg-color) 0%, transparent 100%);
|
||||
}
|
||||
```
|
||||
|
||||
滚动时背景不动,形成轻微视差。
|
||||
|
||||
---
|
||||
|
||||
## 5. 组件规范
|
||||
|
||||
### 5.1 分区卡片(首页每一块)
|
||||
|
||||
| 属性 | 值 |
|
||||
| --- | --- |
|
||||
| 容器 | 白底、`1px solid var(--border-color)`、圆角 12、内边距 20、`var(--shadow-card)` |
|
||||
| 标题色条 | `4 × 16px`,圆角 2,分类色(本地/推荐 `#3858f6`、电影 `#ff6034`、CES `#ffba3a`) |
|
||||
| 标题 | 16px / 500 |
|
||||
| 数量角标 | 红色 `#ff3e3e`、10px 字号、圆角 8、贴在标题右上角(`top:-8px; right:-18px`) |
|
||||
| 更多按钮 | `.icon-more`,16px,右对齐 |
|
||||
|
||||
### 5.2 应用条目(对齐主题「推荐作者」)
|
||||
|
||||
对应标记:
|
||||
|
||||
```html
|
||||
<a class="item">
|
||||
<div class="flex-avatar">
|
||||
<img class="avatar-48">
|
||||
<span class="avatar-badge badge_5 w-10"></span>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<h3 class="font-title text-md">名字 <span class="item-badge hot"></span></h3>
|
||||
<div class="text-xs text-muted h-1x">描述</div>
|
||||
</div>
|
||||
</a>
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
| --- | --- |
|
||||
| 行 | `display:flex; gap:15px`,**无底色、无描边、无阴影** |
|
||||
| 头像 | 48×48,`border-radius:100%`,图片 `object-fit:cover` |
|
||||
| 认证角标 | 10×10,绝对定位右下角,内联 `badge_5` 的 SVG |
|
||||
| 名字 | 16px / 行高 24px / 500 |
|
||||
| HOT 徽标 | 24×16 的**图片**(内联主题 `--hot-icon` SVG),不是文字 |
|
||||
| 描述 | 12px / 行高 16px / `--text-muted`,单行省略 |
|
||||
| 悬停 | 名字变主色,不加阴影 |
|
||||
|
||||
> HOT 与认证角标只出现在 `hot: true` 的条目上,不是每个都有。
|
||||
|
||||
### 5.3 海报 / 图文
|
||||
|
||||
| 网格 | 列数 | 间距 | 比例 |
|
||||
| --- | --- | --- | --- |
|
||||
| 应用 | 4(≤1100px 变 2) | 12px | — |
|
||||
| 电影海报 | 6(≤1100px 变 3) | 12px | 2:3 |
|
||||
| 首页图文 | 4(≤1100px 变 2) | 12px | 4:3 |
|
||||
| 明细页图文白卡 | 4(≤1100px 变 2) | 16px | 封面 16:10 |
|
||||
|
||||
封面下方统一用「黑色渐变 → 透明」遮罩压住白色标题:
|
||||
|
||||
```css
|
||||
background: linear-gradient(to top, rgba(0,0,0,.85) 0%, rgba(0,0,0,.4) 60%, transparent 100%);
|
||||
```
|
||||
|
||||
### 5.4 侧栏
|
||||
|
||||
| 模块 | 规格 |
|
||||
| --- | --- |
|
||||
| 用户卡 | 64px 圆形头像 + 认证角标、14px 名字、11–12px 角色/简介/属地 |
|
||||
| 本地服务面板 | 标题 14px、状态胶囊、两行统计(`dt` 12px / `dd` 12px 等宽)、两个操作按钮 |
|
||||
| 分类导航 | 行高 `9px 12px` 内边距、圆角 8、14px;子项 12px、缩进 12px |
|
||||
|
||||
### 5.5 返回顶部
|
||||
|
||||
对应主题 `.scroll-toolbar .totop-button`:
|
||||
|
||||
```css
|
||||
.back-to-top {
|
||||
position: fixed; top: 50%; right: 30px;
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-sub);
|
||||
opacity: 0; visibility: hidden; /* 默认隐藏 */
|
||||
transform: translateY(-50%) translateY(15px);
|
||||
transition: opacity .3s, visibility .3s, transform .3s;
|
||||
}
|
||||
.back-to-top.is-visible {
|
||||
opacity: 1; visibility: visible;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
```
|
||||
|
||||
滚动超过 4px 才出现,回到顶部后自动隐藏;窄屏改为 `right:15px; bottom:30%`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 动效
|
||||
|
||||
| 效果 | 实现 |
|
||||
| --- | --- |
|
||||
| 视图切换(首页 ↔ 明细 ↔ 条目) | `view-in`:0.22s 淡入 + 上移 6px |
|
||||
| 侧栏子菜单展开 | `grid-template-rows: 0fr → 1fr`,0.28s,全程 `overflow:hidden`(不产生滚动条) |
|
||||
| 导航箭头 | `rotate(315deg) → 135deg`,0.3s |
|
||||
| 分区卡片 / 海报 / 图文悬停 | `translateY(-2px)`,0.2s |
|
||||
| 应用条目悬停 | 仅名字改为主色 |
|
||||
| 回到顶部 | 淡入 + 上移 15px,0.3s |
|
||||
| 刷新按钮加载 | `spin 0.9s linear infinite` |
|
||||
| 内容区顶部遮罩 | 滚动后 0.2s 淡入 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 与演示站的有意差异
|
||||
|
||||
| 项 | 主题 | Stash | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 侧栏宽度 | 260px | 240px | 控制台信息密度略高 |
|
||||
| 深色模式 | 支持 | 未实现 | 当前只做浅色主题 |
|
||||
| 数字字体 | Outfit-Medium | 未引入 | 少一个外部字体请求 |
|
||||
| 图标集 | 全量 446 个 | 13 个子集 | 控制体积(woff2 52KB) |
|
||||
| 导航箭头位置 | 紧贴文字右侧 | 行末右对齐 | 视觉上更整齐 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 扩展指引
|
||||
|
||||
| 需求 | 改动位置 |
|
||||
| --- | --- |
|
||||
| 改主色 / 灰阶 / 圆角 | [`stash-web/src/index.css`](stash-web/src/index.css) 的 `:root` |
|
||||
| 新增图标 | 把 codepoint 加进 [`stash-web/src/iconfont.css`](stash-web/src/iconfont.css),在 `Icon.tsx` 包一个组件 |
|
||||
| 新增分类 | 内容库 `category` 表(首次启动前可改 [`src/db/seed.json`](src/db/seed.json)),首页与明细页自动适配 |
|
||||
| 调整侧栏导航 | 内容库 `nav_entry` 表,标签 / 层级 / 跳转目标都可在库里改 |
|
||||
| 新增卡片类型 | 在 `data/types.ts` 增加 kind,`HomeView` / `DetailView` 各加一个分支 |
|
||||
| 调整分区样式 | [`stash-web/src/App.css`](stash-web/src/App.css) 的「分区卡片」段落 |
|
||||
| 换背景图 | 替换 `stash-web/src/assets/site-bg.jpg` |
|
||||
|
||||
---
|
||||
|
||||
## 9. 变更记录
|
||||
|
||||
- 2026-10-22:按本规范完成整体换肤(配色、字体、图标、背景、应用条目、返回顶部),
|
||||
并将侧栏导航箭头改为与主题一致的 CSS 箭头。
|
||||
- 2026-10-23:分类内容与侧栏导航迁入 SQLite 内容库(`data/stash.db`),
|
||||
前端静态数据下线;视觉与交互未变。
|
||||
Generated
+128
@@ -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"
|
||||
@@ -159,6 +171,16 @@ 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"
|
||||
@@ -230,6 +252,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 +318,24 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[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 +510,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"
|
||||
@@ -619,6 +688,12 @@ 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 = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
@@ -663,6 +738,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"
|
||||
@@ -759,6 +848,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"
|
||||
@@ -803,6 +898,7 @@ dependencies = [
|
||||
"include_dir",
|
||||
"mime_guess",
|
||||
"notify",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
@@ -1073,6 +1169,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 +1295,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"
|
||||
|
||||
+12
@@ -25,6 +25,18 @@ include_dir = "0.7"
|
||||
async-stream = "0.3"
|
||||
notify = "8"
|
||||
|
||||
# 内容库:SQLite 随二进制静态编译,运行时不需要任何外部 DLL
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
[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
|
||||
|
||||
@@ -1,151 +1,391 @@
|
||||
# Stash
|
||||
|
||||
Stash 是一个基于 Rust、Tokio 和 Axum 实现的本地静态页面托管工具。它既可以直接托管一个静态站点,也可以扫描托管目录下的多个前端项目,并通过内置导航页统一访问。
|
||||
Stash 是一个用 Rust 编写的本地静态页面托管工具。编译后的 React 控制台会被内嵌进二进制,
|
||||
所以发布物只有一个 `stash` 可执行文件,不需要额外部署前端静态资源。
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 单站点托管
|
||||
|
||||
当托管根目录中存在 `index.html` 时,访问 `/` 会直接返回该页面。页面依赖的 JavaScript、CSS、图片、字体等资源继续通过静态文件服务加载,因此可以直接运行常见的纯静态前端项目。
|
||||
|
||||
### 多项目导航
|
||||
|
||||
当根目录没有 `index.html` 时,Stash 会扫描根目录下的一级子目录,并将包含 `index.html` 或 `index.htm` 的目录识别为前端项目。
|
||||
|
||||
- 自动排除以 `.` 开头的隐藏目录。
|
||||
- 项目按名称进行不区分大小写的升序排列。
|
||||
- 每个项目生成一个卡片链接,地址格式为 `/<项目目录名>/`。
|
||||
- 项目名称首字符会作为圆形标记显示。
|
||||
- 没有识别到项目时显示空状态提示。
|
||||
- 导航页采用 4 列、2 列、1 列的响应式布局,分别适配桌面、平板和手机。
|
||||
- 项目和目录列表在每次请求时重新扫描,新增项目通常无需重启服务。
|
||||
- 文件监听会在项目快照发生变化后主动通知已打开的导航页,无需手动刷新。
|
||||
|
||||
### 静态文件服务
|
||||
|
||||
Stash 使用 `tower-http` 的 `ServeDir` 托管根目录内容:
|
||||
|
||||
- 自动为目录追加 `index.html`。
|
||||
- 根据文件扩展名返回对应的 MIME 类型。
|
||||
- 未找到的路径会返回内置的中文 404 页面,并显示请求路径。
|
||||
- 每次 HTTP 请求都会经过 Trace 层,可通过调整日志级别观察请求日志。
|
||||
- 内置控制台资源通过保留路径 `/__stash__/` 提供,避免与用户托管的项目文件冲突。
|
||||
|
||||
### 内置控制台
|
||||
|
||||
- 使用 Askama 在编译期渲染项目导航页和 404 页面。
|
||||
- 页面 HTML、CSS 和 JavaScript 分别维护,不再使用字符串替换拼接卡片。
|
||||
- 项目名称和请求路径由模板引擎自动进行 HTML 转义。
|
||||
- 项目目录名会进行 URL 路径段编码,再生成项目访问地址。
|
||||
- 少量 JavaScript 通过 SSE 订阅项目变化,并在不刷新整个页面的情况下更新卡片;脚本不可用时,服务端渲染的导航和手动 API 仍可正常使用。
|
||||
|
||||
### 同源 API
|
||||
|
||||
服务提供两个只读 JSON 接口和一个 SSE 推送接口,可供导航页或同源前端调用。
|
||||
|
||||
### CORS
|
||||
|
||||
默认允许任意来源、任意请求方法和任意请求头访问服务,适合本地联调。通过 `--no-cors` 可以关闭 CORS 标头注入。
|
||||
|
||||
### 服务管理
|
||||
|
||||
- 默认监听 `127.0.0.1:8080`。
|
||||
- 支持自定义绑定地址、端口和托管目录。
|
||||
- 默认托管目录 `./public` 不存在时会自动创建。
|
||||
- 用户显式指定的托管目录不存在时,会输出错误并退出。
|
||||
- 端口绑定失败、目录无效等错误会以清晰的错误信息返回。
|
||||
- 支持分别通过 `Ctrl+C` 以及 Unix 系统的 `SIGTERM` 触发优雅停机。
|
||||
|
||||
### 可复用核心库
|
||||
|
||||
`stash-core` 同时作为库导出 `Config`、`StashError`、`start`、`ServerHandle`、`scan_projects` 和 `AppProject`,既可由 `stash-cli` 使用,也可以被其他 Rust 程序嵌入或复用。
|
||||
它能做两件事:托管目录下的多个静态前端项目、给控制台提供同源 JSON / SSE 接口。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
```bash
|
||||
# 1. 先构建前端(dist 会被内嵌,缺了它 cargo build 会直接失败)
|
||||
cd stash-web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
- Rust 工具链,需支持 Rust 2024 Edition。
|
||||
- Cargo。
|
||||
# 2. 编译并运行
|
||||
cargo run # 托管 ./public,监听 127.0.0.1:8080
|
||||
|
||||
### 启动示例
|
||||
# 自定义目录 / 端口 / 对外暴露
|
||||
cargo run -- --root ./sites --port 3000
|
||||
cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
||||
```
|
||||
|
||||
启动后打开终端里打印的地址即可,例如 `http://127.0.0.1:8080/`。
|
||||
默认托管目录 `./public` 不存在时会自动创建;首次启动还会与二进制同级创建 `data/`,
|
||||
并在其中生成内容库 `stash.db`(已内置示例内容,可以直接用 SQLite 工具修改)。
|
||||
|
||||
环境要求:
|
||||
|
||||
- Rust 工具链(2024 Edition)
|
||||
- Node.js 24,推荐用 [Volta](https://volta.sh/) 管理;`stash-web/package.json` 里已用 `volta` 字段固定版本
|
||||
|
||||
## 工作方式
|
||||
|
||||
请求按下面的顺序命中:
|
||||
|
||||
| 路径 | 处理方式 |
|
||||
| --- | --- |
|
||||
| `/api/*` | JSON / SSE 接口 |
|
||||
| `/`、`/index.html` | 内嵌前端的 `index.html`(`no-cache`) |
|
||||
| `/c/**` | 前端路由地址(分类明细 / 条目详情),没有实体文件时同样返回 `index.html` |
|
||||
| `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) |
|
||||
| `/favicon.svg` | 内嵌前端根目录文件 |
|
||||
| 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` |
|
||||
| 仍未命中 | 内置 404 页面(显示请求路径) |
|
||||
|
||||
托管目录下的每个一级子目录,只要包含 `index.html` 或 `index.htm`,就会被识别为一个项目,
|
||||
通过 `/<目录名>/` 访问。目录名会做 URL 路径段编码。
|
||||
|
||||
## 前端路由
|
||||
|
||||
控制台是单页应用,但「当前在哪一页」写在地址栏里,所以刷新、收藏、前进后退都不会丢上下文:
|
||||
|
||||
| 地址 | 页面 |
|
||||
| --- | --- |
|
||||
| `/` | 首页分区卡片 |
|
||||
| `/c/<分区>` | 分类明细 |
|
||||
| `/c/<分区>/<分组>` | 只展示该分组 |
|
||||
| `/c/<分区>/i/<条目>` | 条目详情,返回 → 分类明细 |
|
||||
| `/c/<分区>/<分组>/i/<条目>` | 条目详情,返回 → 该分组 |
|
||||
|
||||
地址里的每一段都是**业务键**(也就是接口 JSON 里的 `id`):库里是 `node.key` / `entry.key`,
|
||||
本地项目则是目录名(访问地址仍然是 `route_path`,两者不再混用)。
|
||||
|
||||
几条约定:
|
||||
|
||||
- 条目地址带上它自己所在的分组,所以**一个条目只有一个地址**;刷新之后点「返回」
|
||||
仍然落回条目所属的分组,而不是去猜一个返回目标;
|
||||
- 「返回」按钮在应用内有上一页时交给浏览器(`history.back()`),
|
||||
直接打开地址(刷新 / 收藏,应用内没有上一页)时退到上面的推导结果;
|
||||
- 服务端只把 `/c/**` 回落到入口页(`src/server.rs` 的 `SPA_PREFIXES`),
|
||||
托管目录里写错的地址仍然是 404 页面——不做「所有未命中都返回 200」的万能回落,
|
||||
否则分不清是路由拼错还是文件真的不存在;
|
||||
- 地址转换规则在 [`stash-web/src/data/view.ts`](stash-web/src/data/view.ts),
|
||||
与 history 打交道的部分在 [`stash-web/src/useViewRoute.ts`](stash-web/src/useViewRoute.ts)。
|
||||
新增一类页面时两处都要改:前端加前缀,服务端把它加进 `SPA_PREFIXES`。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── Cargo.toml # 单 crate(lib + bin),依赖清单
|
||||
├── Cargo.lock
|
||||
├── build.rs # 把 stash-web/dist 同步到 $OUT_DIR/web
|
||||
├── src/ # Rust 后端
|
||||
│ ├── main.rs # CLI 参数、启动横幅、停机信号
|
||||
│ ├── lib.rs # 库导出
|
||||
│ ├── config.rs # 服务配置、数据目录与路径校验
|
||||
│ ├── error.rs # 错误类型与 JSON 错误响应
|
||||
│ ├── server.rs # 路由、内嵌静态资源、SSE、生命周期
|
||||
│ ├── listing.rs # 托管目录扫描
|
||||
│ ├── watcher.rs # 文件监听、防抖、快照广播
|
||||
│ └── db/ # SQLite 内容库
|
||||
│ ├── mod.rs # 打开、PRAGMA、种子导入时机
|
||||
│ ├── schema.rs # 建表语句与种子导入
|
||||
│ ├── catalog.rs # 查询结果组装成前端 Category[]
|
||||
│ └── seed.json # 内嵌种子数据(编译进二进制)
|
||||
├── stash-web/ # React + Vite + TypeScript 控制台
|
||||
│ ├── package.json # 含 volta 版本固定
|
||||
│ ├── vite.config.ts # 开发期 /api 代理到 127.0.0.1:8080
|
||||
│ └── src/
|
||||
├── data/ # 内容库目录(与二进制同级,运行时生成)
|
||||
│ └── stash.db # SQLite 内容库
|
||||
└── public/ # 默认托管目录(可放多个前端项目)
|
||||
```
|
||||
|
||||
`templates/` 与 `assets/` 是上一版 Askama 控制台留下的文件,当前已无任何代码引用,
|
||||
可以直接删除。
|
||||
|
||||
## 前端怎么被内嵌
|
||||
|
||||
1. `npm run build` 产出 `stash-web/dist`;
|
||||
2. `build.rs` 在编译 Rust 之前把 `dist` 复制到 `$OUT_DIR/web`(先清空,避免旧 hash 文件残留);
|
||||
3. `src/server.rs` 用 `include_dir!("$OUT_DIR/web")` 在编译期把整棵目录树嵌进二进制;
|
||||
4. 二进制运行时不再读取任何前端文件。
|
||||
|
||||
构建顺序不能颠倒:`dist` 不存在时 `build.rs` 会带着提示信息直接失败,而不是产出一个没有界面的二进制。
|
||||
|
||||
`build.rs` 声明了 `rerun-if-changed`,改动 `dist` 后重新 `cargo build` 会自动重新嵌入。
|
||||
|
||||
## 内容库(data/stash.db)
|
||||
|
||||
控制台的分类 / 分组 / 条目不写死在前端代码里,而是存在一个 SQLite 文件中,
|
||||
默认位置是**二进制同级**的 `data/stash.db`。首次启动时若文件不存在,
|
||||
会用内嵌的 `src/db/seed.json` 建库并灌入示例内容;之后一直沿用该文件(不覆盖已有数据)。
|
||||
|
||||
用 `--data <DIR>` 可以把数据目录指到别处。
|
||||
**内容库不能放在 `--root` 托管目录内部**——那里的文件会被 `ServeDir` 当静态资源对外提供,
|
||||
启动时会直接报错拒绝。
|
||||
|
||||
### 表结构
|
||||
|
||||
三张表 + 一张展示配置表:
|
||||
|
||||
- **分区与分组是同一张自引用树**——它们都是「装条目的容器」,只是层级不同;
|
||||
- **侧栏直接由这棵树派生**(分区下挂分组),不再有独立的导航表;
|
||||
- **首页分区展示什么由 `section_block` 决定**,卡片不再混排所有分组。
|
||||
|
||||
文章正文是一整段 Markdown,直接存在 `entry.body` 里(一篇文章一行),不再拆成正文块表。
|
||||
|
||||
主键统一是 `id INTEGER PRIMARY KEY`(SQLite rowid,自动分配,不用 `AUTOINCREMENT`),
|
||||
另有一列 `key` 作为**唯一业务编码**;外键一律写成 `<目标>_key`,指向对方的 `key`。
|
||||
|
||||
#### node —— 分区与分组(一棵容器树)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
|
||||
| `key` | TEXT | 业务编码,全局唯一(如 `recommend` / `ui`) |
|
||||
| `parent_key` | TEXT? | 父节点编码:分组指向所属分区,分区为 NULL |
|
||||
| `title` | TEXT | 展示名:分区标题 / 分组小标题 |
|
||||
| `kind` | TEXT | `section` 顶层分区 / `group` 分区下的分组 |
|
||||
| `view_mode` | TEXT? | 仅分区:`apps` / `posters` / `articles`,决定渲染形态 |
|
||||
| `bar_color` | TEXT? | 仅分区:标题左侧 4×16 色条颜色(如 `#3858f6`) |
|
||||
| `icon` | TEXT? | 仅分区:明细页 hero 左侧的字符图标 |
|
||||
| `preview_limit` | INTEGER? | 仅分区:首页预览条数;为空时按类型取默认 |
|
||||
| `empty_text` | TEXT? | 仅分区:分区为空时的提示文案 |
|
||||
| `show_in_sidebar` | INTEGER | 是否出现在侧栏,`0` / `1`;0 表示只当首页卡片/内容,不进导航 |
|
||||
| `sort` | INTEGER | 同级顺序,越小越靠前 |
|
||||
|
||||
两条约束写在表上:
|
||||
|
||||
- `CHECK`:`section` 必须 `parent_key IS NULL` 且有 `view_mode`;`group` 必须有父节点且无 `view_mode`;
|
||||
- `key` 唯一(UNIQUE),全局不重复。
|
||||
|
||||
#### entry —— 内容条目(网站 / 电影 / 文章共用)
|
||||
|
||||
| 字段 | 类型 | 说明 / 前端用途 |
|
||||
| --- | --- | --- |
|
||||
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
|
||||
| `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 |
|
||||
| `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 |
|
||||
| `name` | TEXT | 名称:网站名 / 电影名 / 文章标题 |
|
||||
| `description` | TEXT? | 一句话简介,列表卡片的副标题 |
|
||||
| `icon` | TEXT? | 字符图标,封面缺失时的占位 |
|
||||
| `href` | TEXT? | 点击后打开的页面;空或 `#` 表示暂无可跳转地址 |
|
||||
| `cover` | TEXT? | 封面图:应用头像 / 电影海报 / 文章配图 |
|
||||
| `banner` | TEXT? | 详情页横幅图 |
|
||||
| `badge` | TEXT? | 详情页标签行上的一句话标记(如「推荐好站」) |
|
||||
| `comment` | TEXT? | 详情页的一句话评价(引用块) |
|
||||
| `summary` | TEXT? | 详情页正文段落(电影简介这类长文本) |
|
||||
| `body` | TEXT? | 文章正文,一整段 Markdown;一篇一行,不拆块 |
|
||||
| `hot` | INTEGER | 是否热门,`0` / `1`;为 1 时列表显示 HOT 徽标 |
|
||||
| `views` | INTEGER | 浏览量,详情页操作条「眼睛」后的数字 |
|
||||
| `likes` | INTEGER | 点赞数,详情页操作条「赞」后的数字 |
|
||||
| `sort` | INTEGER | 条目顺序(组内或分区内),越小越靠前 |
|
||||
|
||||
条目只存 `node_key`,**所属分区由它推导**:父节点是分组就取该分组的 `parent_key`,
|
||||
父节点是分区就取它自己。查询「整个分区」时用:
|
||||
|
||||
```sql
|
||||
SELECT e.*
|
||||
FROM entry e
|
||||
JOIN node p ON p.key = e.node_key
|
||||
WHERE COALESCE(p.parent_key, p.key) = 'recommend';
|
||||
```
|
||||
|
||||
好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况;
|
||||
`node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。
|
||||
|
||||
三种内容共用一张表,只是**用到的字段不同**,形态由所属分区的 `view_mode` 决定:
|
||||
|
||||
| 类型 | 常用字段 |
|
||||
| --- | --- |
|
||||
| 应用 | `name` `description` `icon` `href` `cover` `badge` `banner` `hot` |
|
||||
| 电影 | `name`(前端叫 `title`)`cover` `summary` `comment` `href` + `entry_tag` |
|
||||
| 文章 | `name`(前端叫 `title`)`description` `cover` `body`(Markdown)+ `entry_tag` |
|
||||
|
||||
#### 正文格式
|
||||
|
||||
`entry.body` 存的是 Markdown,渲染层只支持正文实际用到的子集:
|
||||
|
||||
| 写法 | 效果 |
|
||||
| --- | --- |
|
||||
| 空行分段 | 一个段落 |
|
||||
| `## 小标题` | 正文小标题(`#`~`######` 都渲染成同级) |
|
||||
| 单独一行 `` | 配图 |
|
||||
| `**粗体**`、`` `代码` ``、`[文字](链接)` | 行内格式 |
|
||||
|
||||
列表、表格、引用块暂不支持;需要完整语法时把 [`ArticleBody.tsx`](stash-web/src/components/content/ArticleBody.tsx)
|
||||
换成 react-markdown 即可,渲染层是隔离的。
|
||||
|
||||
#### entry_tag —— 行内文本列表(标签 / 元信息)
|
||||
|
||||
| 字段 | 类型 | 说明 / 前端用途 |
|
||||
| --- | --- | --- |
|
||||
| `entry_key` | TEXT | 所属条目编码,外键 → `entry.key` |
|
||||
| `sort` | INTEGER | 展示顺序 |
|
||||
| `label` | TEXT | 标签文字(如「推荐电影」「2 年前」) |
|
||||
|
||||
主键 `(entry_key, sort)`。海报的 `tags` 与文章的 `meta` 都存这里——它们都是「有序字符串列表」,
|
||||
前端只是在不同版式里渲染。
|
||||
|
||||
#### section_block —— 首页卡片的展示配置
|
||||
|
||||
| 字段 | 类型 | 说明 / 前端用途 |
|
||||
| --- | --- | --- |
|
||||
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
|
||||
| `node_key` | TEXT | 要展示的节点编码,外键 → `node.key`;通常是分组,电影/文章填分区自己 |
|
||||
| `title` | TEXT? | 块标题;为空时用节点 `title` |
|
||||
| `preview_limit` | INTEGER? | 卡片显示几条;为空时用分类默认值 |
|
||||
| `sort` | INTEGER | 卡片顺序 |
|
||||
|
||||
**不存所属分区**:一张卡片属于哪个分区,由 `node_key` 推导——节点是分组就取 `parent_key`,
|
||||
节点是分区就取它自己。所以这一列没必要重复存。
|
||||
|
||||
一张卡片展示「你选定的那个分组」,而不是把分区下所有分组混在一起;
|
||||
点卡片标题或「···」都打开这个块指向的节点。
|
||||
|
||||
### 表关系
|
||||
|
||||
```text
|
||||
node (kind=section) 1 ──── n node (kind=group)
|
||||
│ │
|
||||
└────────── n entry ───────┘
|
||||
│
|
||||
├──── n entry_tag (标签 / 元信息)
|
||||
└──── n section_block (首页卡片,指向节点)
|
||||
|
||||
section_block ──── 1 node(分区由 node 自己推导,不单独存)
|
||||
```
|
||||
|
||||
| 关系 | 外键 | 级联 |
|
||||
| --- | --- | --- |
|
||||
| 分区 → 分组 | `node.parent_key → node.key` | 删分区连带删分组 |
|
||||
| 节点 → 条目 | `entry.node_key → node.key` | 删分区或分组连带删条目;改编码时条目自动跟随 |
|
||||
| 条目 → 标签 | `entry_tag.entry_key → entry.key` | 删条目删标签 |
|
||||
| 展示块 → 节点 | `section_block.node_key → node.key` | 删被展示的节点连带删该卡片 |
|
||||
|
||||
几条约束值得记住:
|
||||
|
||||
- **API 只暴露业务编码**:响应里的 `id` 都是 `key`,数字主键只在库内使用;
|
||||
- `entry.node_key`:应用指向分组,海报与图文直接指向分区;
|
||||
- 条目 `key` 是主键,全局唯一;
|
||||
- **侧栏就是内容树**:分区展开是它的分组,名称取自 `node.title`,不再有别名或跨区编排;
|
||||
- 首页卡片展示哪个分组由 `section_block.node_key` 决定,改一行即可换内容。
|
||||
|
||||
### 前端怎么用这些表
|
||||
|
||||
| 界面 | 用到的表 | 关键字段 |
|
||||
| --- | --- | --- |
|
||||
| 首页分区卡片 | `node`(section) + `section_block` + `entry` | 卡片标题用展示块标题(默认被展示节点的 `title`),色条用分区 `bar_color`;预览条目取该节点下的前 N 条 |
|
||||
| 分类明细页 | `node`(section/group) + `entry` | `icon`(hero 图标)、分区 `title` 或分组 `title`、条目卡片字段 |
|
||||
| 应用详情 | `entry`(apps) | `name` `description` `icon` `href` `cover` `banner` `badge` `comment` `views` `likes` |
|
||||
| 海报详情 | `entry` + `entry_tag` | `name`(→`title`)`cover` `comment` `summary` `href` + `tags` |
|
||||
| 图文详情 | `entry` + `entry_tag` | `name`(→`title`)`description` `cover` `body` + `meta` |
|
||||
| 侧栏导航 | `node` | 走 `parent_key` 的分区 + 分组,`show_in_sidebar = 0` 的节点跳过;「本地服务」由前端排除 |
|
||||
| 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 |
|
||||
|
||||
### 侧栏导航
|
||||
|
||||
侧栏不再是单独一层数据,**直接由内容树派生**:
|
||||
|
||||
```text
|
||||
常用推荐(section,可折叠)
|
||||
├── 常用站点(group → 打开该分组)
|
||||
├── UI 设计资源
|
||||
├── 矢量图标
|
||||
├── 免费壁纸
|
||||
└── 开发工具
|
||||
推荐电影(section,无分组 → 直接打开)
|
||||
CES 现场报道(section,无分组 → 直接打开)
|
||||
```
|
||||
|
||||
规则很简单:
|
||||
|
||||
- 分区有分组时渲染成可折叠节点,子项就是分组;
|
||||
- 分区没有分组时(电影 / 文章)整行是链接;
|
||||
- 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层;
|
||||
- `show_in_sidebar = 0` 的节点不出现(种子把「常用站点」设成了 0,因为它已经是首页卡片);
|
||||
- 「本地服务」是前端拼出来的分类,侧栏按 id 直接排除。
|
||||
|
||||
### 首页展示块
|
||||
|
||||
首页卡片展示什么由 `section_block` 决定,不再把分区下所有分组混排:
|
||||
|
||||
```sql
|
||||
-- 常用推荐卡片改成展示「UI 设计资源」分组,最多 6 条
|
||||
UPDATE section_block
|
||||
SET node_key = 'ui', preview_limit = 6
|
||||
WHERE node_key = 'common';
|
||||
```
|
||||
|
||||
每张卡片一行,`node_key` 指向要展示的节点;它属于哪个分区由节点自己决定。
|
||||
点卡片标题或「···」都会打开这个节点;电影 / 文章这类没有分组的,`node_key` 填分区自己的编码。
|
||||
|
||||
**卡片标题跟着展示内容走**:配了展示块时用块标题(没配则用节点 `title`),
|
||||
所以「常用推荐」这张卡片会显示成「常用站点」——它实际展示的就是这个分组。
|
||||
需要单独起名时,写 `section_block.title` 即可。
|
||||
|
||||
### 自己改内容
|
||||
|
||||
1. 用任何 SQLite 工具(DB Browser for SQLite、`sqlite3` CLI、DBeaver…)打开 `data/stash.db`;
|
||||
2. 内容改 `node`(分区与分组)与 `entry`(文章正文就是 `entry.body` 里的 Markdown),标签改 `entry_tag`,首页展示改 `section_block`;
|
||||
3. 提交修改后,页面会在 **5 秒内**自动刷新——服务端轮询 `PRAGMA data_version` 检测外部写入;
|
||||
4. 也可以点侧栏的「重新扫描」或直接刷新浏览器。
|
||||
|
||||
新增条目时 `id` 不用填,`key` 必须写(全局唯一);`node_key` 填直接所在的分组编码
|
||||
(没有分组就填分区编码),所属分区由它推导。
|
||||
先 `SELECT key, kind, parent_key FROM node` 看一眼现有编码即可。
|
||||
|
||||
### 重置
|
||||
|
||||
开发阶段表结构还会调整,**没有实现迁移**。要重置内容:关掉服务后删掉 `data/stash.db`,
|
||||
下次启动会重新建表并灌入内嵌种子。
|
||||
|
||||
```bash
|
||||
# 托管默认目录 ./public,监听 127.0.0.1:8080
|
||||
cargo run -p stash-cli
|
||||
|
||||
# 托管指定目录并修改端口
|
||||
cargo run -p stash-cli -- --root ./sites --port 3000
|
||||
|
||||
# 允许局域网设备访问
|
||||
cargo run -p stash-cli -- --root ./sites --host 0.0.0.0 --port 3000
|
||||
|
||||
# 关闭 CORS
|
||||
cargo run -p stash-cli -- --root ./sites --no-cors
|
||||
rm data/stash.db # Linux / macOS
|
||||
Remove-Item data\stash.db # Windows PowerShell
|
||||
```
|
||||
|
||||
启动成功后访问终端中显示的地址即可,例如:
|
||||
库结构版本记在 `PRAGMA user_version` 里(当前为 `15`)。新版本二进制遇到旧库时会**直接拒绝启动**
|
||||
并提示删库,而不是拿旧表执行新查询:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8080/
|
||||
[FATAL] 启动失败: 数据库错误: 内容库结构版本为 14,当前程序需要 15。
|
||||
开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。
|
||||
```
|
||||
|
||||
### 构建发布版本
|
||||
`SCHEMA_VERSION` 定义在 [`src/db/schema.rs`](src/db/schema.rs),改动表结构时把它 +1。
|
||||
|
||||
## 前端开发
|
||||
|
||||
```bash
|
||||
cargo build --release -p stash-cli
|
||||
cd stash-web
|
||||
npm install
|
||||
npm run dev # http://127.0.0.1:5173,/api 自动代理到后端 8080
|
||||
npm run build # 产物输出到 stash-web/dist
|
||||
npm run lint # oxlint
|
||||
```
|
||||
|
||||
构建产物为 `target/release/stash`;Windows 下为 `target/release/stash.exe`。
|
||||
|
||||
## 托管目录示例
|
||||
|
||||
### 单个静态站点
|
||||
|
||||
```text
|
||||
public/
|
||||
├── index.html
|
||||
├── assets/
|
||||
│ ├── app.js
|
||||
│ └── style.css
|
||||
└── images/
|
||||
└── logo.png
|
||||
```
|
||||
|
||||
此时访问 `/` 会直接展示 `public/index.html`。
|
||||
|
||||
### 多个前端项目
|
||||
|
||||
```text
|
||||
public/
|
||||
├── admin-console/
|
||||
│ └── index.html
|
||||
├── docs/
|
||||
│ └── index.html
|
||||
└── landing-page/
|
||||
└── index.html
|
||||
```
|
||||
|
||||
由于根目录没有 `index.html`,访问 `/` 时会展示项目导航页。对应访问地址为:
|
||||
|
||||
```text
|
||||
/admin-console/
|
||||
/docs/
|
||||
/landing-page/
|
||||
```
|
||||
开发时前后端分开跑:一个终端 `cargo run`,另一个终端 `npm run dev`。
|
||||
控制台的用户资料目前是占位数据(见 `stash-web/src/profile.ts`),后端尚未提供鉴权接口。
|
||||
|
||||
## 命令行参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `-r, --root <DIR>` | `./public` | 静态页面托管根目录,支持相对路径和绝对路径 |
|
||||
| `-p, --port <PORT>` | `8080` | HTTP 服务监听端口 |
|
||||
| `--host <HOST>` | `127.0.0.1` | 监听地址;使用 `0.0.0.0` 可供局域网访问 |
|
||||
| `--no-listing` | 关闭 | 设计用于关闭根目录项目导航页 |
|
||||
| `-r, --root <DIR>` | `./public` | 静态页面托管根目录 |
|
||||
| `--data <DIR>` | 二进制同级 `data/` | 内容库所在目录(存放 `stash.db`) |
|
||||
| `-p, --port <PORT>` | `8080` | 监听端口 |
|
||||
| `--host <HOST>` | `127.0.0.1` | 监听地址,`0.0.0.0` 供局域网访问 |
|
||||
| `--no-cors` | 关闭 | 关闭全局 CORS 标头注入 |
|
||||
| `-h, --help` | - | 查看命令行帮助 |
|
||||
| `-V, --version` | - | 查看程序版本 |
|
||||
| `-h, --help` | - | 帮助 |
|
||||
| `-V, --version` | - | 版本 |
|
||||
|
||||
注意:`--no-listing` 当前已由命令行解析并写入 `Config.directory_listing`,但根路径处理器尚未读取该配置。因此根目录没有 `index.html` 时,导航页目前仍会显示。
|
||||
`--no-listing` 参数目前仍能被解析,但由于控制台已改为内嵌前端,它不再影响任何行为。
|
||||
|
||||
## HTTP API
|
||||
|
||||
@@ -155,40 +395,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 +421,97 @@ GET /api/projects/events
|
||||
Accept: text/event-stream
|
||||
```
|
||||
|
||||
连接建立后,服务会立即发送当前项目快照,随后在文件监听检测到项目列表变化时推送新快照:
|
||||
连接建立后立即推送一次当前快照,之后在项目列表变化时推送:
|
||||
|
||||
```text
|
||||
event: projects
|
||||
data: {"projects":[{"name":"docs","route_path":"/docs/"}]}
|
||||
```
|
||||
|
||||
服务端会递归监听托管目录,将短时间内的连续文件事件合并为一次扫描,并且只在项目名称或访问路径实际变化时广播。SSE 连接每 15 秒发送一次保活消息,浏览器断开后会自动重连。
|
||||
服务端递归监听托管目录,把短时间内的连续事件合并成一次扫描,只在项目名称或访问路径真正变化时广播;
|
||||
每 15 秒发送一次保活消息,浏览器断开后自动重连。
|
||||
|
||||
## 项目结构
|
||||
### 内容目录
|
||||
|
||||
```http
|
||||
GET /api/catalog
|
||||
```
|
||||
|
||||
返回控制台需要的全部分类(结构见 [`types.ts`](stash-web/src/data/types.ts)),
|
||||
数据来自 `data/stash.db`。一次全量下发,前端搜索在客户端完成:
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"id": "recommend",
|
||||
"kind": "apps",
|
||||
"title": "常用推荐",
|
||||
"barColor": "#3858f6",
|
||||
"icon": "🔖",
|
||||
"block": { "nodeKey": "common", "previewLimit": 4 },
|
||||
"groups": [
|
||||
{
|
||||
"id": "common",
|
||||
"name": "常用站点",
|
||||
"items": [
|
||||
{ "id": "github", "name": "GitHub", "desc": "代码托管与开源社区", "views": 429 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
「本地服务」分类**不在**这个接口里,它由 `/api/projects` 扫描托管目录后在前端拼接。
|
||||
|
||||
每个分类上的 `block` 就是首页卡片的展示配置:`nodeKey` 指向要展示的节点(通常是分组),
|
||||
`previewLimit` 是卡片里显示几条。侧栏不再单独下发,前端直接用 `categories` 里的
|
||||
分区与分组渲染。
|
||||
|
||||
### 订阅内容库变化
|
||||
|
||||
```http
|
||||
GET /api/catalog/events
|
||||
Accept: text/event-stream
|
||||
```
|
||||
|
||||
当内容库被**其它连接**(例如用户用 DB Browser 改库)提交后推送一次,事件本身不带数据:
|
||||
|
||||
```text
|
||||
.
|
||||
├── Cargo.toml # Rust workspace 配置
|
||||
├── Cargo.lock # 依赖锁定文件
|
||||
├── README.md
|
||||
├── stash-cli/
|
||||
│ ├── Cargo.toml
|
||||
│ └── src/
|
||||
│ └── main.rs # CLI 参数解析、启动信息和停机信号
|
||||
└── stash-core/
|
||||
├── Cargo.toml
|
||||
├── assets/
|
||||
│ ├── dashboard.css # 控制台与 404 页面样式
|
||||
│ └── dashboard.js # 项目列表刷新增强
|
||||
├── src/
|
||||
│ ├── config.rs # 服务配置
|
||||
│ ├── error.rs # 错误类型与 HTTP 错误响应
|
||||
│ ├── lib.rs # 核心库导出
|
||||
│ ├── listing.rs # 项目扫描与页面渲染
|
||||
│ ├── server.rs # Axum 路由、SSE、静态托管与服务器生命周期
|
||||
│ └── watcher.rs # 文件监听、事件防抖和项目快照广播
|
||||
└── templates/
|
||||
├── index.html # Askama 多项目导航页模板
|
||||
└── 404.html # Askama 404 页面模板
|
||||
event: catalog
|
||||
data: {"changed":true}
|
||||
```
|
||||
|
||||
客户端收到后重新 `GET /api/catalog`。检测基于 SQLite 的 `PRAGMA data_version`,每 5 秒轮询一次。
|
||||
|
||||
## 测试
|
||||
|
||||
运行核心库测试:
|
||||
|
||||
```bash
|
||||
cargo test -p stash-core
|
||||
cargo test # 目录扫描、隐藏目录过滤、排序、路径编码、文件监听广播、内容库、SPA 回落
|
||||
cd stash-web && npm run build # tsc -b + vite build,同时做类型检查
|
||||
```
|
||||
|
||||
当前测试覆盖项目目录扫描、隐藏目录过滤、一级目录限制、项目排序、项目路径编码、空状态渲染、项目内容 HTML 转义、404 页面渲染与路径转义,以及新增项目后的文件监听广播。
|
||||
内容库相关测试全部使用**内存库**(`:memory:`),不写磁盘、不污染 `data/`。
|
||||
如果运行环境限制了系统临时目录(部分沙箱会这样),前几个测试会报 `PermissionDenied`,
|
||||
把 `TEMP` / `TMP` 指到工作区内再跑即可:
|
||||
|
||||
```powershell
|
||||
$env:TEMP = "$PWD\target\test-tmp"; $env:TMP = $env:TEMP; cargo test
|
||||
```
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 项目扫描只检查托管根目录的一级子目录,不递归查找更深层项目。
|
||||
- 根目录存在 `index.html` 时会优先进入单站点模式,不再展示项目导航页。
|
||||
- 静态服务只为目录自动追加 `index.html`;导航扫描虽然也识别 `index.htm`,但建议项目统一使用 `index.html`。
|
||||
- 未配置 SPA History API 回退。刷新前端路由等不存在的物理路径时,会返回 404 页面。
|
||||
- SSE 仅用于服务端向浏览器推送项目快照,不提供浏览器向服务端发送命令的双向通道。
|
||||
- 文件事件会经过约 150ms 防抖后再扫描,因此项目出现在导航页中可能有短暂延迟。
|
||||
- 启动横幅中显示的接口地址为 `/api/pages`,实际实现的项目列表接口是 `/api/projects`。
|
||||
- 服务没有身份认证机制,默认仅建议绑定 `127.0.0.1`。使用 `0.0.0.0` 暴露到局域网前,应确认目录内容适合被其他设备访问。
|
||||
- 前端路由只有 `/c/**` 一个前缀;新增一类页面时,`stash-web/src/data/view.ts` 与
|
||||
`src/server.rs` 的 `SPA_PREFIXES` 必须一起改,否则刷新那条地址会拿到 404 页面。
|
||||
- 前进 / 后退能回到对应页面,但不恢复滚动位置:切换视图统一滚回内容区顶部。
|
||||
- 内嵌资源占用了 `/assets/` 前缀,托管目录下如果存在同名目录会被遮蔽。
|
||||
- `/` 固定由控制台占用,上一版「根目录有 `index.html` 就直接当单站点展示」的行为已取消;
|
||||
托管目录里的项目统一走 `/<目录名>/`。
|
||||
- 项目扫描只检查一级子目录,不递归查找更深层项目。
|
||||
- 文件事件经过约 150ms 防抖,新项目出现在控制台可能有短暂延迟。
|
||||
- 内容库没有迁移机制:表结构变化时需要删掉 `data/stash.db` 重建(当前属于开发阶段)。
|
||||
- 内容库被外部工具修改后,页面最长有 5 秒延迟才自动刷新。
|
||||
- 内容库按单实例使用设计:多个进程同时写同一个库会在 `busy_timeout`(5 秒)后报错。
|
||||
- 没有身份认证,默认只建议绑定 `127.0.0.1`;使用 `0.0.0.0` 暴露到局域网前请确认目录内容适合被其他设备访问。
|
||||
|
||||
+202
-1
@@ -1,9 +1,20 @@
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::error::StashError;
|
||||
|
||||
/// 数据目录名:与二进制同级,存放可被用户直接修改的内容库。
|
||||
pub const DATA_DIR_NAME: &str = "data";
|
||||
/// 内容库文件名:固定为这个名字,一个实例一个库。
|
||||
pub const DB_FILE_NAME: &str = "stash.db";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// 静态页面托管根目录(存放一个或多个前端项目)
|
||||
pub root: PathBuf,
|
||||
/// 数据目录:存放内容库与将来的备份 / 导出文件
|
||||
pub data_dir: PathBuf,
|
||||
/// 内容库路径,固定为 `data/stash.db`
|
||||
pub db_path: PathBuf,
|
||||
/// 监听端口
|
||||
pub port: u16,
|
||||
/// 监听地址,默认 127.0.0.1
|
||||
@@ -16,8 +27,13 @@ pub struct Config {
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
// 默认值只在测试与兜底路径上使用,真实启动路径由 main.rs 解析
|
||||
let data_dir = PathBuf::from(DATA_DIR_NAME);
|
||||
|
||||
Self {
|
||||
root: PathBuf::from("../public"),
|
||||
db_path: resolve_db_path(&data_dir),
|
||||
data_dir,
|
||||
port: 8080,
|
||||
host: "127.0.0.1".to_string(),
|
||||
directory_listing: true,
|
||||
@@ -25,3 +41,188 @@ impl Default for Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据目录的默认位置:**二进制所在目录**下的 `data/`。
|
||||
///
|
||||
/// 之所以不用当前工作目录,是因为双击启动、快捷方式启动时 CWD 并不可控
|
||||
/// (可能落在 System32 这种没写权限的位置),而数据库属于程序自身状态,
|
||||
/// 跟着 exe 走最稳。托管目录 `--root` 仍然保持 CWD 相对,行为不变。
|
||||
pub fn default_data_dir() -> PathBuf {
|
||||
default_data_dir_from(std::env::current_exe().ok().as_deref())
|
||||
}
|
||||
|
||||
/// `default_data_dir` 的纯函数版本,便于在测试里用假路径验证。
|
||||
fn default_data_dir_from(exe: Option<&Path>) -> PathBuf {
|
||||
match exe.and_then(Path::parent) {
|
||||
// 拿不到 exe 路径时退化成 CWD 相对目录,至少不会 panic
|
||||
Some(dir) => dir.join(DATA_DIR_NAME),
|
||||
None => PathBuf::from(DATA_DIR_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
/// 由数据目录推导出内容库路径。
|
||||
pub fn resolve_db_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(DB_FILE_NAME)
|
||||
}
|
||||
|
||||
/// 校验内容库没有落在静态托管目录里。
|
||||
///
|
||||
/// `ServeDir` 会把 `--root` 下的所有文件当静态资源对外提供,
|
||||
/// 一旦库文件在其中,任何人都能直接下载整个数据库,属于必须拦截的配置错误。
|
||||
pub fn validate_db_location(root: &Path, db_path: &Path) -> Result<(), StashError> {
|
||||
let root = normalize(root);
|
||||
let db = normalize(db_path);
|
||||
|
||||
if db.starts_with(&root) {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"内容库不能放在托管目录内部:库={},托管目录={}。请用 --data 指定其它位置。",
|
||||
db.display(),
|
||||
root.display()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 去掉 Windows `canonicalize` 加上的 `\\?\` 前缀。
|
||||
///
|
||||
/// 该前缀本身合法,但会让 `Path::starts_with` 把它们当成不同的路径前缀:
|
||||
/// `\\?\D:\sites` 与 `D:\sites` 的 `Prefix` 组件不相等,
|
||||
/// 于是「库是否在托管目录内」的校验会被绕过。比较之前必须统一。
|
||||
pub fn strip_verbatim(path: &Path) -> PathBuf {
|
||||
match path.as_os_str().to_string_lossy().strip_prefix(r"\\?\") {
|
||||
Some(stripped) => PathBuf::from(stripped),
|
||||
None => path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示用路径:去掉 `\\?\` 前缀后再转成字符串。
|
||||
///
|
||||
/// 前缀对程序无害,但打印在启动横幅和日志里很碍眼。
|
||||
pub fn display_path(path: &Path) -> String {
|
||||
strip_verbatim(path).display().to_string()
|
||||
}
|
||||
|
||||
/// 把路径转成绝对路径,并消掉 `.` / `..` 与重复分隔符。///
|
||||
/// 这里刻意不做 `canonicalize`:校验发生在目录创建之前,
|
||||
/// 目标文件往往还不存在,而 `canonicalize` 要求路径真实存在。
|
||||
/// 纯字符串层面的归一化已经足够判断「谁在谁内部」。
|
||||
fn normalize(path: &Path) -> PathBuf {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(path)
|
||||
};
|
||||
|
||||
// `canonicalize` 产出的 `\\?\` 前缀会让后续 starts_with 比较失效,先统一掉
|
||||
let absolute = strip_verbatim(&absolute);
|
||||
|
||||
let mut normalized = PathBuf::new();
|
||||
|
||||
for component in absolute.components() {
|
||||
match component {
|
||||
// 当前目录不产生任何变化
|
||||
Component::CurDir => {}
|
||||
// 父目录回退一级;已经到根时 pop 是空操作,正好不会越界
|
||||
Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
other => normalized.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn data_dir_follows_the_binary_directory() {
|
||||
let dir = default_data_dir_from(Some(Path::new(r"D:\apps\stash\stash.exe")));
|
||||
assert_eq!(dir, PathBuf::from(r"D:\apps\stash\data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_dir_falls_back_to_relative_when_exe_path_is_unknown() {
|
||||
assert_eq!(default_data_dir_from(None), PathBuf::from(DATA_DIR_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_path_is_fixed_inside_the_data_dir() {
|
||||
assert_eq!(
|
||||
resolve_db_path(Path::new(r"D:\apps\stash\data")),
|
||||
PathBuf::from(r"D:\apps\stash\data\stash.db")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_database_nested_inside_the_hosted_root() {
|
||||
let err = validate_db_location(
|
||||
Path::new(r"D:\sites"),
|
||||
Path::new(r"D:\sites\data\stash.db"),
|
||||
);
|
||||
assert!(err.is_err(), "库在托管目录内时必须拒绝启动");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_database_beside_the_hosted_root() {
|
||||
assert!(
|
||||
validate_db_location(
|
||||
Path::new(r"D:\stash\public"),
|
||||
Path::new(r"D:\stash\data\stash.db"),
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn location_check_survives_dot_segments() {
|
||||
// `..` 归一化之后应认出这是同一个目录,而不是被绕过
|
||||
assert!(
|
||||
validate_db_location(
|
||||
Path::new(r"D:\stash\sites"),
|
||||
Path::new(r"D:\stash\sites\..\sites\stash.db"),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// 回归测试:`canonicalize` 会给路径加 `\\?\` 前缀,
|
||||
/// 若比较前不统一,库落在托管目录内这种配置错误会被漏过。
|
||||
#[test]
|
||||
fn location_check_ignores_verbatim_prefix() {
|
||||
assert!(
|
||||
validate_db_location(
|
||||
Path::new(r"\\?\D:\stash\sites"),
|
||||
Path::new(r"D:\stash\sites\data\stash.db"),
|
||||
)
|
||||
.is_err(),
|
||||
"带 \\\\?\\ 前缀的托管目录也必须能拦住库"
|
||||
);
|
||||
|
||||
assert!(
|
||||
validate_db_location(
|
||||
Path::new(r"D:\stash\sites"),
|
||||
Path::new(r"\\?\D:\stash\sites\data\stash.db"),
|
||||
)
|
||||
.is_err(),
|
||||
"带 \\\\?\\ 前缀的库路径也必须能被识别"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verbatim_prefix_is_stripped() {
|
||||
assert_eq!(
|
||||
strip_verbatim(Path::new(r"\\?\D:\apps\stash")),
|
||||
PathBuf::from(r"D:\apps\stash")
|
||||
);
|
||||
assert_eq!(
|
||||
strip_verbatim(Path::new(r"D:\apps\stash")),
|
||||
PathBuf::from(r"D:\apps\stash")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
//! 内容库的查询组装:把统一的 node 树还原成前端 `types.ts` 想要的三种形态。
|
||||
//!
|
||||
//! 库里只有一套容器树(分区 → 分组),前端需要 `apps / posters / articles`
|
||||
//! 的可辨识联合,那是**查询层按 `node.view_mode` 组装**的事。
|
||||
//!
|
||||
//! 所有关联都走业务键(`key`),SQL 里能直接看懂指向的是哪个分区 / 分组。
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{
|
||||
db::{Catalog, map_error},
|
||||
error::StashError,
|
||||
};
|
||||
|
||||
/// 容器节点行:分区与分组共用
|
||||
struct NodeRow {
|
||||
key: String,
|
||||
title: String,
|
||||
view_mode: Option<String>,
|
||||
bar_color: Option<String>,
|
||||
icon: Option<String>,
|
||||
preview_limit: Option<i64>,
|
||||
empty_text: Option<String>,
|
||||
show_in_sidebar: bool,
|
||||
}
|
||||
|
||||
/// 条目行:与 `entry` 表的列一一对应
|
||||
struct EntryRow {
|
||||
key: String,
|
||||
node_key: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
icon: Option<String>,
|
||||
href: Option<String>,
|
||||
cover: Option<String>,
|
||||
banner: Option<String>,
|
||||
badge: Option<String>,
|
||||
comment: Option<String>,
|
||||
summary: Option<String>,
|
||||
body: Option<String>,
|
||||
hot: bool,
|
||||
views: i64,
|
||||
likes: i64,
|
||||
}
|
||||
|
||||
/// 读出整个内容目录。
|
||||
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
|
||||
Ok(Catalog {
|
||||
categories: load_sections(conn)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。
|
||||
fn load_sections(conn: &Connection) -> Result<Vec<Value>, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar
|
||||
FROM node
|
||||
WHERE kind = 'section'
|
||||
ORDER BY sort, key",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let sections = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(NodeRow {
|
||||
key: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
view_mode: row.get(2)?,
|
||||
bar_color: row.get(3)?,
|
||||
icon: row.get(4)?,
|
||||
preview_limit: row.get(5)?,
|
||||
empty_text: row.get(6)?,
|
||||
show_in_sidebar: row.get(7)?,
|
||||
})
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
// 先释放语句再发后续查询,避免同一连接上的借用冲突
|
||||
drop(stmt);
|
||||
|
||||
// 每个分区对应的首页卡片:展示哪个节点、几条
|
||||
let blocks = load_blocks(conn)?;
|
||||
|
||||
sections
|
||||
.iter()
|
||||
.map(|section| build_section(conn, section, blocks.get(§ion.key)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 读出所有卡片配置,按推导出的分区业务键索引。
|
||||
///
|
||||
/// 分区不单独存:节点是分组就取它的 `parent_key`,节点是分区就取它自己。
|
||||
/// `title` 同样在 SQL 里解析:卡片自己配了标题就用它,否则用被展示节点的标题。
|
||||
fn load_blocks(conn: &Connection) -> Result<HashMap<String, Value>, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT COALESCE(p.key, n.key) AS section_key,
|
||||
b.node_key,
|
||||
COALESCE(b.title, n.title),
|
||||
b.preview_limit
|
||||
FROM section_block b
|
||||
JOIN node n ON n.key = b.node_key
|
||||
LEFT JOIN node p ON p.key = n.parent_key
|
||||
ORDER BY b.sort, b.node_key",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<i64>>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let mut blocks = HashMap::new();
|
||||
for (section_key, node_key, title, preview_limit) in rows {
|
||||
let mut block = Map::new();
|
||||
block.insert("nodeKey".into(), json!(node_key));
|
||||
block.insert("title".into(), json!(title));
|
||||
if let Some(limit) = preview_limit {
|
||||
block.insert("previewLimit".into(), json!(limit));
|
||||
}
|
||||
// 同一分区配了多个卡片时只保留第一个:当前 UI 一张卡片展示一个节点
|
||||
blocks.entry(section_key).or_insert(Value::Object(block));
|
||||
}
|
||||
|
||||
Ok(blocks)
|
||||
}
|
||||
|
||||
/// 按 `view_mode` 分派到不同的组装方式。
|
||||
fn build_section(
|
||||
conn: &Connection,
|
||||
section: &NodeRow,
|
||||
block: Option<&Value>,
|
||||
) -> Result<Value, StashError> {
|
||||
let view_mode = section
|
||||
.view_mode
|
||||
.as_deref()
|
||||
.ok_or_else(|| StashError::Database(format!("分区 {} 缺少 view_mode", section.key)))?;
|
||||
|
||||
let mut object = Map::new();
|
||||
object.insert("id".into(), json!(section.key));
|
||||
object.insert("kind".into(), json!(view_mode));
|
||||
object.insert("title".into(), json!(section.title));
|
||||
object.insert(
|
||||
"barColor".into(),
|
||||
json!(section.bar_color.clone().unwrap_or_default()),
|
||||
);
|
||||
object.insert("icon".into(), json!(section.icon.clone().unwrap_or_default()));
|
||||
object.insert("showInSidebar".into(), json!(section.show_in_sidebar));
|
||||
|
||||
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
|
||||
if let Some(limit) = section.preview_limit {
|
||||
object.insert("previewLimit".into(), json!(limit));
|
||||
}
|
||||
if let Some(empty_text) = §ion.empty_text {
|
||||
object.insert("emptyText".into(), json!(empty_text));
|
||||
}
|
||||
if let Some(block) = block {
|
||||
object.insert("block".into(), block.clone());
|
||||
}
|
||||
|
||||
match view_mode {
|
||||
"apps" => object.insert("groups".into(), build_groups(conn, section)?),
|
||||
"posters" => object.insert("items".into(), build_poster_items(conn, section)?),
|
||||
"articles" => object.insert("items".into(), build_article_items(conn, section)?),
|
||||
other => {
|
||||
return Err(StashError::Database(format!(
|
||||
"分区 {} 的 view_mode 非法: {other}",
|
||||
section.key
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Value::Object(object))
|
||||
}
|
||||
|
||||
/// 应用类:取出分组节点,再把条目按 `node_key` 分派进去。
|
||||
fn build_groups(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT key, title, show_in_sidebar FROM node
|
||||
WHERE kind = 'group' AND parent_key = ?1
|
||||
ORDER BY sort, key",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let groups = stmt
|
||||
.query_map([§ion.key], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, bool>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
drop(stmt);
|
||||
|
||||
let entries = load_entries(conn, §ion.key)?;
|
||||
let mut rendered_groups = Vec::with_capacity(groups.len());
|
||||
|
||||
for (group_key, group_title, show_in_sidebar) in groups {
|
||||
let items = entries
|
||||
.iter()
|
||||
// 保持 entry.sort 决定的顺序
|
||||
.filter(|entry| entry.node_key == group_key)
|
||||
.map(app_item)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rendered_groups.push(json!({
|
||||
// 对外用业务键,不用数字主键
|
||||
"id": group_key,
|
||||
"name": group_title,
|
||||
"showInSidebar": show_in_sidebar,
|
||||
"items": items,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Value::Array(rendered_groups))
|
||||
}
|
||||
|
||||
/// 海报类:竖版卡片要 `title` / `cover` / `tags`。
|
||||
fn build_poster_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
|
||||
let entries = load_entries(conn, §ion.key)?;
|
||||
let tags = load_tags(conn, §ion.key)?;
|
||||
|
||||
let items = entries
|
||||
.iter()
|
||||
.map(|entry| poster_item(entry, tags.get(&entry.key)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Value::Array(items))
|
||||
}
|
||||
|
||||
/// 图文类:需要 `title` / `desc` / `cover` / `meta` / `body`。
|
||||
fn build_article_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
|
||||
let entries = load_entries(conn, §ion.key)?;
|
||||
let tags = load_tags(conn, §ion.key)?;
|
||||
|
||||
let items = entries
|
||||
.iter()
|
||||
.map(|entry| article_item(entry, tags.get(&entry.key)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Value::Array(items))
|
||||
}
|
||||
|
||||
/// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。
|
||||
fn app_item(entry: &EntryRow) -> Value {
|
||||
let mut object = Map::new();
|
||||
object.insert("id".into(), json!(entry.key));
|
||||
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));
|
||||
}
|
||||
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
/// 可选字段:`None` 就不下发,前端用 `??` 兜底。
|
||||
fn insert_optional(object: &mut Map<String, Value>, key: &str, value: &Option<String>) {
|
||||
if let Some(value) = value {
|
||||
object.insert(key.into(), json!(value));
|
||||
}
|
||||
}
|
||||
|
||||
/// 取某个分区下的全部条目,按 `sort` 升序。
|
||||
///
|
||||
/// 条目只存 `node_key`,所属分区由父节点推导:
|
||||
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
|
||||
fn load_entries(conn: &Connection, section_key: &str) -> Result<Vec<EntryRow>, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT e.key, e.node_key, e.name, e.description, e.icon, e.href, e.cover,
|
||||
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes
|
||||
FROM entry e
|
||||
JOIN node p ON p.key = e.node_key
|
||||
WHERE COALESCE(p.parent_key, p.key) = ?1
|
||||
ORDER BY e.sort, e.key",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let entries = stmt
|
||||
.query_map([section_key], |row| {
|
||||
Ok(EntryRow {
|
||||
key: row.get(0)?,
|
||||
node_key: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
icon: row.get(4)?,
|
||||
href: row.get(5)?,
|
||||
cover: row.get(6)?,
|
||||
banner: row.get(7)?,
|
||||
badge: row.get(8)?,
|
||||
comment: row.get(9)?,
|
||||
summary: row.get(10)?,
|
||||
body: row.get(11)?,
|
||||
hot: row.get(12)?,
|
||||
views: row.get(13)?,
|
||||
likes: row.get(14)?,
|
||||
})
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// 取某个分区下所有条目的标签,按条目业务键聚合;一次查询避免 N+1。
|
||||
fn load_tags(conn: &Connection, section_key: &str) -> Result<HashMap<String, Vec<String>>, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT t.entry_key, t.label
|
||||
FROM entry_tag t
|
||||
JOIN entry e ON e.key = t.entry_key
|
||||
JOIN node p ON p.key = e.node_key
|
||||
WHERE COALESCE(p.parent_key, p.key) = ?1
|
||||
ORDER BY t.entry_key, t.sort",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([section_key], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for (entry_key, label) in rows {
|
||||
grouped.entry(entry_key).or_default().push(label);
|
||||
}
|
||||
|
||||
Ok(grouped)
|
||||
}
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
//! 内容库(SQLite)。
|
||||
//!
|
||||
//! 三条设计约束:
|
||||
//!
|
||||
//! 1. **单文件**:库固定是 `data/stash.db`,用 `journal_mode=DELETE`,
|
||||
//! 不会像 WAL 那样在磁盘上多出 `-wal` / `-shm` 两个旁文件。
|
||||
//! 2. **可编辑**:用户可以用任何 SQLite 工具直接改库;服务只读,不独占文件。
|
||||
//! 3. **开发期不做迁移**:表结构还在变,改结构就删掉 `data/stash.db` 重启,
|
||||
//! 启动时会重新建表并灌入内嵌种子。
|
||||
|
||||
mod catalog;
|
||||
mod schema;
|
||||
|
||||
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;
|
||||
|
||||
/// 一次查询得到的完整内容目录。
|
||||
///
|
||||
/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。
|
||||
#[derive(Debug)]
|
||||
pub struct Catalog {
|
||||
/// 分类树,结构对应前端 `types.ts` 的 `Category[]`
|
||||
pub categories: Vec<Value>,
|
||||
}
|
||||
|
||||
/// 内容库句柄。
|
||||
///
|
||||
/// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来:
|
||||
/// 本地工具并发极低,单连接足够,没必要引入连接池。
|
||||
/// 所有耗时操作都应放在 `spawn_blocking` 里调用(见 server.rs)。
|
||||
pub struct Db {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// 打开(必要时创建)内容库。
|
||||
///
|
||||
/// 顺序很重要:先建目录,再连库,再设 PRAGMA,最后建表 / 判断是否需要灌种子。
|
||||
pub fn open(config: &Config) -> Result<Self, StashError> {
|
||||
std::fs::create_dir_all(&config.data_dir)?;
|
||||
|
||||
let mut conn = Connection::open(&config.db_path).map_err(map_error)?;
|
||||
|
||||
// busy_timeout 让用户用外部工具改库时的短暂写锁变成等待而不是立刻失败
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = DELETE;
|
||||
PRAGMA synchronous = FULL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA busy_timeout = 5000;",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
// 先校验结构版本,再动任何东西:旧库直接报错,避免用旧表跑新查询
|
||||
let version = schema::check_version(&conn)?;
|
||||
|
||||
schema::apply(&conn)?;
|
||||
|
||||
// 写入当前结构版本,供下次启动比对
|
||||
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
|
||||
.map_err(map_error)?;
|
||||
|
||||
// 空库才灌种子:用户删掉内置条目后重启不会被重新塞回来
|
||||
let existing: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0))
|
||||
.map_err(map_error)?;
|
||||
|
||||
if existing == 0 {
|
||||
// 全新库是常态;版本号为 0 但库里已经有数据属于异常(比如手工清表),给条提示
|
||||
if version != 0 {
|
||||
warn!("结构版本为 {version} 但节点表为空,将按空库重新导入种子");
|
||||
}
|
||||
|
||||
let report = schema::seed(&mut conn)?;
|
||||
info!(
|
||||
"内容库为空,已导入内嵌种子:{} 分区 / {} 分组 / {} 条目 / {} 标签 / {} 展示块",
|
||||
report.sections, report.groups, report.entries, report.tags, report.blocks
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"内容库已存在,沿用现有数据:{}",
|
||||
crate::config::display_path(&config.db_path)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// 读出完整的内容目录,供 `/api/catalog` 直接序列化。
|
||||
pub fn load_catalog(&self) -> Result<Catalog, StashError> {
|
||||
let conn = self.lock()?;
|
||||
catalog::load(&conn)
|
||||
}
|
||||
|
||||
/// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。
|
||||
///
|
||||
/// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。
|
||||
pub fn data_version(&self) -> Result<i64, StashError> {
|
||||
let conn = self.lock()?;
|
||||
conn.pragma_query_value(None, "data_version", |row| row.get::<_, i64>(0))
|
||||
.map_err(map_error)
|
||||
}
|
||||
|
||||
/// 取连接;锁中毒说明此前某个线程 panic 过,此时继续用库并不安全。
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>, StashError> {
|
||||
self.conn
|
||||
.lock()
|
||||
.map_err(|_| StashError::Database("数据库连接锁已损坏,请重启进程".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
|
||||
pub(crate) fn map_error(error: rusqlite::Error) -> StashError {
|
||||
StashError::Database(error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Db {
|
||||
/// 测试用:包一个空的内存库。
|
||||
///
|
||||
/// 路由测试只关心静态资源与回落规则,不查内容,所以既不建表也不灌种子。
|
||||
pub(crate) fn in_memory() -> Self {
|
||||
Self {
|
||||
conn: Mutex::new(Connection::open_in_memory().expect("内存库应能打开")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 建一个内存库并灌种子;内存库不落盘,测试不受临时目录权限影响。
|
||||
fn seeded() -> Connection {
|
||||
let mut conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")
|
||||
.expect("PRAGMA 应能设置");
|
||||
schema::apply(&conn).expect("建表应成功");
|
||||
schema::seed(&mut conn).expect("种子导入应成功");
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_fills_every_section_group_and_entry() {
|
||||
let conn = seeded();
|
||||
|
||||
let sections: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM node WHERE kind = 'section'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能统计分区");
|
||||
let groups: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM node WHERE kind = 'group'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能统计分组");
|
||||
let entries: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
|
||||
.expect("应能统计条目");
|
||||
let blocks: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
|
||||
.expect("应能统计展示块");
|
||||
let tags: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0))
|
||||
.expect("应能统计标签");
|
||||
let articles_with_body: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entry WHERE body IS NOT NULL",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能统计带正文的条目");
|
||||
|
||||
assert_eq!(sections, 3, "种子应含 常用推荐 / 推荐电影 / CES 三个分区");
|
||||
assert_eq!(groups, 5, "常用推荐应有 5 个分组");
|
||||
assert_eq!(entries, 42, "18 应用 + 12 电影 + 12 图文");
|
||||
// 12 部电影各 2 个 tags,12 篇图文各 2 个 meta
|
||||
assert_eq!(tags, 48, "标签与元信息共用 entry_tag");
|
||||
// 正文是一篇一段 Markdown,只有 12 篇文章有
|
||||
assert_eq!(articles_with_body, 12, "只有图文条目带正文");
|
||||
assert_eq!(blocks, 3, "每个分区一个首页展示块");
|
||||
}
|
||||
|
||||
/// 分区与分组是同一张表:分组必须挂在分区下,且不能有 view_mode。
|
||||
#[test]
|
||||
fn groups_are_nodes_under_their_section() {
|
||||
let conn = seeded();
|
||||
|
||||
let (parent_key, view_mode): (String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT p.key, g.view_mode
|
||||
FROM node g
|
||||
JOIN node p ON p.key = g.parent_key
|
||||
WHERE g.kind = 'group' AND g.key = 'ui'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.expect("应能查到分组及其父节点");
|
||||
|
||||
assert_eq!(parent_key, "recommend");
|
||||
assert!(view_mode.is_none(), "分组不应带 view_mode");
|
||||
|
||||
// 分区一定是顶层节点
|
||||
let root_sections: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM node WHERE kind = 'section' AND parent_key IS NULL",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能统计数据");
|
||||
assert_eq!(root_sections, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_matches_the_frontend_model() {
|
||||
let conn = seeded();
|
||||
let catalog = catalog::load(&conn).expect("应能组装内容目录");
|
||||
let categories = &catalog.categories;
|
||||
|
||||
assert_eq!(categories.len(), 3);
|
||||
|
||||
let apps = &categories[0];
|
||||
assert_eq!(apps["id"], "recommend");
|
||||
assert_eq!(apps["kind"], "apps");
|
||||
assert_eq!(apps["barColor"], "#3858f6");
|
||||
assert_eq!(apps["showInSidebar"], true);
|
||||
assert_eq!(apps["groups"].as_array().expect("应有分组").len(), 5);
|
||||
|
||||
// 常用站点只作为首页卡片展示,不进侧栏
|
||||
assert_eq!(
|
||||
apps["groups"][0]["showInSidebar"], false,
|
||||
"常用站点不应出现在侧栏"
|
||||
);
|
||||
assert_eq!(apps["groups"][1]["showInSidebar"], true);
|
||||
|
||||
let first_app = &apps["groups"][0]["items"][0];
|
||||
assert_eq!(first_app["name"], "GitHub");
|
||||
assert_eq!(first_app["hot"], true);
|
||||
// 计数从 JSON 提到了独立列,这里确认读取时又合并回来了
|
||||
assert_eq!(first_app["views"], 429);
|
||||
|
||||
let posters = &categories[1];
|
||||
assert_eq!(posters["kind"], "posters");
|
||||
// 海报与图文用 title 而不是 name
|
||||
assert_eq!(
|
||||
posters["items"][0].get("name"),
|
||||
None,
|
||||
"海报不应同时下发 name 字段"
|
||||
);
|
||||
assert!(posters["items"][0]["title"].is_string());
|
||||
// tags 来自 entry_tag 子表
|
||||
assert_eq!(
|
||||
posters["items"][0]["tags"]
|
||||
.as_array()
|
||||
.expect("海报应有标签")
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
|
||||
let articles = &categories[2];
|
||||
assert_eq!(articles["kind"], "articles");
|
||||
// 正文是一整段 Markdown,而不是块数组
|
||||
let body = articles["items"][0]["body"]
|
||||
.as_str()
|
||||
.expect("图文应有正文");
|
||||
assert!(body.contains("## 设计细节"), "正文应含小标题: {body}");
|
||||
assert!(body.contains("!["), "正文应含配图语法: {body}");
|
||||
assert!(body.contains("\n\n"), "段落之间用空行分隔");
|
||||
assert!(
|
||||
articles["items"][0].get("blocks").is_none(),
|
||||
"不应再下发 blocks 数组"
|
||||
);
|
||||
// meta 与海报的 tags 共用 entry_tag 表
|
||||
assert_eq!(
|
||||
articles["items"][0]["meta"]
|
||||
.as_array()
|
||||
.expect("图文应有元信息")
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
|
||||
// ---- 首页展示块 ----
|
||||
// 常用推荐卡片展示「常用站点」分组,最多 4 条
|
||||
assert_eq!(apps["block"]["nodeKey"], "common");
|
||||
assert_eq!(apps["block"]["previewLimit"], 4);
|
||||
// 电影/文章没有分组,块直接指向分区自己
|
||||
assert_eq!(posters["block"]["nodeKey"], "movie");
|
||||
assert_eq!(articles["block"]["nodeKey"], "ces");
|
||||
}
|
||||
|
||||
/// 删除分区时,它的展示块一起消失(外键级联),不会留下指向空分区的卡片。
|
||||
#[test]
|
||||
fn section_blocks_cascade_with_their_section() {
|
||||
let conn = seeded();
|
||||
conn.execute("DELETE FROM node WHERE key = 'recommend' AND kind = 'section'", [])
|
||||
.expect("应能删除分区");
|
||||
|
||||
let remaining: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
|
||||
.expect("应能统计展示块");
|
||||
|
||||
assert_eq!(remaining, 2, "三个展示块应随分区删除只剩两个");
|
||||
}
|
||||
|
||||
/// 删除分组节点时,组内条目也要一起删掉。
|
||||
#[test]
|
||||
fn entries_cascade_when_their_group_is_removed() {
|
||||
let conn = seeded();
|
||||
|
||||
let before: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
|
||||
.expect("应能统计条目");
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM node WHERE kind = 'group' AND key = 'ui' AND parent_key = 'recommend'",
|
||||
[],
|
||||
)
|
||||
.expect("应能删除分组");
|
||||
|
||||
let after: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
|
||||
.expect("应能统计条目");
|
||||
|
||||
assert_eq!(before - after, 5, "ui 分组原有 5 个应用,应被级联删除");
|
||||
}
|
||||
|
||||
/// 主键是 `id`(SQLite rowid),`key` 是唯一业务键;外键仍指向 key。
|
||||
#[test]
|
||||
fn primary_key_is_id_and_key_is_unique() {
|
||||
let conn = seeded();
|
||||
|
||||
for table in ["node", "entry", "section_block"] {
|
||||
let pk: String = conn
|
||||
.query_row(
|
||||
&format!("SELECT name FROM pragma_table_info('{table}') WHERE pk = 1"),
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能查到主键列");
|
||||
assert_eq!(pk, "id", "{table} 的主键应为 id");
|
||||
}
|
||||
|
||||
let duplicate = conn.execute(
|
||||
"INSERT INTO entry (key, node_key, name)
|
||||
VALUES ('github', 'common', '重复')",
|
||||
[],
|
||||
);
|
||||
assert!(duplicate.is_err(), "key 必须唯一");
|
||||
}
|
||||
|
||||
/// `key` 是业务键:不允许重复,否则详情页按它查找会命中错行。
|
||||
#[test]
|
||||
fn entry_key_is_unique() {
|
||||
let conn = seeded();
|
||||
let duplicate = conn.execute(
|
||||
"INSERT INTO entry (key, node_key, name)
|
||||
SELECT key, node_key, '重复条目'
|
||||
FROM entry WHERE key = 'github'",
|
||||
[],
|
||||
);
|
||||
|
||||
assert!(duplicate.is_err(), "重复的条目 key 必须被拒绝");
|
||||
}
|
||||
|
||||
/// 条目不再存所属分区,分区由 `node_key` 推导:
|
||||
/// 改分区业务键后,它的分组与条目应当自动归到新键下。
|
||||
#[test]
|
||||
fn section_membership_is_derived_from_parent() {
|
||||
let conn = seeded();
|
||||
|
||||
conn.execute("UPDATE node SET key = 'recommend-v2' WHERE key = 'recommend'", [])
|
||||
.expect("应能改分区业务键");
|
||||
|
||||
let catalog = catalog::load(&conn).expect("应能组装内容目录");
|
||||
let apps = catalog
|
||||
.categories
|
||||
.iter()
|
||||
.find(|category| category["id"] == "recommend-v2")
|
||||
.expect("应能找到改名后的分区");
|
||||
|
||||
let total: usize = apps["groups"]
|
||||
.as_array()
|
||||
.expect("应有分组")
|
||||
.iter()
|
||||
.map(|group| group["items"].as_array().expect("应有条目").len())
|
||||
.sum();
|
||||
|
||||
assert_eq!(total, 18, "改名后条目仍应归在这个分区下");
|
||||
}
|
||||
|
||||
/// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。
|
||||
#[test]
|
||||
fn entry_fields_are_columns_not_json_blobs() {
|
||||
let conn = seeded();
|
||||
|
||||
let (name, href, cover, hot, views): (String, String, String, i64, i64) = conn
|
||||
.query_row(
|
||||
"SELECT name, href, cover, hot, views FROM entry WHERE key = 'github'",
|
||||
[],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.expect("应能按列读取条目");
|
||||
|
||||
assert_eq!(name, "GitHub");
|
||||
assert_eq!(href, "#");
|
||||
assert!(cover.starts_with("https://"));
|
||||
assert_eq!(hot, 1, "布尔字段以 0/1 存储");
|
||||
assert_eq!(views, 429);
|
||||
|
||||
// 旧结构里那个 data 列已经不存在了
|
||||
let has_data_column: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('entry') WHERE name = 'data'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能查询表结构");
|
||||
assert_eq!(has_data_column, 0, "entry 不应再有 data JSON 列");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_version_is_readable() {
|
||||
let db = Db {
|
||||
conn: Mutex::new(seeded()),
|
||||
};
|
||||
assert!(db.data_version().expect("应能读取 data_version") >= 0);
|
||||
}
|
||||
|
||||
/// 全新库(user_version = 0)允许直接建表;当前版本也允许。
|
||||
#[test]
|
||||
fn accepts_fresh_or_current_schema_version() {
|
||||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||
|
||||
assert_eq!(
|
||||
schema::check_version(&conn).expect("全新库应放行"),
|
||||
0,
|
||||
"新库的 user_version 应为 0"
|
||||
);
|
||||
|
||||
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
|
||||
.expect("应能写入版本");
|
||||
assert_eq!(
|
||||
schema::check_version(&conn).expect("当前版本应放行"),
|
||||
schema::SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/// 结构变过之后,旧库必须被拒绝并提示删库,而不是用旧表跑新查询。
|
||||
#[test]
|
||||
fn rejects_database_from_older_schema() {
|
||||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION - 1)
|
||||
.expect("应能写入旧版本号");
|
||||
|
||||
let error = schema::check_version(&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,404 @@
|
||||
//! 内容库的表结构与种子导入。
|
||||
//!
|
||||
//! 两条设计原则:
|
||||
//!
|
||||
//! 1. **一个容器树**:分区(section)与分组(group)是同一种东西——「装条目的容器」,
|
||||
//! 合并在 `node` 一张自引用表里,用 `kind` 区分层级。
|
||||
//! 2. **内容与展示分离**:`nav_item` 不存内容,只存侧栏的排列(哪个节点、叫什么、放在哪)。
|
||||
//!
|
||||
//! 命名规则:
|
||||
//!
|
||||
//! - 所有表用**业务编码 `code`** 做主键(`recommend` / `ui` / `github`),不再有数字 id;
|
||||
//! - 外键统一写成 `<目标>_code`:`parent_code`、`section_code`、`node_code`、`entry_code`;
|
||||
//! - 改编码时下游由 `ON UPDATE CASCADE` 自动跟随。
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{db::map_error, error::StashError};
|
||||
|
||||
/// 种子文件在编译期内嵌,分发时不需要额外带数据文件。
|
||||
const SEED_JSON: &str = include_str!("seed.json");
|
||||
|
||||
/// 建表语句。
|
||||
///
|
||||
/// 开发阶段结构还会调整,所以这里只做 `IF NOT EXISTS` 式的«建库»,
|
||||
/// 不实现版本迁移:需要改结构时删掉 `data/stash.db` 重启即可,
|
||||
/// 种子会自动重新灌一份干净数据。
|
||||
const SCHEMA_SQL: &str = r#"
|
||||
-- 内容节点:分区与分组共用一张自引用树
|
||||
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_code 必须为 NULL
|
||||
-- kind = 'group' → 分区下的分组,parent_code 指向所属分区
|
||||
-- view_mode 只对分区有意义,决定首页与明细页怎么渲染
|
||||
CREATE TABLE IF NOT EXISTS node (
|
||||
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT)
|
||||
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui)
|
||||
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
-- 父节点业务键:分组指向分区,分区为 NULL
|
||||
title TEXT NOT NULL, -- 展示名:分区标题 / 分组小标题
|
||||
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
|
||||
-- 容器层级
|
||||
view_mode TEXT CHECK (view_mode IN ('apps', 'posters', 'articles')),
|
||||
-- 分区渲染形态;分组为 NULL
|
||||
bar_color TEXT, -- 分区标题左侧色条颜色(分组为 NULL)
|
||||
icon TEXT, -- 分区 hero 字符图标(分组为 NULL)
|
||||
preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认
|
||||
empty_text TEXT, -- 分区为空时的提示文案
|
||||
show_in_sidebar INTEGER NOT NULL DEFAULT 1 CHECK (show_in_sidebar IN (0, 1)),
|
||||
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
|
||||
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
|
||||
CHECK (
|
||||
(kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL)
|
||||
OR (kind = 'group' AND parent_key IS NOT NULL AND view_mode IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS node_parent_idx ON node(parent_key, sort);
|
||||
|
||||
-- 条目:网站 / 电影 / 文章都在这张表里,基础字段各占一列
|
||||
CREATE TABLE IF NOT EXISTS entry (
|
||||
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
|
||||
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 github / movie-1)
|
||||
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
-- 直接所属容器:应用是分组,海报/图文是分区本身
|
||||
-- 所属分区不单独存:由 node_key 推导(分组看它的 parent_key)
|
||||
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 -- 条目顺序(组内或分区内),越小越靠前
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entry_parent_idx ON entry(node_key, sort);
|
||||
|
||||
-- 条目的行内文本列表:海报的 tags、图文的 meta 都放这里(有序、一对多)
|
||||
CREATE TABLE IF NOT EXISTS entry_tag (
|
||||
entry_key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
-- 所属条目业务键
|
||||
sort INTEGER NOT NULL DEFAULT 0, -- 展示顺序
|
||||
label TEXT NOT NULL, -- 标签文字(如「推荐电影」「2 年前」)
|
||||
PRIMARY KEY (entry_key, sort)
|
||||
);
|
||||
|
||||
-- 首页卡片:一行一个卡片,说明展示哪个节点
|
||||
-- node_key 指向要展示的节点(通常是分组;电影/文章这类无分组的填分区自己)
|
||||
-- 「属于哪个分区」不单独存:分组看 parent_key,分区就是它自己,查询时推导
|
||||
CREATE TABLE IF NOT EXISTS section_block (
|
||||
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
|
||||
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
-- 要展示的节点业务键
|
||||
title TEXT, -- 块标题;NULL 时用节点 title
|
||||
preview_limit INTEGER, -- 卡片里显示几条;NULL 时用前端默认
|
||||
sort INTEGER NOT NULL DEFAULT 0 -- 卡片顺序
|
||||
);
|
||||
"#;
|
||||
|
||||
/// 种子导入结果,仅用于启动日志。
|
||||
#[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` 里。
|
||||
///
|
||||
/// 开发阶段不做迁移:版本对不上时直接拒绝启动,让用户删库重建,
|
||||
/// 比悄悄用旧表跑新查询(最终表现为 500)好排查得多。
|
||||
/// 结构调整时把这个数字 +1。
|
||||
pub const SCHEMA_VERSION: i64 = 15;
|
||||
|
||||
/// 校验库结构版本:0 表示全新库,允许;其它非当前版本一律拒绝。
|
||||
pub fn check_version(conn: &Connection) -> Result<i64, StashError> {
|
||||
let version: i64 = conn
|
||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.map_err(map_error)?;
|
||||
|
||||
if version != 0 && version != SCHEMA_VERSION {
|
||||
return Err(StashError::Database(format!(
|
||||
"内容库结构版本为 {version},当前程序需要 {SCHEMA_VERSION}。\
|
||||
开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// 建表(幂等):每次启动都跑一遍,缺表就补,已有数据不动。
|
||||
pub fn apply(conn: &Connection) -> Result<(), StashError> {
|
||||
conn.execute_batch(SCHEMA_SQL).map_err(map_error)
|
||||
}
|
||||
|
||||
/// 把内嵌种子写进空库。
|
||||
///
|
||||
/// 只用业务编码,不需要先插入再回查自增 id:父子关系在种子文件里已经写清楚,
|
||||
/// 按「分区 → 分组 → 条目 → 侧栏」的顺序插入即可满足外键。
|
||||
pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
||||
let seed: SeedFile = serde_json::from_str(SEED_JSON)
|
||||
.map_err(|error| StashError::Database(format!("种子文件不是合法 JSON: {error}")))?;
|
||||
|
||||
let mut report = SeedReport::default();
|
||||
// 单事务写入:避免中途失败留下半个内容库
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
|
||||
for section in &seed.sections {
|
||||
let view = section.view.as_str();
|
||||
|
||||
// 只认识前端支持的三种形态,拼错时立刻报错而不是静默写脏数据
|
||||
if !matches!(view, "apps" | "posters" | "articles") {
|
||||
return Err(StashError::Database(format!(
|
||||
"种子分区 {} 的 view 非法: {view}",
|
||||
section.key
|
||||
)));
|
||||
}
|
||||
|
||||
// 只有 apps 分区才允许有分组:其余类型直接挂条目
|
||||
if view != "apps" && !section.groups.is_empty() {
|
||||
return Err(StashError::Database(format!(
|
||||
"种子分区 {} 是 {view} 类型,不应包含 groups",
|
||||
section.key
|
||||
)));
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, sort)
|
||||
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
section.key,
|
||||
section.title,
|
||||
view,
|
||||
section.bar_color,
|
||||
section.icon,
|
||||
section.preview_limit,
|
||||
section.empty_text,
|
||||
i64::from(section.show_in_sidebar),
|
||||
section.sort,
|
||||
],
|
||||
)
|
||||
.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, show_in_sidebar, sort)
|
||||
VALUES (?1, ?2, ?3, 'group', ?4, ?5)",
|
||||
params![
|
||||
group.key,
|
||||
section.key,
|
||||
group.title,
|
||||
i64::from(group.show_in_sidebar),
|
||||
group.sort
|
||||
],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
report.groups += 1;
|
||||
|
||||
for (index, entry) in group.entries.iter().enumerate() {
|
||||
insert_entry(&tx, &group.key, index, entry, &mut report)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 海报与图文没有分组,直接挂在分区上(node_key 就是分区自己)
|
||||
for (index, entry) in section.entries.iter().enumerate() {
|
||||
insert_entry(&tx, §ion.key, index, entry, &mut report)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身)
|
||||
tx.execute(
|
||||
"INSERT INTO section_block (node_key, title, preview_limit, sort)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
section.block.node_key,
|
||||
section.block.title,
|
||||
section.block.preview_limit,
|
||||
section.sort,
|
||||
],
|
||||
)
|
||||
.map_err(|error| {
|
||||
StashError::Database(format!(
|
||||
"分区 {} 的展示节点 {} 无效: {error}",
|
||||
section.key, section.block.node_key
|
||||
))
|
||||
})?;
|
||||
report.blocks += 1;
|
||||
}
|
||||
|
||||
tx.commit().map_err(map_error)?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// 写入一个条目:基础字段进 entry,行内标签进 entry_tag。
|
||||
///
|
||||
/// `node_key` 是直接容器的业务键;所属分区由它推导,不单独存。
|
||||
fn insert_entry(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
node_key: &str,
|
||||
index: usize,
|
||||
entry: &SeedEntry,
|
||||
report: &mut SeedReport,
|
||||
) -> Result<(), StashError> {
|
||||
tx.execute(
|
||||
"INSERT INTO entry (
|
||||
key, node_key, 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)",
|
||||
params![
|
||||
entry.key,
|
||||
node_key,
|
||||
entry.name,
|
||||
entry.description,
|
||||
entry.icon,
|
||||
entry.href,
|
||||
entry.cover,
|
||||
entry.banner,
|
||||
entry.badge,
|
||||
entry.comment,
|
||||
entry.summary,
|
||||
entry.body,
|
||||
i64::from(entry.hot),
|
||||
entry.views,
|
||||
entry.likes,
|
||||
index as i64,
|
||||
],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
report.entries += 1;
|
||||
|
||||
// 标签:海报的 tags 与图文的 meta 共用一张子表,按出现顺序编号
|
||||
for (sort, label) in entry.tags.iter().chain(entry.meta.iter()).enumerate() {
|
||||
tx.execute(
|
||||
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
|
||||
params![entry.key, sort as i64, label],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
report.tags += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 种子文件的反序列化结构
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 种子文件根节点
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SeedFile {
|
||||
sections: Vec<SeedSection>,
|
||||
}
|
||||
|
||||
/// 一个分区(顶层节点)
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SeedSection {
|
||||
key: String,
|
||||
/// 渲染形态:apps / posters / articles
|
||||
view: String,
|
||||
title: String,
|
||||
bar_color: String,
|
||||
icon: String,
|
||||
sort: i64,
|
||||
#[serde(default)]
|
||||
preview_limit: Option<i64>,
|
||||
#[serde(default)]
|
||||
empty_text: Option<String>,
|
||||
/// 是否出现在侧栏;默认 true
|
||||
#[serde(default = "default_true")]
|
||||
show_in_sidebar: bool,
|
||||
/// 仅 apps 分区使用
|
||||
#[serde(default)]
|
||||
groups: Vec<SeedGroup>,
|
||||
/// 非 apps 分区直接挂条目
|
||||
#[serde(default)]
|
||||
entries: Vec<SeedEntry>,
|
||||
/// 首页卡片展示哪个节点(分组编码,或分区自己的编码)
|
||||
block: SeedBlockConfig,
|
||||
}
|
||||
|
||||
/// 首页分区的展示配置
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SeedBlockConfig {
|
||||
/// 要展示的节点业务键;通常是分组,电影/文章填分区自己
|
||||
node_key: String,
|
||||
/// 块标题;空则用节点 title
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
/// 卡片显示几条;空则用前端默认
|
||||
#[serde(default)]
|
||||
preview_limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// 分区下的一个分组
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SeedGroup {
|
||||
key: String,
|
||||
title: String,
|
||||
sort: i64,
|
||||
/// 是否出现在侧栏;默认 true
|
||||
#[serde(default = "default_true")]
|
||||
show_in_sidebar: bool,
|
||||
entries: Vec<SeedEntry>,
|
||||
}
|
||||
|
||||
/// serde 默认值:侧栏默认显示
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 一条内容:字段与 `entry` 表的列一一对应。
|
||||
///
|
||||
/// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta);
|
||||
/// `body` 是文章的 Markdown 正文,一篇一段文本。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SeedEntry {
|
||||
key: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default)]
|
||||
href: Option<String>,
|
||||
#[serde(default)]
|
||||
cover: Option<String>,
|
||||
#[serde(default)]
|
||||
banner: Option<String>,
|
||||
#[serde(default)]
|
||||
badge: Option<String>,
|
||||
#[serde(default)]
|
||||
comment: Option<String>,
|
||||
#[serde(default)]
|
||||
summary: Option<String>,
|
||||
/// 文章正文(Markdown)
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
hot: bool,
|
||||
#[serde(default)]
|
||||
views: i64,
|
||||
#[serde(default)]
|
||||
likes: i64,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
meta: Vec<String>,
|
||||
}
|
||||
@@ -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文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -25,6 +25,11 @@ pub enum StashError {
|
||||
|
||||
#[error("未找到资源: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// 数据库相关失败:打开、建表、种子导入、查询。
|
||||
/// 统一收敛成一个变体,避免把 rusqlite 的类型泄漏到库的公开 API。
|
||||
#[error("数据库错误: {0}")]
|
||||
Database(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for StashError {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod listing;
|
||||
pub mod server;
|
||||
mod watcher;
|
||||
|
||||
pub use config::Config;
|
||||
pub use db::Db;
|
||||
pub use error::StashError;
|
||||
pub use listing::{AppProject, scan_projects};
|
||||
pub use server::{ServerHandle, start};
|
||||
|
||||
+33
-5
@@ -1,7 +1,11 @@
|
||||
use std::path::PathBuf;
|
||||
use clap::Parser;
|
||||
use colored::*;
|
||||
use stash::{Config, start};
|
||||
use stash::{
|
||||
Config,
|
||||
config::{default_data_dir, display_path, resolve_db_path, validate_db_location},
|
||||
start,
|
||||
};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -17,6 +21,10 @@ struct Cli {
|
||||
#[arg(short, long, default_value = "./public", value_name = "DIR")]
|
||||
root: PathBuf,
|
||||
|
||||
/// 数据目录(默认是与二进制同级的 data/,用于存放 stash.db)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
data: Option<PathBuf>,
|
||||
|
||||
/// 监听端口
|
||||
#[arg(short, long, default_value_t = 8080, value_name = "PORT")]
|
||||
port: u16,
|
||||
@@ -69,9 +77,28 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let abs_root = cli.root.canonicalize().unwrap_or_else(|_| cli.root.clone());
|
||||
|
||||
// 3. 构造 stash-core 配置并启动
|
||||
// 3. 解析数据目录:默认跟随二进制(双击启动时 CWD 不可控),可用 --data 覆盖
|
||||
let data_dir = match cli.data {
|
||||
Some(dir) => dir,
|
||||
None => default_data_dir(),
|
||||
};
|
||||
|
||||
// 先校验再建目录:配置错了就不该在磁盘上留下任何痕迹
|
||||
let db_path = resolve_db_path(&data_dir);
|
||||
if let Err(error) = validate_db_location(&abs_root, &db_path) {
|
||||
eprintln!("{} {}", "[ERROR]".red().bold(), error);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
let data_dir = data_dir.canonicalize().unwrap_or(data_dir);
|
||||
let db_path = resolve_db_path(&data_dir);
|
||||
|
||||
// 4. 构造配置并启动
|
||||
let config = Config {
|
||||
root: abs_root.clone(),
|
||||
data_dir: data_dir.clone(),
|
||||
db_path: db_path.clone(),
|
||||
port: cli.port,
|
||||
host: cli.host,
|
||||
directory_listing: !cli.no_listing,
|
||||
@@ -86,20 +113,21 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
// 4. 打印启动成功 Banner
|
||||
// 5. 打印启动成功 Banner
|
||||
println!();
|
||||
println!("{}", "==================================================".bright_blue());
|
||||
println!(" ✨ {} {}", "Stash Server".green().bold(), format!("v{}", env!("CARGO_PKG_VERSION")).dimmed());
|
||||
println!("{}", "==================================================".bright_blue());
|
||||
println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline());
|
||||
println!(" ➜ 托管路径: {}", abs_root.display().to_string().white());
|
||||
println!(" ➜ 托管路径: {}", display_path(&abs_root).white());
|
||||
println!(" ➜ 内容库: {}", display_path(&db_path).white());
|
||||
println!(" ➜ 前端资源: {}", "已内嵌 (stash-web/dist)".white());
|
||||
println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed());
|
||||
println!("{}", "--------------------------------------------------".bright_blue());
|
||||
println!(" 按 {} 停止服务", "Ctrl+C".yellow().bold());
|
||||
println!();
|
||||
|
||||
// 5. 跨平台捕获退出信号
|
||||
// 6. 跨平台捕获退出信号
|
||||
wait_for_shutdown_signal().await;
|
||||
|
||||
println!("\n{} 正在停止服务并释放端口...", "[SYS]".yellow());
|
||||
|
||||
+285
-30
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{OriginalUri, Path, State},
|
||||
http::{HeaderValue, StatusCode, header},
|
||||
http::{HeaderValue, Method, StatusCode, header},
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
@@ -17,6 +17,7 @@ use tower_http::{
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::error::StashError;
|
||||
use crate::listing::{AppProject, scan_projects};
|
||||
use crate::watcher;
|
||||
@@ -33,6 +34,8 @@ const HTML_CACHE: &str = "no-cache";
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
pub project_updates: broadcast::Sender<Vec<AppProject>>,
|
||||
/// 内容库:分类 / 分组 / 条目的唯一数据源
|
||||
pub db: Arc<Db>,
|
||||
}
|
||||
|
||||
pub struct ServerHandle {
|
||||
@@ -110,7 +113,30 @@ fn not_found_page(path: &str) -> Response {
|
||||
(StatusCode::NOT_FOUND, Html(body)).into_response()
|
||||
}
|
||||
|
||||
async fn not_found_handler(OriginalUri(uri): OriginalUri) -> Response {
|
||||
/// 前端客户端路由占用的路径前缀(见 `stash-web/src/data/view.ts`)。
|
||||
///
|
||||
/// 这些地址在服务端没有对应文件:`/c/apps` 刷新时 `ServeDir` 找不到东西,
|
||||
/// 会落到下面的兜底处理器,由这里回落到内嵌的 `index.html`,
|
||||
/// 前端再按 `location.pathname` 还原出同一页。
|
||||
///
|
||||
/// 只接管明确列出的前缀,托管目录里写错的路径仍然返回 404 页面:
|
||||
/// 把所有未命中都冒充成 200 的「万能 SPA 回落」,会让人分不清
|
||||
/// 到底是路由拼错了还是文件真的不存在。
|
||||
const SPA_PREFIXES: &[&str] = &["/c/"];
|
||||
|
||||
/// 是否为前端路由路径(没有实体文件,应该返回入口页)。
|
||||
fn is_spa_path(path: &str) -> bool {
|
||||
SPA_PREFIXES.iter().any(|prefix| path.starts_with(prefix))
|
||||
}
|
||||
|
||||
/// `ServeDir` 的兜底:前端路由回落到入口页,其余路径仍是 404 页面。
|
||||
async fn fallback_handler(OriginalUri(uri): OriginalUri, method: Method) -> Response {
|
||||
let is_page = method == Method::GET || method == Method::HEAD;
|
||||
|
||||
if is_page && is_spa_path(uri.path()) {
|
||||
return web_index().await;
|
||||
}
|
||||
|
||||
not_found_page(uri.path())
|
||||
}
|
||||
|
||||
@@ -138,6 +164,77 @@ async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
|
||||
}))
|
||||
}
|
||||
|
||||
/// API: 完整内容目录(分区 / 分组 / 条目 + 首页展示块)
|
||||
///
|
||||
/// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。
|
||||
/// 侧栏由这份数据派生,不再单独下发导航。
|
||||
async fn catalog_api(State(state): State<AppState>) -> Response {
|
||||
let db = state.db.clone();
|
||||
|
||||
// rusqlite 是同步的,查询扔进阻塞线程池,别占住 async 执行器
|
||||
match tokio::task::spawn_blocking(move || db.load_catalog()).await {
|
||||
Ok(Ok(catalog)) => Json(serde_json::json!({
|
||||
"categories": catalog.categories,
|
||||
}))
|
||||
.into_response(),
|
||||
Ok(Err(error)) => {
|
||||
// 典型原因是用户手改坏了 JSON,错误信息里已带条目 id
|
||||
warn!("读取内容库失败: {error}");
|
||||
error.into_response()
|
||||
}
|
||||
Err(error) => {
|
||||
warn!("内容库查询任务崩溃: {error}");
|
||||
StashError::Database(format!("内容库查询任务崩溃: {error}")).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SSE:内容库被外部修改时通知前端重新拉取。
|
||||
///
|
||||
/// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件:
|
||||
/// SQLite 在 journal 模式下会产生临时文件的频繁读写,文件监听会误报,
|
||||
/// 而 data_version 恰好在「其它连接提交了事务」时才变化,语义正好对上。
|
||||
async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let db = state.db.clone();
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
let mut last = db.data_version().unwrap_or(0);
|
||||
let mut ticker = tokio::time::interval(Duration::from_secs(5));
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
|
||||
let current = match db.data_version() {
|
||||
Ok(version) => version,
|
||||
Err(error) => {
|
||||
warn!("读取内容库版本失败: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 只在其它连接提交之后才推送。
|
||||
// 当前服务对内容库是只读的;将来若加入写接口,必须在那里主动广播,
|
||||
// 因为 data_version 不会因本连接自己的提交而变化。
|
||||
tracing::debug!(last, current, "内容库版本检查");
|
||||
if current == last {
|
||||
continue;
|
||||
}
|
||||
last = current;
|
||||
|
||||
// 只告诉前端「变了」,具体内容由它重新 GET /api/catalog
|
||||
yield Ok::<Event, Infallible>(
|
||||
Event::default().event("catalog").data(r#"{"changed":true}"#),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream).keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(Duration::from_secs(15))
|
||||
.text("keep-alive"),
|
||||
)
|
||||
}
|
||||
|
||||
/// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。
|
||||
async fn project_events(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let root = state.config.root.clone();
|
||||
@@ -178,6 +275,47 @@ fn project_event(projects: Vec<AppProject>) -> Event {
|
||||
})
|
||||
}
|
||||
|
||||
/// 组装路由:API + 内嵌前端 + 托管目录。
|
||||
///
|
||||
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
|
||||
/// 起监听任务,没法在单元测试里用。
|
||||
fn build_router(state: AppState) -> Router {
|
||||
let root = state.config.root.clone();
|
||||
let enable_cors = state.config.enable_cors;
|
||||
|
||||
// 顺序就是优先级:显式路由(API / 内嵌前端)→ 托管目录 → 前端路由回落 → 404
|
||||
let serve_dir = ServeDir::new(&root)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(axum::handler::HandlerWithoutStateExt::into_service(
|
||||
fallback_handler,
|
||||
));
|
||||
|
||||
let mut app = Router::new()
|
||||
.route("/api/health", get(health_check))
|
||||
.route("/api/projects", get(list_projects_api))
|
||||
.route("/api/projects/events", get(project_events))
|
||||
.route("/api/catalog", get(catalog_api))
|
||||
.route("/api/catalog/events", get(catalog_events))
|
||||
.route("/", get(web_index))
|
||||
.route("/index.html", get(web_index))
|
||||
.route("/favicon.svg", get(web_root_file))
|
||||
.route("/assets/{*path}", get(web_asset))
|
||||
.fallback_service(serve_dir)
|
||||
.with_state(state)
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
if enable_cors {
|
||||
app = app.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
);
|
||||
}
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
||||
if !config.root.exists() {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
@@ -188,39 +326,16 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
||||
let (project_updates, _) = broadcast::channel(32);
|
||||
watcher::start(config.root.clone(), project_updates.clone())?;
|
||||
|
||||
// 内容库在启动时打开:建库 / 建表 / 空库时灌种子都在这里完成
|
||||
let db = Arc::new(Db::open(&config)?);
|
||||
|
||||
let shared_state = AppState {
|
||||
config: Arc::new(config.clone()),
|
||||
project_updates,
|
||||
db,
|
||||
};
|
||||
|
||||
// 托管目录里找不到的路径,交给 404 页面而不是 SPA 回落:
|
||||
// 前端目前没有客户端路由,把 404 冒充 200 只会让排错更难。
|
||||
let serve_dir = ServeDir::new(&config.root)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(axum::handler::HandlerWithoutStateExt::into_service(
|
||||
not_found_handler,
|
||||
));
|
||||
|
||||
let mut app = Router::new()
|
||||
.route("/api/health", get(health_check))
|
||||
.route("/api/projects", get(list_projects_api))
|
||||
.route("/api/projects/events", get(project_events))
|
||||
.route("/", get(web_index))
|
||||
.route("/index.html", get(web_index))
|
||||
.route("/favicon.svg", get(web_root_file))
|
||||
.route("/assets/{*path}", get(web_asset))
|
||||
.fallback_service(serve_dir)
|
||||
.with_state(shared_state)
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
if config.enable_cors {
|
||||
app = app.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
);
|
||||
}
|
||||
let app = build_router(shared_state);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
@@ -248,3 +363,143 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
||||
local_addr,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{body::Body, http::Request};
|
||||
use std::path::PathBuf;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// 测试用的托管目录:放在 `target/` 下,不依赖系统临时目录的权限。
|
||||
fn hosted_root() -> PathBuf {
|
||||
let root =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/spa-root");
|
||||
|
||||
std::fs::create_dir_all(root.join("demo")).expect("应能创建测试项目");
|
||||
std::fs::write(
|
||||
root.join("demo/index.html"),
|
||||
"<!doctype html><title>demo</title>",
|
||||
)
|
||||
.expect("应能写入项目入口页");
|
||||
std::fs::write(root.join("note.txt"), "plain file").expect("应能写入普通文件");
|
||||
|
||||
root
|
||||
}
|
||||
|
||||
fn router() -> Router {
|
||||
let mut config = Config::default();
|
||||
config.root = hosted_root();
|
||||
// 测试不关心 CORS 标头
|
||||
config.enable_cors = false;
|
||||
|
||||
let (project_updates, _) = broadcast::channel(4);
|
||||
|
||||
build_router(AppState {
|
||||
config: Arc::new(config),
|
||||
project_updates,
|
||||
db: Arc::new(Db::in_memory()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 发一个 GET,返回(状态码,Content-Type,响应体)。
|
||||
async fn get(path: &str) -> (StatusCode, String, String) {
|
||||
let response = router()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(path)
|
||||
.body(Body::empty())
|
||||
.expect("请求应能构造"),
|
||||
)
|
||||
.await
|
||||
.expect("路由应能处理请求");
|
||||
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("应能读出响应体");
|
||||
|
||||
(
|
||||
status,
|
||||
content_type,
|
||||
String::from_utf8_lossy(&bytes).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新分类明细页:服务端没有 `/c/apps` 这个文件,必须回落到前端入口页。
|
||||
#[tokio::test]
|
||||
async fn spa_routes_fall_back_to_the_embedded_index() {
|
||||
let (status, content_type, body) = get("/c/apps").await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(
|
||||
content_type.starts_with("text/html"),
|
||||
"应返回 HTML: {content_type}"
|
||||
);
|
||||
assert!(body.contains(r#"<div id="root">"#), "应返回前端入口页");
|
||||
}
|
||||
|
||||
/// 条目详情地址(含分组)同样回落:`/c/` 前缀下的任意层级都是前端路由。
|
||||
#[tokio::test]
|
||||
async fn item_routes_fall_back_to_the_embedded_index() {
|
||||
let (status, _, body) = get("/c/apps/common/i/github").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.contains(r#"<div id="root">"#));
|
||||
|
||||
// 本地项目:条目 id 是项目名,可能带空格 / 中文,照样要能落到入口页
|
||||
let (status, _, _) = get("/c/local/local-all/i/demo").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
/// 托管目录里的真实文件优先于回落,不能被前端路由吞掉。
|
||||
#[tokio::test]
|
||||
async fn hosted_files_still_win_over_the_spa_fallback() {
|
||||
let (status, _, body) = get("/demo/").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(
|
||||
body.contains("<title>demo</title>"),
|
||||
"应直接返回项目入口页: {body}"
|
||||
);
|
||||
|
||||
let (status, _, body) = get("/note.txt").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body, "plain file");
|
||||
}
|
||||
|
||||
/// 不是前端路由的路径仍然返回 404 页面(带请求路径,便于排错)。
|
||||
#[tokio::test]
|
||||
async fn unknown_paths_still_return_the_404_page() {
|
||||
let (status, content_type, body) = get("/missing.txt").await;
|
||||
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert!(content_type.starts_with("text/html"));
|
||||
assert!(body.contains("404"));
|
||||
assert!(body.contains("/missing.txt"), "404 页面应显示请求路径");
|
||||
}
|
||||
|
||||
/// API 不能被回落影响。
|
||||
#[tokio::test]
|
||||
async fn api_routes_are_not_swallowed_by_the_fallback() {
|
||||
let (status, content_type, body) = get("/api/health").await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(content_type.starts_with("application/json"));
|
||||
assert!(body.contains("\"status\":\"ok\""));
|
||||
}
|
||||
|
||||
/// 只接管登记过的前缀:`/c` 本身与 `/css/...` 都还是普通路径。
|
||||
#[test]
|
||||
fn only_registered_prefixes_fall_back() {
|
||||
assert!(is_spa_path("/c/apps"));
|
||||
assert!(is_spa_path("/c/"));
|
||||
assert!(!is_spa_path("/c"));
|
||||
assert!(!is_spa_path("/css/app.css"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<!-- 与 Alright 主题一致的正文字体:霞鹜文楷 -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/style.min.css"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<title>Stash · 本地应用工作区</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+1099
-386
File diff suppressed because it is too large
Load Diff
+305
-98
@@ -1,119 +1,261 @@
|
||||
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 { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BackToTop } from './components/common/BackToTop'
|
||||
import { ArticleDetailView } from './components/content/ArticleDetailView'
|
||||
import { DetailView } from './components/content/DetailView'
|
||||
import { HomeView } from './components/content/HomeView'
|
||||
import { ItemDetailView } from './components/content/ItemDetailView'
|
||||
import { SiteHeader } from './components/header/SiteHeader'
|
||||
import { SearchBar } from './components/search/SearchBar'
|
||||
import { Sidebar } from './components/sidebar/Sidebar'
|
||||
import { withLocalCategory } from './data/local'
|
||||
import {
|
||||
appDetailModel,
|
||||
posterDetailModel,
|
||||
posterToRelatedItem,
|
||||
toRelatedItem,
|
||||
} from './data/detail'
|
||||
import { filterCategories } from './data/filter'
|
||||
import type { NavTarget } from './data/nav'
|
||||
import {
|
||||
type Category,
|
||||
findAppItem,
|
||||
findArticleItem,
|
||||
findPosterItem,
|
||||
relatedAppItems,
|
||||
relatedPosterItems,
|
||||
} from './data/types'
|
||||
import { useProjects } from './useProjects'
|
||||
import { useCatalog } from './useCatalog'
|
||||
import { useViewRoute } from './useViewRoute'
|
||||
import type { ItemRoute, View } from './data/view'
|
||||
import './App.css'
|
||||
|
||||
function initialOf(name: string): string {
|
||||
const [first] = Array.from(name.trim())
|
||||
return first ? first.toUpperCase() : '#'
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { status, projects, error, live, refreshing, refresh } = useProjects()
|
||||
// 内容目录来自后端 SQLite 内容库;本地服务分类仍由托管项目动态生成
|
||||
const catalog = useCatalog()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
|
||||
const { view, go, back } = useViewRoute()
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(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,
|
||||
}))
|
||||
const categories = useMemo(
|
||||
() => withLocalCategory(projects, catalog.categories),
|
||||
[projects, catalog.categories],
|
||||
)
|
||||
const visibleCategories = useMemo(
|
||||
() => filterCategories(categories, keyword),
|
||||
[categories, keyword],
|
||||
)
|
||||
|
||||
return [{ id: 'local', title: '本地服务', cards: localCards }, ...MOCK_SECTIONS]
|
||||
}, [projects])
|
||||
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区
|
||||
const sidebarCategories = useMemo(
|
||||
() =>
|
||||
categories.filter(
|
||||
(category) => category.id !== 'local' && category.showInSidebar !== false,
|
||||
),
|
||||
[categories],
|
||||
)
|
||||
|
||||
const query = keyword.trim().toLowerCase()
|
||||
const searching = query !== ''
|
||||
const detailView = view.type === 'detail' ? view : undefined
|
||||
const itemView = view.type === 'item' ? view : undefined
|
||||
|
||||
// 搜索时过滤卡片,并丢掉没有命中的分区
|
||||
const sectionsToRender = useMemo(() => {
|
||||
if (!searching) return sections
|
||||
const detailCategory =
|
||||
detailView !== undefined
|
||||
? categories.find((category) => category.id === detailView.categoryId)
|
||||
: undefined
|
||||
|
||||
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 itemCategory =
|
||||
itemView !== undefined
|
||||
? categories.find((category) => category.id === itemView.categoryId)
|
||||
: undefined
|
||||
|
||||
const noMatch = searching && sectionsToRender.length === 0
|
||||
// 应用与电影共用一个详情模型,这里按分类类型做映射
|
||||
const detailModel = useMemo(() => {
|
||||
if (itemView === undefined || itemCategory === undefined) return undefined
|
||||
|
||||
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 (itemCategory.kind === 'apps') {
|
||||
const item = findAppItem(itemCategory, itemView.itemId)
|
||||
return item !== undefined ? appDetailModel(item) : undefined
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="notice">
|
||||
<strong>正在读取项目列表…</strong>
|
||||
<p>首次加载会扫描一次托管目录。</p>
|
||||
</div>
|
||||
)
|
||||
if (itemCategory.kind === 'posters') {
|
||||
const poster = findPosterItem(itemCategory, itemView.itemId)
|
||||
return poster !== undefined ? posterDetailModel(poster) : undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="notice">
|
||||
<strong>这里还没有项目</strong>
|
||||
<p>
|
||||
在托管根目录下创建子目录并放入 <code>index.html</code>,刷新后即可在这里看到它。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
return undefined
|
||||
}, [itemView, itemCategory])
|
||||
|
||||
const relatedItems = useMemo(() => {
|
||||
if (itemView === undefined || itemCategory === undefined) return []
|
||||
|
||||
if (itemCategory.kind === 'apps') {
|
||||
return relatedAppItems(itemCategory, itemView.itemId, 4).map(toRelatedItem)
|
||||
}
|
||||
|
||||
if (itemCategory.kind === 'posters') {
|
||||
return relatedPosterItems(itemCategory, itemView.itemId, 4).map(posterToRelatedItem)
|
||||
}
|
||||
|
||||
return []
|
||||
}, [itemView, itemCategory])
|
||||
|
||||
// 切换视图时把内容区滚回顶部
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0 })
|
||||
}, [view])
|
||||
|
||||
// 桌面端滚动作在内容容器里,窄屏滚的是窗口:
|
||||
// 两处都要驱动「已滚动」状态(顶部渐隐层 + 返回顶部按钮共用)
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
const inner = scrollRef.current?.scrollTop ?? 0
|
||||
const next = Math.max(inner, window.scrollY) > 4
|
||||
setScrolled((current) => (current === next ? current : next))
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', sync, { passive: true })
|
||||
return () => window.removeEventListener('scroll', sync)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 「打开这个分区」应该落到哪个视图。
|
||||
*
|
||||
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
|
||||
* 这样卡片上的「···」和分区标题都通向卡片里实际展示的那块内容。
|
||||
*/
|
||||
const detailViewFor = (categoryId: string): View => {
|
||||
const category = categories.find((item) => item.id === categoryId)
|
||||
|
||||
if (category?.kind === 'apps' && category.block !== undefined) {
|
||||
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
|
||||
if (group !== undefined) {
|
||||
return { type: 'detail', categoryId, groupId: group.id }
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'detail', categoryId }
|
||||
}
|
||||
|
||||
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>
|
||||
const openDetail = (categoryId: string) => go(detailViewFor(categoryId))
|
||||
|
||||
{section.cards.length > 0 ? (
|
||||
<div className="grid">
|
||||
{section.cards.map((card, index) => (
|
||||
<SiteCard key={card.id} card={card} index={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
renderLocalPlaceholder()
|
||||
)}
|
||||
</section>
|
||||
/** 条目所在的分组:应用按组组织,海报 / 图文直接挂在分区下 */
|
||||
const groupOfItem = (category: Category, itemId: string): string | undefined => {
|
||||
if (category.kind !== 'apps') return undefined
|
||||
return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id
|
||||
}
|
||||
|
||||
const openItem = (categoryId: string, itemId: string) => {
|
||||
const category = categories.find((item) => item.id === categoryId)
|
||||
|
||||
go({
|
||||
type: 'item',
|
||||
categoryId,
|
||||
itemId,
|
||||
// 地址里带上条目自己的分组,刷新后「返回」才有确定的落点
|
||||
groupId: category === undefined ? undefined : groupOfItem(category, itemId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 条目详情的「返回」目标:优先回到该条目所在的分组,
|
||||
* 没有分组(海报 / 图文)时回到分区,走和侧栏一样的展示块规则。
|
||||
*/
|
||||
const detailTargetFor = (item: ItemRoute): View =>
|
||||
item.groupId === undefined
|
||||
? detailViewFor(item.categoryId)
|
||||
: { type: 'detail', categoryId: item.categoryId, groupId: item.groupId }
|
||||
|
||||
/**
|
||||
* 侧栏导航:带分组时直接打开那个分组;
|
||||
* 点分区本身时,交给 openDetail 按展示块落到对应节点。
|
||||
*/
|
||||
const navigate = (target: NavTarget) => {
|
||||
if (target.groupId === undefined) {
|
||||
openDetail(target.categoryId)
|
||||
return
|
||||
}
|
||||
|
||||
go({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
|
||||
}
|
||||
|
||||
const goHome = () => go({ type: 'home' })
|
||||
|
||||
const searching = keyword.trim() !== ''
|
||||
|
||||
const viewCategoryId = detailView?.categoryId ?? itemView?.categoryId
|
||||
|
||||
/**
|
||||
* 首屏数据还没回来时,地址指向的分类 / 条目只是「暂时查不到」,
|
||||
* 不能直接报「不存在」——刷新详情页时最容易被这个误导。
|
||||
*/
|
||||
const dataPending =
|
||||
viewCategoryId !== undefined &&
|
||||
(catalog.status === 'loading' || (viewCategoryId === 'local' && status === 'loading'))
|
||||
|
||||
/**
|
||||
* 条目详情按分类类型分流:
|
||||
* 图文走长文版式,应用 / 电影走通用的条目版式。
|
||||
*/
|
||||
function renderItemView() {
|
||||
if (itemView === undefined) return null
|
||||
if (itemCategory === undefined) {
|
||||
return (
|
||||
<p className="section-empty">
|
||||
{dataPending ? '正在读取内容库…' : '分类不存在'}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (itemCategory.kind === 'articles') {
|
||||
const article = findArticleItem(itemCategory, itemView.itemId)
|
||||
if (article === undefined) {
|
||||
return (
|
||||
<p className="section-empty">
|
||||
{dataPending ? '正在读取内容库…' : '条目不存在'}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ArticleDetailView
|
||||
article={article}
|
||||
categoryTitle={itemCategory.title}
|
||||
onBack={() => back(detailTargetFor(itemView))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (detailModel === undefined) {
|
||||
return (
|
||||
<p className="section-empty">
|
||||
{dataPending ? '正在读取内容库…' : '条目不存在'}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ItemDetailView
|
||||
breadcrumb={`${itemCategory.title} / ${detailModel.title}`}
|
||||
model={detailModel}
|
||||
related={relatedItems}
|
||||
onBack={() => back(detailTargetFor(itemView))}
|
||||
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<TopBar
|
||||
projects={projects}
|
||||
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
|
||||
<div className="site-background" aria-hidden="true" />
|
||||
|
||||
<SiteHeader
|
||||
projectCount={projects.length}
|
||||
sampleProject={projects[0]?.name}
|
||||
live={live}
|
||||
onFocusSearch={() => searchRef.current?.focus()}
|
||||
/>
|
||||
@@ -125,28 +267,93 @@ export default function App() {
|
||||
<div className="layout">
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
categories={sidebarCategories}
|
||||
live={live}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
activeCategoryId={viewCategoryId}
|
||||
activeGroupId={detailView?.groupId}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
|
||||
<main className="content">
|
||||
<div className="content-scroll" id="content-scroll">
|
||||
{/* 滚动后才淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
|
||||
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
|
||||
|
||||
<div
|
||||
className="content-scroll"
|
||||
id="content-scroll"
|
||||
ref={scrollRef}
|
||||
onScroll={(event) => {
|
||||
const next = event.currentTarget.scrollTop > 4
|
||||
setScrolled((current) => (current === next ? current : next))
|
||||
}}
|
||||
>
|
||||
<h1 className="sr-only">Stash 本地应用工作区</h1>
|
||||
|
||||
{sectionsToRender.map(renderSection)}
|
||||
|
||||
{noMatch && (
|
||||
<div className="notice">
|
||||
<strong>没有匹配「{keyword.trim()}」的内容</strong>
|
||||
<p>换个关键字试试,或清空搜索查看全部。</p>
|
||||
{status === 'error' && (
|
||||
<div className="notice notice-error" role="alert">
|
||||
<strong>连不上 Stash 服务</strong>
|
||||
<p>{error}</p>
|
||||
<button type="button" className="notice-action" onClick={refresh}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
|
||||
{catalog.status === 'error' && (
|
||||
<div className="notice notice-error" role="alert">
|
||||
<strong>内容库读取失败</strong>
|
||||
<p>{catalog.error}</p>
|
||||
<button type="button" className="notice-action" onClick={catalog.reload}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailView === undefined && itemView === undefined && (
|
||||
<>
|
||||
{catalog.status === 'loading' && (
|
||||
<p className="section-empty">正在读取内容库…</p>
|
||||
)}
|
||||
|
||||
<HomeView
|
||||
categories={visibleCategories}
|
||||
onOpenDetail={openDetail}
|
||||
onOpenItem={openItem}
|
||||
/>
|
||||
|
||||
{searching && visibleCategories.length === 0 && (
|
||||
<div className="notice">
|
||||
<strong>没有匹配「{keyword.trim()}」的内容</strong>
|
||||
<p>换个关键字试试,或清空搜索查看全部。</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{detailView !== undefined && detailCategory !== undefined && (
|
||||
<DetailView
|
||||
category={detailCategory}
|
||||
groupId={detailView.groupId}
|
||||
onBack={goHome}
|
||||
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailView !== undefined && detailCategory === undefined && (
|
||||
<p className="section-empty">
|
||||
{dataPending ? '正在读取内容库…' : '分类不存在'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{itemView !== undefined && renderItemView()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<BackToTop visible={scrolled} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+62
-1
@@ -1,3 +1,5 @@
|
||||
import type { Category } from './data/types'
|
||||
|
||||
export type Project = {
|
||||
name: string
|
||||
route_path: string
|
||||
@@ -9,7 +11,6 @@ type ProjectsPayload = {
|
||||
|
||||
const PROJECTS_ENDPOINT = '/api/projects'
|
||||
const EVENTS_ENDPOINT = '/api/projects/events'
|
||||
|
||||
/** 读取服务端扫描出的项目列表。 */
|
||||
export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
|
||||
const response = await fetch(PROJECTS_ENDPOINT, {
|
||||
@@ -67,3 +68,63 @@ export function subscribeProjects(handlers: ProjectEventsHandlers): () => void {
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 内容目录:数据来自后端的 SQLite 内容库(data/stash.db),不再是前端静态数组
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CATALOG_ENDPOINT = '/api/catalog'
|
||||
const CATALOG_EVENTS_ENDPOINT = '/api/catalog/events'
|
||||
|
||||
type CatalogPayload = {
|
||||
categories?: Category[]
|
||||
}
|
||||
|
||||
/** 内容目录的完整返回;侧栏由这份分类树派生 */
|
||||
export type Catalog = {
|
||||
categories: Category[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取完整内容目录。
|
||||
*
|
||||
* 数据量只有几十 KB,一次全量取回即可;搜索仍然在前端按关键字过滤。
|
||||
*/
|
||||
export async function fetchCatalog(signal?: AbortSignal): Promise<Catalog> {
|
||||
const response = await fetch(CATALOG_ENDPOINT, {
|
||||
signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求 ${CATALOG_ENDPOINT} 失败:HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as CatalogPayload
|
||||
|
||||
return {
|
||||
categories: Array.isArray(payload.categories) ? payload.categories : [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅内容库变更。
|
||||
*
|
||||
* 服务端用 SQLite 的 data_version 检测「外部工具改了库」,事件里不带数据,
|
||||
* 只表示「该重新拉一次了」,避免两套数据结构需要保持同步。
|
||||
*/
|
||||
export function subscribeCatalog(onChange: () => void): () => void {
|
||||
if (typeof EventSource === 'undefined') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const source = new EventSource(CATALOG_EVENTS_ENDPOINT)
|
||||
const handleChange = () => onChange()
|
||||
|
||||
source.addEventListener('catalog', handleChange)
|
||||
|
||||
return () => {
|
||||
source.removeEventListener('catalog', handleChange)
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -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,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
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ArrowUpIcon } from './Icon'
|
||||
|
||||
/**
|
||||
* 回到顶部:复刻 Alright 主题的 .scroll-toolbar .totop-button。
|
||||
*
|
||||
* - 42px 圆形、浅灰底,固定在右侧(bottom: 50%)
|
||||
* - 默认隐藏,只有内容区滚动后才淡入,回到顶部后再淡出
|
||||
*/
|
||||
type BackToTopProps = {
|
||||
/** 内容区是否已滚动(由 App 传入) */
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export function BackToTop({ visible }: BackToTopProps) {
|
||||
const handleClick = () => {
|
||||
// 桌面端滚动的是内容容器,窄屏滚动的是窗口,两处都滚一遍
|
||||
document.getElementById('content-scroll')?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`back-to-top${visible ? ' is-visible' : ''}`}
|
||||
title="回到顶部"
|
||||
aria-label="回到顶部"
|
||||
tabIndex={visible ? 0 : -1}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 数量角标:贴在分区标题右上角的小红标。
|
||||
* 纯展示组件,只接收数字,宽度由内容撑开(两位数不会变形)。
|
||||
*/
|
||||
type CountBadgeProps = {
|
||||
count: number
|
||||
}
|
||||
|
||||
export function CountBadge({ count }: CountBadgeProps) {
|
||||
return <span className="count-badge">{count}</span>
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 通用图标组件。
|
||||
*
|
||||
* 全部使用 Alright 主题的 iconfont 线性图标(见 src/iconfont.css),
|
||||
* 尺寸与颜色由使用处的 CSS 控制(font-size / color),
|
||||
* 因此同一套图标可以复用在按钮、统计条、导航等不同位置。
|
||||
*/
|
||||
|
||||
type IconProps = {
|
||||
/** 附加类名,便于局部覆盖字号或颜色 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
const base = (name: string, className?: string) =>
|
||||
`iconfont icon-${name}${className !== undefined ? ` ${className}` : ''}`
|
||||
|
||||
export function SearchIcon({ className }: IconProps) {
|
||||
return <i className={base('search', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function UserIcon({ className }: IconProps) {
|
||||
return <i className={base('user', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function RefreshIcon({ className }: IconProps) {
|
||||
return <i className={base('refresh', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function LinkIcon({ className }: IconProps) {
|
||||
return <i className={base('link', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function ExternalIcon({ className }: IconProps) {
|
||||
return <i className={base('external', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function EyeIcon({ className }: IconProps) {
|
||||
return <i className={base('eye', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function ThumbUpIcon({ className }: IconProps) {
|
||||
return <i className={base('thumb-up', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function ShareIcon({ className }: IconProps) {
|
||||
return <i className={base('share', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function CloseIcon({ className }: IconProps) {
|
||||
return <i className={base('close', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function MoreIcon({ className }: IconProps) {
|
||||
return <i className={base('more', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function ArrowUpIcon({ className }: IconProps) {
|
||||
return <i className={base('arrow-up', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function ArrowDownIcon({ className }: IconProps) {
|
||||
return <i className={base('arrow-down', className)} aria-hidden="true" />
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { AppItem } from '../../data/types'
|
||||
|
||||
/**
|
||||
* 应用卡片:逐项复刻 Alright 主题「推荐作者」条目的结构。
|
||||
*
|
||||
* 对应主题的:
|
||||
* .flex-avatar > img.avatar-48 + span.avatar-badge.badge_5
|
||||
* .item-content > h3.font-title.text-md(名字 + span.item-badge.hot)
|
||||
* + div.text-xs.text-muted.h-1x(单行描述)
|
||||
*
|
||||
* 热门条目才会同时出现头像角标和 HOT 徽标,普通条目两者都没有。
|
||||
*/
|
||||
type AppCardProps = {
|
||||
item: AppItem
|
||||
onOpen?: (itemId: string) => void
|
||||
}
|
||||
|
||||
export function AppCard({ item, onOpen }: AppCardProps) {
|
||||
return (
|
||||
<a
|
||||
className="app-card"
|
||||
href={item.href}
|
||||
title={item.name}
|
||||
onClick={(event) => {
|
||||
if (onOpen === undefined) return
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
||||
event.preventDefault()
|
||||
onOpen(item.id)
|
||||
}}
|
||||
>
|
||||
<span className="app-card-avatar" aria-hidden="true">
|
||||
{item.cover !== undefined ? (
|
||||
<img src={item.cover} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className="app-card-letter">{item.icon}</span>
|
||||
)}
|
||||
{item.hot === true && <span className="app-card-verify" />}
|
||||
</span>
|
||||
|
||||
<span className="app-card-info">
|
||||
<span className="app-card-title">
|
||||
<span className="app-card-name">{item.name}</span>
|
||||
{item.hot === true && <span className="app-card-badge" />}
|
||||
</span>
|
||||
<span className="app-card-desc">{item.desc}</span>
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AppItem } from '../../data/types'
|
||||
import { AppCard } from './AppCard'
|
||||
|
||||
/**
|
||||
* 应用网格:默认 4 列,窄屏退化为 2 列。
|
||||
* 首页预览与明细页共用,靠 modifier 调整间距。
|
||||
*/
|
||||
type AppGridProps = {
|
||||
items: AppItem[]
|
||||
/** 明细页使用更宽松的间距 */
|
||||
variant?: 'home' | 'detail'
|
||||
/** 点击卡片打开条目详情 */
|
||||
onOpen?: (itemId: string) => void
|
||||
}
|
||||
|
||||
export function AppGrid({ items, variant = 'home', onOpen }: AppGridProps) {
|
||||
return (
|
||||
<div className={`app-grid${variant === 'detail' ? ' app-grid--detail' : ''}`}>
|
||||
{items.map((item) => (
|
||||
<AppCard key={item.id} item={item} onOpen={onOpen} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* 极简 Markdown 渲染:只覆盖文章正文实际用到的子集。
|
||||
*
|
||||
* 支持:
|
||||
* - `## 小标题`(1~6 级井号都渲染成同一级小标题,标题层级由版式决定)
|
||||
* - 空行分段
|
||||
* - 单独一行的 `` 作为配图
|
||||
* - 行内 `**粗体**`、`` `代码` ``、`[文字](链接)`
|
||||
*
|
||||
* 不支持列表、表格、引用块——这里渲染的是文章正文,不是通用 Markdown 编辑器。
|
||||
* 需要完整语法时再换 react-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 (
|
||||
<h3 className="article-body-heading" key={index}>
|
||||
{renderInline(block.text)}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.kind === 'image') {
|
||||
return (
|
||||
<img
|
||||
className="article-body-image"
|
||||
key={index}
|
||||
src={block.src}
|
||||
alt={block.alt}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="article-body-text" key={index}>
|
||||
{renderInline(block.text)}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ArticleItem } from '../../data/types'
|
||||
|
||||
/**
|
||||
* 首页图文卡:4:3 封面 + 底部渐变条上的标题。
|
||||
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
|
||||
*/
|
||||
type ArticleCardProps = {
|
||||
item: ArticleItem
|
||||
onOpen?: (itemId: string) => void
|
||||
}
|
||||
|
||||
export function ArticleCard({ item, onOpen }: ArticleCardProps) {
|
||||
const content = (
|
||||
<>
|
||||
<img className="article-cover" src={item.cover} alt={item.title} loading="lazy" />
|
||||
<span className="article-caption">
|
||||
<span className="article-title">{item.title}</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (onOpen === undefined) {
|
||||
return <figure className="article-card">{content}</figure>
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="article-card"
|
||||
title={item.title}
|
||||
onClick={() => onOpen(item.id)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ArticleItem } from '../../data/types'
|
||||
import { ArticleBody } from './ArticleBody'
|
||||
|
||||
/**
|
||||
* 文章详情页(对应模板图二):
|
||||
* 大标题 + [编辑] + 元信息行 + 白色正文卡。
|
||||
*
|
||||
* 正文是一整段 Markdown,交给 ArticleBody 解析渲染;
|
||||
* 渲染细节(支持哪些语法)都收在那一个组件里。
|
||||
*/
|
||||
type ArticleDetailViewProps = {
|
||||
article: ArticleItem
|
||||
categoryTitle: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function ArticleDetailView({ article, categoryTitle, onBack }: ArticleDetailViewProps) {
|
||||
const meta = article.meta ?? ['2 年前', categoryTitle]
|
||||
|
||||
return (
|
||||
<div className="detail-view article-view">
|
||||
<div className="detail-nav card-box">
|
||||
<button type="button" className="detail-back" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
<span className="detail-breadcrumb">{categoryTitle} / 文章</span>
|
||||
</div>
|
||||
|
||||
<header className="article-head">
|
||||
<h2 className="article-heading">
|
||||
{article.title}
|
||||
<button type="button" className="item-edit" disabled title="编辑功能待接入">
|
||||
[编辑]
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
<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}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article className="article-body card-box">
|
||||
<ArticleBody markdown={article.body} />
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ArticleItem } from '../../data/types'
|
||||
import { ArticleCard } from './ArticleCard'
|
||||
|
||||
/** 图文网格:首页 4 列,窄屏 2 列 */
|
||||
type ArticleGridProps = {
|
||||
items: ArticleItem[]
|
||||
/** 点击卡片打开文章详情 */
|
||||
onOpen?: (itemId: string) => void
|
||||
}
|
||||
|
||||
export function ArticleGrid({ items, onOpen }: ArticleGridProps) {
|
||||
return (
|
||||
<div className="article-grid">
|
||||
{items.map((item) => (
|
||||
<ArticleCard key={item.id} item={item} onOpen={onOpen} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ArticleItem } from '../../data/types'
|
||||
|
||||
/**
|
||||
* 明细页图文卡:上图下文的白卡。
|
||||
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
|
||||
*/
|
||||
type CesCardProps = {
|
||||
item: ArticleItem
|
||||
onOpen?: (itemId: string) => void
|
||||
}
|
||||
|
||||
export function CesCard({ item, onOpen }: CesCardProps) {
|
||||
const content = (
|
||||
<>
|
||||
<img className="ces-cover" src={item.cover} alt={item.title} loading="lazy" />
|
||||
<span className="ces-body">
|
||||
<span className="ces-title">{item.title}</span>
|
||||
<span className="ces-desc">{item.desc}</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (onOpen === undefined) {
|
||||
return <article className="ces-card">{content}</article>
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="ces-card"
|
||||
title={item.title}
|
||||
onClick={() => onOpen(item.id)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ArticleItem } from '../../data/types'
|
||||
import { CesCard } from './CesCard'
|
||||
|
||||
/** 明细页图文网格:4 列白卡,窄屏 2 列 */
|
||||
type CesCardGridProps = {
|
||||
items: ArticleItem[]
|
||||
/** 点击卡片打开文章详情 */
|
||||
onOpen?: (itemId: string) => void
|
||||
}
|
||||
|
||||
export function CesCardGrid({ items, onOpen }: CesCardGridProps) {
|
||||
return (
|
||||
<div className="ces-grid">
|
||||
{items.map((item) => (
|
||||
<CesCard key={item.id} item={item} onOpen={onOpen} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { categoryCount, type Category } from '../../data/types'
|
||||
import { AppGrid } from './AppGrid'
|
||||
import { CesCardGrid } from './CesCardGrid'
|
||||
import { GroupBlock } from './GroupBlock'
|
||||
import { PosterGrid } from './PosterGrid'
|
||||
|
||||
/**
|
||||
* 分类明细视图:返回条 + 面包屑 + hero(图标 / 标题 / 数量)+ 内容区。
|
||||
*
|
||||
* 内容区按分类类型切换:
|
||||
* - apps:指定分组时只渲染该组,否则单组扁平渲染、多组带小标题分组渲染;
|
||||
* - posters:6 列海报;
|
||||
* - articles:上图下文白卡网格。
|
||||
*/
|
||||
type DetailViewProps = {
|
||||
category: Category
|
||||
/** 从侧栏子菜单进入时指定的分组 */
|
||||
groupId?: string
|
||||
onBack: () => void
|
||||
/** 点击卡片 / 海报进入条目详情 */
|
||||
onOpenItem: (itemId: string) => void
|
||||
}
|
||||
|
||||
export function DetailView({ category, groupId, onBack, onOpenItem }: DetailViewProps) {
|
||||
const groups = category.kind === 'apps' ? category.groups : []
|
||||
const activeGroup =
|
||||
groupId !== undefined ? groups.find((group) => group.id === groupId) : undefined
|
||||
|
||||
const title = activeGroup?.name ?? category.title
|
||||
const count = activeGroup !== undefined ? activeGroup.items.length : categoryCount(category)
|
||||
const breadcrumb =
|
||||
activeGroup !== undefined
|
||||
? `首页 / ${category.title} / ${activeGroup.name}`
|
||||
: `首页 / ${category.title}`
|
||||
|
||||
function renderBody() {
|
||||
if (category.kind === 'posters') {
|
||||
return <PosterGrid items={category.items} variant="detail" onOpen={onOpenItem} />
|
||||
}
|
||||
|
||||
if (category.kind === 'articles') {
|
||||
return <CesCardGrid items={category.items} onOpen={onOpenItem} />
|
||||
}
|
||||
|
||||
if (activeGroup !== undefined) {
|
||||
return <AppGrid items={activeGroup.items} variant="detail" onOpen={onOpenItem} />
|
||||
}
|
||||
|
||||
if (category.groups.length === 1) {
|
||||
return <AppGrid items={category.groups[0].items} variant="detail" onOpen={onOpenItem} />
|
||||
}
|
||||
|
||||
if (category.groups.length === 0) {
|
||||
return <p className="section-empty">{category.emptyText ?? '暂无内容'}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{category.groups.map((group) => (
|
||||
<GroupBlock key={group.id} group={group} onOpen={onOpenItem} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="detail-view">
|
||||
<div className="detail-nav card-box">
|
||||
<button type="button" className="detail-back" onClick={onBack}>
|
||||
返回首页
|
||||
</button>
|
||||
<span className="detail-breadcrumb">{breadcrumb}</span>
|
||||
</div>
|
||||
|
||||
<div className="detail-hero">
|
||||
<span className="detail-hero-icon" aria-hidden="true">
|
||||
{category.icon}
|
||||
</span>
|
||||
<h2 className="detail-hero-title">{title}</h2>
|
||||
<span className="detail-hero-count">[{count}]</span>
|
||||
</div>
|
||||
|
||||
{renderBody()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { AppGroup } from '../../data/types'
|
||||
import { AppGrid } from './AppGrid'
|
||||
|
||||
/**
|
||||
* 明细页的分组块:小标题 + 该组的应用网格。
|
||||
* 分类被拆成多个分组时重复使用,单个分组时明细页不渲染标题。
|
||||
*/
|
||||
type GroupBlockProps = {
|
||||
group: AppGroup
|
||||
onOpen?: (itemId: string) => void
|
||||
}
|
||||
|
||||
export function GroupBlock({ group, onOpen }: GroupBlockProps) {
|
||||
return (
|
||||
<section className="group-block">
|
||||
<h3 className="group-title">{group.name}</h3>
|
||||
<AppGrid items={group.items} variant="detail" onOpen={onOpen} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { categoryCount, flattenAppItems, type Category } from '../../data/types'
|
||||
import { AppGrid } from './AppGrid'
|
||||
import { ArticleGrid } from './ArticleGrid'
|
||||
import { PosterGrid } from './PosterGrid'
|
||||
import { SectionCard } from './SectionCard'
|
||||
|
||||
/**
|
||||
* 首页:把每个分类渲染成一张分区卡片。
|
||||
*
|
||||
* 每个分区只展示前若干条(由分类的 previewLimit 决定),
|
||||
* 右上角 ••• 进入该分类明细,点击卡片 / 海报则进入该条目的详情。
|
||||
*/
|
||||
type HomeViewProps = {
|
||||
categories: Category[]
|
||||
onOpenDetail: (categoryId: string) => void
|
||||
onOpenItem: (categoryId: string, itemId: string) => void
|
||||
}
|
||||
|
||||
export function HomeView({ categories, onOpenDetail, onOpenItem }: HomeViewProps) {
|
||||
return (
|
||||
<div className="home-view">
|
||||
{categories.map((category) => (
|
||||
<SectionCard
|
||||
key={category.id}
|
||||
// 卡片标题跟着实际展示的内容走:配了展示块就用块标题(如「常用站点」),
|
||||
// 没配(如「本地服务」)才用分类标题
|
||||
title={category.block?.title ?? category.title}
|
||||
count={cardCount(category)}
|
||||
barColor={category.barColor}
|
||||
onMore={() => onOpenDetail(category.id)}
|
||||
>
|
||||
{renderPreview(category, onOpenItem)}
|
||||
</SectionCard>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片右上角的数量角标。
|
||||
*
|
||||
* 配了「分组展示块」时,卡片实际展示的是那个分组,所以数量也应该只数它;
|
||||
* 否则(电影 / 文章 / 本地服务)数整个分区。
|
||||
*/
|
||||
function cardCount(category: Category): number {
|
||||
if (category.kind === 'apps' && category.block !== undefined) {
|
||||
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
|
||||
if (group !== undefined) return group.items.length
|
||||
}
|
||||
|
||||
return categoryCount(category)
|
||||
}
|
||||
|
||||
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
|
||||
function renderPreview(
|
||||
category: Category,
|
||||
onOpenItem: (categoryId: string, itemId: string) => void,
|
||||
) {
|
||||
if (category.kind === 'apps') {
|
||||
// 配了展示块就只展示那个分组;没有配置(如「本地服务」)则退回全部条目
|
||||
const blockGroup =
|
||||
category.block !== undefined
|
||||
? category.groups.find((group) => group.id === category.block?.nodeKey)
|
||||
: undefined
|
||||
|
||||
const source = category.block !== undefined ? (blockGroup?.items ?? []) : flattenAppItems(category)
|
||||
const limit = category.block?.previewLimit ?? category.previewLimit ?? 4
|
||||
const items = source.slice(0, limit)
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="section-empty">{category.emptyText ?? '暂无内容'}</p>
|
||||
}
|
||||
|
||||
return <AppGrid items={items} onOpen={(itemId) => onOpenItem(category.id, itemId)} />
|
||||
}
|
||||
|
||||
if (category.kind === 'posters') {
|
||||
return (
|
||||
<PosterGrid
|
||||
items={category.items.slice(0, category.previewLimit ?? 6)}
|
||||
onOpen={(itemId) => onOpenItem(category.id, itemId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ArticleGrid
|
||||
items={category.items.slice(0, category.previewLimit ?? 4)}
|
||||
onOpen={(itemId) => onOpenItem(category.id, itemId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ItemDetailModel } from '../../data/detail'
|
||||
import type { RelatedItem } from '../../data/types'
|
||||
import { ExternalIcon, EyeIcon, ShareIcon, ThumbUpIcon } from '../common/Icon'
|
||||
import { RelatedList } from './RelatedList'
|
||||
|
||||
/**
|
||||
* 条目详情页:应用与电影共用。
|
||||
*
|
||||
* 版式:标题 + 主图(方形头像 / 竖版海报)+ 标签行 + 一句话评价 + 正文
|
||||
* + 操作条(浏览 / 点赞 / 分享 / 跳转)+ 可选横幅 + 「推荐阅读」。
|
||||
* 具体形态由 ItemDetailModel 决定,这里不判断条目类型。
|
||||
*/
|
||||
type ItemDetailViewProps = {
|
||||
breadcrumb: string
|
||||
model: ItemDetailModel
|
||||
related: RelatedItem[]
|
||||
onBack: () => void
|
||||
onOpenItem: (itemId: string) => void
|
||||
}
|
||||
|
||||
export function ItemDetailView({
|
||||
breadcrumb,
|
||||
model,
|
||||
related,
|
||||
onBack,
|
||||
onOpenItem,
|
||||
}: ItemDetailViewProps) {
|
||||
return (
|
||||
<div className="detail-view item-view">
|
||||
<div className="detail-nav card-box">
|
||||
<button type="button" className="detail-back" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
<span className="detail-breadcrumb">{breadcrumb}</span>
|
||||
</div>
|
||||
|
||||
<article className="item-card card-box">
|
||||
<h2 className="item-title">
|
||||
{model.title}
|
||||
<button type="button" className="item-edit" disabled title="编辑功能待接入">
|
||||
[编辑]
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
<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 && (
|
||||
<div className="item-tags">
|
||||
{model.tags.map((tag) => (
|
||||
<span className="item-tag" key={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{model.comment !== undefined && (
|
||||
<div className="item-quote">
|
||||
<span className="item-quote-mark" aria-hidden="true">
|
||||
“
|
||||
</span>
|
||||
<p className="item-quote-text">{model.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{model.paragraphs.map((paragraph, index) => (
|
||||
<p className="item-desc" key={`${index}-${paragraph.slice(0, 8)}`}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="item-actions">
|
||||
<span className="item-stat" title="浏览量">
|
||||
<EyeIcon />
|
||||
<em>{model.views}</em>
|
||||
</span>
|
||||
|
||||
<span className="item-stat" title="点赞数">
|
||||
<ThumbUpIcon />
|
||||
<em>{model.likes}</em>
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="item-stat item-stat--icon"
|
||||
title="分享(待接入)"
|
||||
aria-label="分享"
|
||||
>
|
||||
<ShareIcon />
|
||||
</button>
|
||||
|
||||
{/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}
|
||||
{model.href === '#' ? (
|
||||
<button type="button" className="item-jump" disabled title="暂无跳转地址">
|
||||
跳转到
|
||||
<ExternalIcon />
|
||||
</button>
|
||||
) : (
|
||||
<a className="item-jump" href={model.href} target="_blank" rel="noreferrer">
|
||||
跳转到
|
||||
<ExternalIcon />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{model.banner !== undefined && (
|
||||
<img className="item-banner" src={model.banner} alt="" loading="lazy" />
|
||||
)}
|
||||
</article>
|
||||
|
||||
<RelatedList items={related} onOpen={onOpenItem} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PosterItem } from '../../data/types'
|
||||
|
||||
/**
|
||||
* 竖版海报卡:2:3 封面 + 底部渐变遮罩上的标题。
|
||||
* 传入 onOpen 时整张卡变成按钮,点击进入该电影的详情页。
|
||||
*/
|
||||
type PosterCardProps = {
|
||||
item: PosterItem
|
||||
onOpen?: (itemId: string) => void
|
||||
}
|
||||
|
||||
export function PosterCard({ item, onOpen }: PosterCardProps) {
|
||||
const content = (
|
||||
<>
|
||||
<img className="poster-cover" src={item.cover} alt={item.title} loading="lazy" />
|
||||
<span className="poster-caption">
|
||||
<span className="poster-title">{item.title}</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (onOpen === undefined) {
|
||||
return <figure className="poster-card">{content}</figure>
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="poster-card"
|
||||
title={item.title}
|
||||
onClick={() => onOpen(item.id)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PosterItem } from '../../data/types'
|
||||
import { PosterCard } from './PosterCard'
|
||||
|
||||
/** 海报网格:首页 6 列,明细页同样 6 列但间距更大 */
|
||||
type PosterGridProps = {
|
||||
items: PosterItem[]
|
||||
variant?: 'home' | 'detail'
|
||||
/** 点击海报打开条目详情 */
|
||||
onOpen?: (itemId: string) => void
|
||||
}
|
||||
|
||||
export function PosterGrid({ items, variant = 'home', onOpen }: PosterGridProps) {
|
||||
return (
|
||||
<div className={`poster-grid${variant === 'detail' ? ' poster-grid--detail' : ''}`}>
|
||||
{items.map((item) => (
|
||||
<PosterCard key={item.id} item={item} onOpen={onOpen} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 (
|
||||
<section className="related-card card-box" aria-label="推荐阅读">
|
||||
<h3 className="related-title">
|
||||
<span className="related-title-icon" aria-hidden="true">
|
||||
📚
|
||||
</span>
|
||||
推荐阅读
|
||||
</h3>
|
||||
|
||||
<ul className="related-grid">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button type="button" className="related-item" onClick={() => onOpen(item.id)}>
|
||||
<span className="related-avatar" aria-hidden="true">
|
||||
{item.cover !== undefined ? (
|
||||
<img src={item.cover} alt="" loading="lazy" />
|
||||
) : (
|
||||
(item.icon ?? '•')
|
||||
)}
|
||||
</span>
|
||||
<span className="related-info">
|
||||
<span className="related-name">{item.name}</span>
|
||||
<span className="related-desc">{item.desc}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { CountBadge } from '../common/CountBadge'
|
||||
import { MoreIcon } from '../common/Icon'
|
||||
|
||||
/**
|
||||
* 首页分区卡片:白底圆角容器,组成「色条 + 标题 + 角标 + 更多按钮 + 内容」。
|
||||
* 内容由调用方传入,因此同一容器可以承载应用网格、海报网格或图文网格。
|
||||
*/
|
||||
type SectionCardProps = {
|
||||
title: string
|
||||
count: number
|
||||
/** 标题左侧色条颜色,不同分类用不同颜色区分 */
|
||||
barColor?: string
|
||||
/** 点击右上角 ••• 进入该分类的明细视图 */
|
||||
onMore?: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SectionCard({ title, count, barColor, onMore, children }: SectionCardProps) {
|
||||
return (
|
||||
<section className="section-card">
|
||||
<header className="section-header">
|
||||
<div className="section-title-wrapper">
|
||||
<span className="section-bar" style={{ backgroundColor: barColor }} aria-hidden="true" />
|
||||
<span className="section-title-box">
|
||||
<h2 className="section-title">{title}</h2>
|
||||
<CountBadge count={count} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{onMore !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className="section-more"
|
||||
title={`查看${title}全部`}
|
||||
aria-label={`查看${title}全部`}
|
||||
onClick={onMore}
|
||||
>
|
||||
<MoreIcon />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* 顶栏中间的一条信息,形如「[标签] 文本」。
|
||||
* 标签带浅色底,用于区分「本地 / 状态」这类类型前缀。
|
||||
*/
|
||||
type HeaderInfoItemProps = {
|
||||
/** 可选的前缀标签 */
|
||||
tag?: string
|
||||
/** 标签配色:默认蓝色,状态类用绿色 */
|
||||
tone?: 'blue' | 'green'
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function HeaderInfoItem({ tag, tone = 'blue', children }: HeaderInfoItemProps) {
|
||||
return (
|
||||
<div className="header-info-item">
|
||||
{tag !== undefined && (
|
||||
<span className={`header-info-tag${tone === 'green' ? ' is-green' : ''}`}>{tag}</span>
|
||||
)}
|
||||
<span className="header-info-text">{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { SearchIcon, UserIcon } from '../common/Icon'
|
||||
import { HeaderInfoItem } from './HeaderInfoItem'
|
||||
|
||||
/**
|
||||
* 顶部栏:左品牌、中间运行信息、右侧操作图标。
|
||||
* 信息区展示「共 N 个本地应用 / 第一个项目 / 连接状态」,
|
||||
* 对应模板里的 header-center-info。
|
||||
*/
|
||||
type SiteHeaderProps = {
|
||||
projectCount: number
|
||||
/** 用于展示的第一个项目名,没有项目时留空 */
|
||||
sampleProject?: string
|
||||
live: boolean
|
||||
onFocusSearch: () => void
|
||||
}
|
||||
|
||||
export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }: SiteHeaderProps) {
|
||||
return (
|
||||
<header className="site-header">
|
||||
<div className="layout-container site-header-inner">
|
||||
<a className="site-brand" href="/" aria-label="Stash 首页">
|
||||
<span className="site-brand-mark" aria-hidden="true">
|
||||
S
|
||||
</span>
|
||||
<span className="site-brand-name">STASH</span>
|
||||
</a>
|
||||
|
||||
<div className="header-info">
|
||||
<HeaderInfoItem>共 {projectCount} 个本地应用</HeaderInfoItem>
|
||||
|
||||
{sampleProject !== undefined && (
|
||||
<HeaderInfoItem tag="本地">{sampleProject}</HeaderInfoItem>
|
||||
)}
|
||||
|
||||
<HeaderInfoItem tag="状态" tone={live ? 'green' : 'blue'}>
|
||||
{live ? '实时同步已连接' : '等待服务连接'}
|
||||
</HeaderInfoItem>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-button"
|
||||
title="搜索(Ctrl+K)"
|
||||
aria-label="聚焦搜索框"
|
||||
onClick={onFocusSearch}
|
||||
>
|
||||
<SearchIcon />
|
||||
</button>
|
||||
{/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */}
|
||||
<button type="button" className="header-icon-button" title="登录" aria-label="登录">
|
||||
<UserIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+7
-7
@@ -1,5 +1,10 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
import { CloseIcon, SearchIcon } from '../common/Icon'
|
||||
|
||||
/**
|
||||
* 全局搜索框:固定悬浮在顶栏下方居中。
|
||||
* 支持 Ctrl/Cmd+K 聚焦、Esc 清空,输入内容由父组件接管过滤逻辑。
|
||||
*/
|
||||
type SearchBarProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
@@ -22,10 +27,7 @@ export function SearchBar({ value, onChange, inputRef }: SearchBarProps) {
|
||||
|
||||
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>
|
||||
<SearchIcon className="search-icon" />
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -48,9 +50,7 @@ export function SearchBar({ value, onChange, inputRef }: SearchBarProps) {
|
||||
aria-label="清空搜索"
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState } from 'react'
|
||||
import type { NavTarget } from '../../data/nav'
|
||||
import type { Category } from '../../data/types'
|
||||
|
||||
/**
|
||||
* 分类导航树:直接由内容树派生,不再读独立的导航数据。
|
||||
*
|
||||
* - 有分组的分类(apps)渲染成可折叠节点,子项是分组(分组可用 show_in_sidebar 单独隐藏);
|
||||
* - 没有分组、或分组全被隐藏的分类,直接是链接。
|
||||
*/
|
||||
type CategoryNavProps = {
|
||||
/** 内容目录;顺序即侧栏顺序 */
|
||||
categories: Category[]
|
||||
/** 当前明细视图的分类 / 分组,用于高亮 */
|
||||
activeCategoryId?: string
|
||||
activeGroupId?: string
|
||||
onNavigate: (target: NavTarget) => void
|
||||
}
|
||||
|
||||
export function CategoryNav({
|
||||
categories,
|
||||
activeCategoryId,
|
||||
activeGroupId,
|
||||
onNavigate,
|
||||
}: CategoryNavProps) {
|
||||
const [openGroups, setOpenGroups] = useState<string[]>([])
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setOpenGroups((previous) =>
|
||||
previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id],
|
||||
)
|
||||
}
|
||||
|
||||
const isOpen = (id: string) => openGroups.includes(id)
|
||||
|
||||
return (
|
||||
<nav className="nav-card card-box" aria-label="站点分类">
|
||||
<ul className="nav-group">
|
||||
{categories.map((category) => {
|
||||
// 只显示允许出现在侧栏的分组
|
||||
const groups =
|
||||
category.kind === 'apps'
|
||||
? category.groups.filter((group) => group.showInSidebar !== false)
|
||||
: []
|
||||
|
||||
// 没有分组的分类:整行就是一个链接
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<li className="nav-item" key={category.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-link${
|
||||
activeCategoryId === category.id && activeGroupId === undefined ? ' active' : ''
|
||||
}`}
|
||||
onClick={() => onNavigate({ categoryId: category.id })}
|
||||
>
|
||||
<span>{category.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// 有分组:分区可折叠,分组是真正的链接
|
||||
return (
|
||||
<li className={`nav-item${isOpen(category.id) ? ' open' : ''}`} key={category.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-link has-children"
|
||||
aria-expanded={isOpen(category.id)}
|
||||
onClick={() => toggle(category.id)}
|
||||
>
|
||||
<span>{category.title}</span>
|
||||
</button>
|
||||
|
||||
{/* 用 grid-template-rows 做高度动画:不需要猜 max-height,
|
||||
也不会在过渡期间出现滚动条 */}
|
||||
<div className={`submenu-wrap${isOpen(category.id) ? ' is-open' : ''}`}>
|
||||
<ul className="submenu">
|
||||
{groups.map((group) => (
|
||||
<li key={group.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-link${activeGroupId === group.id ? ' active' : ''}`}
|
||||
onClick={() => onNavigate({ categoryId: category.id, groupId: group.id })}
|
||||
>
|
||||
<span>{group.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
+7
-6
@@ -1,16 +1,17 @@
|
||||
import { PLACEHOLDER_PROFILE } from '../profile'
|
||||
import { PLACEHOLDER_PROFILE } from '../../profile'
|
||||
import { UserIcon } from '../common/Icon'
|
||||
|
||||
/** 登录后展示的用户资料卡(数据为占位,见 profile.ts)。 */
|
||||
/**
|
||||
* 用户资料卡:头像、认证徽标、昵称、角色、简介、IP 属地。
|
||||
* 数据来自 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>
|
||||
<UserIcon />
|
||||
<span className="profile-badge">✓</span>
|
||||
</div>
|
||||
|
||||
+6
-6
@@ -1,6 +1,10 @@
|
||||
import type { Project } from '../api'
|
||||
import { LinkIcon, RefreshIcon } from './icons'
|
||||
import type { Project } from '../../api'
|
||||
import { LinkIcon, RefreshIcon } from '../common/Icon'
|
||||
|
||||
/**
|
||||
* 本地服务面板:连接状态、项目数、访问地址,以及两个操作入口。
|
||||
* 只读展示 + 触发刷新,不负责导航。
|
||||
*/
|
||||
type ServicePanelProps = {
|
||||
projects: Project[]
|
||||
live: boolean
|
||||
@@ -8,10 +12,6 @@ type ServicePanelProps = {
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地服务面板:状态概览 + 操作入口。
|
||||
* 项目列表本身在右侧内容区以卡片呈现,这里不再展开。
|
||||
*/
|
||||
export function ServicePanel({ projects, live, refreshing, onRefresh }: ServicePanelProps) {
|
||||
const host = typeof window === 'undefined' ? '-' : window.location.host
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Project } from '../../api'
|
||||
import type { NavTarget } from '../../data/nav'
|
||||
import type { Category } from '../../data/types'
|
||||
import { CategoryNav } from './CategoryNav'
|
||||
import { ProfileCard } from './ProfileCard'
|
||||
import { ServicePanel } from './ServicePanel'
|
||||
|
||||
/**
|
||||
* 左侧栏容器:用户资料 + 本地服务面板 + 分类导航。
|
||||
* 只负责布局与数据透传,具体内容由三个子组件分别渲染。
|
||||
*/
|
||||
type SidebarProps = {
|
||||
projects: Project[]
|
||||
/** 内容目录;侧栏直接由它派生 */
|
||||
categories: Category[]
|
||||
live: boolean
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
activeCategoryId?: string
|
||||
activeGroupId?: string
|
||||
onNavigate: (target: NavTarget) => void
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
projects,
|
||||
categories,
|
||||
live,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
activeCategoryId,
|
||||
activeGroupId,
|
||||
onNavigate,
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<ProfileCard />
|
||||
|
||||
<ServicePanel
|
||||
projects={projects}
|
||||
live={live}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
|
||||
<CategoryNav
|
||||
categories={categories}
|
||||
activeCategoryId={activeCategoryId}
|
||||
activeGroupId={activeGroupId}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Category } from './types'
|
||||
|
||||
/**
|
||||
* 按关键字过滤分类内容。
|
||||
*
|
||||
* 应用类分类会逐组过滤并丢掉空组,海报 / 图文类按标题或描述匹配;
|
||||
* 空关键字直接原样返回,避免无谓的对象重建。
|
||||
*/
|
||||
export function filterCategories(categories: Category[], keyword: string): Category[] {
|
||||
const query = keyword.trim().toLowerCase()
|
||||
if (query === '') return categories
|
||||
|
||||
const result: Category[] = []
|
||||
|
||||
for (const category of categories) {
|
||||
if (category.kind === 'apps') {
|
||||
const groups = category.groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => matches(query, item.name, item.desc)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0)
|
||||
|
||||
if (groups.length > 0) {
|
||||
result.push({ ...category, groups })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (category.kind === 'posters') {
|
||||
const items = category.items.filter((item) => matches(query, item.title))
|
||||
if (items.length > 0) {
|
||||
result.push({ ...category, items })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const items = category.items.filter((item) => matches(query, item.title, item.desc))
|
||||
if (items.length > 0) {
|
||||
result.push({ ...category, items })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function matches(query: string, ...fields: string[]): boolean {
|
||||
return fields.some((field) => field.toLowerCase().includes(query))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { AppCategory, AppItem, Category } from './types'
|
||||
|
||||
/**
|
||||
* 「本地服务」分类的组装逻辑。
|
||||
*
|
||||
* 这一块不来自内容库:托管目录里有哪些项目是由文件系统决定的,
|
||||
* 后端每次扫描后通过 `/api/projects` 推送,所以本地分类始终在前端拼出来,
|
||||
* 内容库只负责其余的分类(常用推荐 / 推荐电影 / CES 现场报道)。
|
||||
*/
|
||||
|
||||
/** 后端 `/api/projects` 返回的项目结构 */
|
||||
export type HostedProject = {
|
||||
name: string
|
||||
route_path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 把托管项目映射成应用条目。
|
||||
*
|
||||
* id 用项目名(就是目录名):它在前端各处(首页卡片、明细、详情)唯一,
|
||||
* 又正好是可以直接放进地址栏的一段——条目详情地址是 `/c/local/<分组>/i/<项目名>`。
|
||||
* 访问地址仍然用 `route_path`(`/demo/`),两者不再混用。
|
||||
* 本地项目没有封面图,详情页会退化成字符头像。
|
||||
*/
|
||||
function toLocalItem(project: HostedProject): AppItem {
|
||||
return {
|
||||
id: project.name,
|
||||
name: project.name,
|
||||
desc: project.route_path,
|
||||
icon: project.name.slice(0, 1).toUpperCase(),
|
||||
href: project.route_path,
|
||||
comment: '本地静态站点,由 Stash 直接托管。',
|
||||
badge: '本地项目',
|
||||
views: 0,
|
||||
likes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成「本地服务」分类;没有项目时给出一句引导文案 */
|
||||
export function buildLocalCategory(projects: HostedProject[]): AppCategory {
|
||||
const items = projects.map(toLocalItem)
|
||||
|
||||
return {
|
||||
id: 'local',
|
||||
kind: 'apps',
|
||||
title: '本地服务',
|
||||
barColor: '#3858f6',
|
||||
icon: '🖥️',
|
||||
groups: items.length > 0 ? [{ id: 'local-all', name: '全部项目', items }] : [],
|
||||
previewLimit: 8,
|
||||
emptyText: '还没有项目:在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接首页全部分类:本地服务永远排第一,其后跟进内容库返回的分类。
|
||||
*/
|
||||
export function withLocalCategory(
|
||||
projects: HostedProject[],
|
||||
categories: Category[],
|
||||
): Category[] {
|
||||
return [buildLocalCategory(projects), ...categories]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 导航目标。
|
||||
*
|
||||
* 侧栏现在直接由内容树(分区 → 分组)派生,不再有独立的导航数据模型;
|
||||
* 这里只保留「点击后要去哪」的最小类型。
|
||||
*/
|
||||
|
||||
export type NavTarget = {
|
||||
categoryId: string
|
||||
/** 指定后明细页只展示该分组 */
|
||||
groupId?: string
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 内容数据模型。
|
||||
*
|
||||
* 首页把每个「分类」渲染成一张卡片,明细页按分类的 kind 渲染不同网格,
|
||||
* 因此这里用可辨识联合把三种展示形态分开。
|
||||
*/
|
||||
|
||||
/** 一个可点击的应用 / 站点条目 */
|
||||
export type AppItem = {
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
/** 卡片左侧的字符图标 */
|
||||
icon: string
|
||||
/** 跳转地址;本地项目为 /<目录名>/ */
|
||||
href: string
|
||||
/** 详情页头像 / 封面,缺省时退化为字符图标 */
|
||||
cover?: string
|
||||
/** 是否为热门条目:卡片名字后显示主题同款 HOT 徽标 */
|
||||
hot?: boolean
|
||||
/** 详情页的一句话评价 */
|
||||
comment?: string
|
||||
/** 详情页横幅图 */
|
||||
banner?: string
|
||||
/** 详情页头像下方的标签 */
|
||||
badge?: string
|
||||
/** 详情页浏览量(占位数据) */
|
||||
views?: number
|
||||
/** 详情页点赞数(占位数据) */
|
||||
likes?: number
|
||||
}
|
||||
|
||||
/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */
|
||||
export type AppGroup = {
|
||||
id: string
|
||||
name: string
|
||||
/** 是否出现在侧栏;缺省视为 true */
|
||||
showInSidebar?: boolean
|
||||
items: AppItem[]
|
||||
}
|
||||
|
||||
/** 竖版海报条目(电影) */
|
||||
export type PosterItem = {
|
||||
id: string
|
||||
title: string
|
||||
cover: string
|
||||
/** 详情页标签行,如「推荐电影」「豆瓣电影 Top250」 */
|
||||
tags?: string[]
|
||||
/** 详情页一句话评价 */
|
||||
comment?: string
|
||||
/** 详情页正文段落 */
|
||||
summary?: string
|
||||
/** 跳转地址(占位数据暂无真实链接) */
|
||||
href?: string
|
||||
views?: number
|
||||
likes?: number
|
||||
}
|
||||
|
||||
/** 「推荐阅读」用的轻量条目,应用与电影都能映射过来 */
|
||||
export type RelatedItem = {
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
cover?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/** 图文条目(报道) */
|
||||
export type ArticleItem = {
|
||||
id: string
|
||||
title: string
|
||||
desc: string
|
||||
cover: string
|
||||
/** 详情页标题下的元信息,如 ['2 年前', 'CES 现场报道'] */
|
||||
meta?: string[]
|
||||
/** 详情页正文:一整段 Markdown(一篇一段文本,不再拆块) */
|
||||
body?: string
|
||||
}
|
||||
|
||||
/** 首页展示块:分区卡片展示哪个节点、显示几条 */
|
||||
export type SectionBlock = {
|
||||
/** 要展示的节点业务键:通常是分组;电影/文章这类无分组的填分区自己 */
|
||||
nodeKey: string
|
||||
/** 块标题,缺省用节点标题 */
|
||||
title?: string
|
||||
/** 卡片显示几条,缺省用分类的 previewLimit 或默认值 */
|
||||
previewLimit?: number
|
||||
}
|
||||
|
||||
/** 应用类分类:内部按分组组织 */
|
||||
export type AppCategory = {
|
||||
id: string
|
||||
kind: 'apps'
|
||||
title: string
|
||||
/** 标题左侧色条颜色,各分类用不同颜色区分 */
|
||||
barColor: string
|
||||
/** 明细页 hero 区的字符图标 */
|
||||
icon: string
|
||||
groups: AppGroup[]
|
||||
/** 首页预览条数,默认 4 */
|
||||
previewLimit?: number
|
||||
/** 首页预览为空时的提示文案 */
|
||||
emptyText?: string
|
||||
/** 是否出现在侧栏;缺省视为 true */
|
||||
showInSidebar?: boolean
|
||||
/** 首页卡片展示哪一块内容 */
|
||||
block?: SectionBlock
|
||||
}
|
||||
|
||||
/** 海报类分类:竖版海报网格 */
|
||||
export type PosterCategory = {
|
||||
id: string
|
||||
kind: 'posters'
|
||||
title: string
|
||||
barColor: string
|
||||
icon: string
|
||||
items: PosterItem[]
|
||||
/** 首页预览条数,默认 6 */
|
||||
previewLimit?: number
|
||||
/** 是否出现在侧栏;缺省视为 true */
|
||||
showInSidebar?: boolean
|
||||
/** 首页卡片展示哪一块内容 */
|
||||
block?: SectionBlock
|
||||
}
|
||||
|
||||
/** 图文类分类:上图下文卡片网格 */
|
||||
export type ArticleCategory = {
|
||||
id: string
|
||||
kind: 'articles'
|
||||
title: string
|
||||
barColor: string
|
||||
icon: string
|
||||
items: ArticleItem[]
|
||||
/** 首页预览条数,默认 4 */
|
||||
previewLimit?: number
|
||||
/** 是否出现在侧栏;缺省视为 true */
|
||||
showInSidebar?: boolean
|
||||
/** 首页卡片展示哪一块内容 */
|
||||
block?: SectionBlock
|
||||
}
|
||||
|
||||
export type Category = AppCategory | PosterCategory | ArticleCategory
|
||||
|
||||
/** 分类内的条目总数,用于角标与明细页计数 */
|
||||
export function categoryCount(category: Category): number {
|
||||
if (category.kind === 'apps') {
|
||||
return category.groups.reduce((total, group) => total + group.items.length, 0)
|
||||
}
|
||||
return category.items.length
|
||||
}
|
||||
|
||||
/** 把应用类分类的分组拍平,首页预览用 */
|
||||
export function flattenAppItems(category: AppCategory): AppItem[] {
|
||||
return category.groups.flatMap((group) => group.items)
|
||||
}
|
||||
|
||||
/** 按 id 在分类里查找条目;详情页用 */
|
||||
export function findAppItem(category: Category, itemId: string): AppItem | undefined {
|
||||
if (category.kind !== 'apps') return undefined
|
||||
|
||||
for (const group of category.groups) {
|
||||
const found = group.items.find((item) => item.id === itemId)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 取同分类下的其他条目,用于详情页的「推荐阅读」 */
|
||||
export function relatedAppItems(
|
||||
category: Category,
|
||||
excludeId: string | undefined,
|
||||
limit = 4,
|
||||
): AppItem[] {
|
||||
if (category.kind !== 'apps') return []
|
||||
|
||||
return flattenAppItems(category)
|
||||
.filter((item) => item.id !== excludeId)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/** 按 id 查找海报条目 */
|
||||
export function findPosterItem(
|
||||
category: Category,
|
||||
itemId: string,
|
||||
): PosterItem | undefined {
|
||||
if (category.kind !== 'posters') return undefined
|
||||
return category.items.find((item) => item.id === itemId)
|
||||
}
|
||||
|
||||
/** 取同分类下的其他海报,用于详情页的「推荐阅读」 */
|
||||
export function relatedPosterItems(
|
||||
category: Category,
|
||||
excludeId: string | undefined,
|
||||
limit = 4,
|
||||
): PosterItem[] {
|
||||
if (category.kind !== 'posters') return []
|
||||
|
||||
return category.items.filter((item) => item.id !== excludeId).slice(0, limit)
|
||||
}
|
||||
|
||||
/** 按 id 查找图文条目 */
|
||||
export function findArticleItem(
|
||||
category: Category,
|
||||
itemId: string,
|
||||
): ArticleItem | undefined {
|
||||
if (category.kind !== 'articles') return undefined
|
||||
return category.items.find((item) => item.id === itemId)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 视图与地址的对应关系:地址栏是唯一的视图状态源。
|
||||
*
|
||||
* 控制台是单页应用,但「当前在哪一页」写在 URL 里,所以刷新、收藏、
|
||||
* 前进后退都不会丢上下文。服务端对应的规则在 `src/server.rs`:
|
||||
* `/c/**` 没有实体文件时回落到内嵌的 `index.html`,由前端按 pathname 重新出页面。
|
||||
*
|
||||
* 路由形状(各段都是业务键,也就是接口 JSON 里的 `id`):
|
||||
*
|
||||
* / 首页
|
||||
* /c/<分区> 分类明细
|
||||
* /c/<分区>/<分组> 只展示该分组
|
||||
* /c/<分区>/i/<条目> 条目详情,返回 → 分类明细
|
||||
* /c/<分区>/<分组>/i/<条目> 条目详情,返回 → 该分组
|
||||
*
|
||||
* 条目地址带上它自己所在的分组,URL 才是自解释的:刷新之后点「返回」
|
||||
* 仍然落回条目所属的分组,而不是去猜一个返回目标。
|
||||
*
|
||||
* 这里只做「地址 ↔ 视图」的纯转换,读写 history 的部分在 `useViewRoute.ts`。
|
||||
*/
|
||||
|
||||
export type DetailRoute = {
|
||||
type: 'detail'
|
||||
categoryId: string
|
||||
/** 指定后明细页只展示该分组 */
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
export type ItemRoute = {
|
||||
type: 'item'
|
||||
categoryId: string
|
||||
itemId: string
|
||||
/**
|
||||
* 条目所在的分组(应用才有)。
|
||||
*
|
||||
* 它只描述「条目在哪」,不描述「从哪来」:返回目标由它推导,
|
||||
* 这样同一个条目永远只有一个地址,刷新和分享都不会跑偏。
|
||||
*/
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
export type View =
|
||||
| { type: 'home' }
|
||||
| DetailRoute
|
||||
| ItemRoute
|
||||
|
||||
/** 前端路由前缀;改动这里要同步 `src/server.rs` 的 `SPA_PREFIXES`。 */
|
||||
const CATEGORY_PREFIX = '/c/'
|
||||
/** 前缀去掉两侧斜杠后的那一段,拼地址时当第一个路径段用 */
|
||||
const CATEGORY_SEGMENT = CATEGORY_PREFIX.slice(1, -1)
|
||||
/** 条目段标记:`/c/<分区>[/<分组>]/i/<条目>` */
|
||||
const ITEM_MARK = 'i'
|
||||
|
||||
/** 首页视图;未知地址一律归到这里 */
|
||||
export const HOME_VIEW: View = { type: 'home' }
|
||||
|
||||
/** 把视图转成地址。 */
|
||||
export function viewPath(view: View): string {
|
||||
if (view.type === 'home') return '/'
|
||||
|
||||
const segments = [CATEGORY_SEGMENT, encodeSegment(view.categoryId)]
|
||||
if (view.groupId !== undefined) segments.push(encodeSegment(view.groupId))
|
||||
if (view.type === 'item') segments.push(ITEM_MARK, encodeSegment(view.itemId))
|
||||
|
||||
return `/${segments.join('/')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 把地址解析成视图;看不懂的地址(段数不对、百分号编码非法)按首页处理。
|
||||
*
|
||||
* 这里只做形状校验,不判断分类 / 条目是否真的存在——那是数据层的事,
|
||||
* 而且首屏还没拿到内容库时,它们本来就「暂时不存在」。
|
||||
*/
|
||||
export function parseViewPath(pathname: string): View {
|
||||
if (!pathname.startsWith(CATEGORY_PREFIX)) return HOME_VIEW
|
||||
|
||||
const segments = pathname
|
||||
.slice(CATEGORY_PREFIX.length)
|
||||
.split('/')
|
||||
.filter((segment) => segment !== '')
|
||||
|
||||
const [categorySegment] = segments
|
||||
if (categorySegment === undefined) return HOME_VIEW
|
||||
|
||||
const categoryId = decodeSegment(categorySegment)
|
||||
if (categoryId === undefined) return HOME_VIEW
|
||||
|
||||
if (segments.length === 1) return { type: 'detail', categoryId }
|
||||
|
||||
if (segments.length === 2) {
|
||||
const groupId = decodeSegment(segments[1])
|
||||
if (groupId === undefined) return HOME_VIEW
|
||||
return { type: 'detail', categoryId, groupId }
|
||||
}
|
||||
|
||||
if (segments.length === 3 && segments[1] === ITEM_MARK) {
|
||||
const itemId = decodeSegment(segments[2])
|
||||
if (itemId === undefined) return HOME_VIEW
|
||||
return { type: 'item', categoryId, itemId }
|
||||
}
|
||||
|
||||
if (segments.length === 4 && segments[2] === ITEM_MARK) {
|
||||
const groupId = decodeSegment(segments[1])
|
||||
const itemId = decodeSegment(segments[3])
|
||||
if (groupId === undefined || itemId === undefined) return HOME_VIEW
|
||||
return { type: 'item', categoryId, itemId, groupId }
|
||||
}
|
||||
|
||||
return HOME_VIEW
|
||||
}
|
||||
|
||||
/** 单个路径段编码:id 里可能带斜杠、空格、中文 */
|
||||
function encodeSegment(value: string): string {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
/** 解码单个路径段;百分号编码非法时返回 undefined */
|
||||
function decodeSegment(segment: string): string | undefined {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 图标字体:取自 Alright 主题自带的 iconfont(Project id 4809463)。
|
||||
* 字体文件放在 public/fonts/iconfont.woff2,这里只保留项目用到的字形,
|
||||
* 避免把整份 500 多个图标的样式表打进包里。
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'iconfont';
|
||||
src: url('./assets/iconfont.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
display: inline-block;
|
||||
font-family: 'iconfont' !important;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
line-height: 1;
|
||||
text-rendering: auto;
|
||||
speak: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-search::before {
|
||||
content: '\e942';
|
||||
}
|
||||
|
||||
.icon-user::before {
|
||||
content: '\e966';
|
||||
}
|
||||
|
||||
.icon-refresh::before {
|
||||
content: '\e93d';
|
||||
}
|
||||
|
||||
.icon-link::before {
|
||||
content: '\ea37';
|
||||
}
|
||||
|
||||
.icon-external::before {
|
||||
content: '\e8be';
|
||||
}
|
||||
|
||||
.icon-eye::before {
|
||||
content: '\e8fd';
|
||||
}
|
||||
|
||||
.icon-thumb-up::before {
|
||||
content: '\e957';
|
||||
}
|
||||
|
||||
.icon-share::before {
|
||||
content: '\e946';
|
||||
}
|
||||
|
||||
.icon-close::before {
|
||||
content: '\e8e4';
|
||||
}
|
||||
|
||||
.icon-more::before {
|
||||
content: '\e91f';
|
||||
}
|
||||
|
||||
.icon-arrow-up::before {
|
||||
content: '\e8c1';
|
||||
}
|
||||
|
||||
.icon-arrow-left::before {
|
||||
content: '\e8b5';
|
||||
}
|
||||
|
||||
.icon-arrow-down::before {
|
||||
content: '\e8b7';
|
||||
}
|
||||
+25
-12
@@ -1,24 +1,37 @@
|
||||
:root {
|
||||
--bg-color: #f7f9fb;
|
||||
/* ---------- 颜色:取自 Alright 主题的设计变量 ---------- */
|
||||
--bg-color: #f7f8fa;
|
||||
--card-bg: #ffffff;
|
||||
--primary-color: #3b82f6;
|
||||
--primary-soft: #eff6ff;
|
||||
--text-main: #1e293b;
|
||||
--text-sub: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
--border-color: #e2e8f0;
|
||||
--primary-color: #3858f6;
|
||||
--primary-soft: rgba(56, 88, 246, 0.1);
|
||||
--text-main: #1d2129;
|
||||
--text-sub: #4e5969;
|
||||
--text-muted: #86909c;
|
||||
--border-color: #f6f6f6;
|
||||
--border-strong: #eeeeee;
|
||||
--danger: #ff3e3e;
|
||||
--orange: #ffba3a;
|
||||
--green: #4abd86;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-main: 12px;
|
||||
--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);
|
||||
--radius-lg: 24px;
|
||||
|
||||
/* 主题的阴影很轻:0 6px 16px rgba(27,26,49,2%) */
|
||||
--shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
|
||||
--shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06);
|
||||
|
||||
--sidebar-width: 240px;
|
||||
--top-nav-height: 60px;
|
||||
/* 内容区最大宽度,宽屏时整体居中 */
|
||||
--layout-max-width: 1280px;
|
||||
--layout-max-width: 1380px;
|
||||
/* 内容列顶部让位高度(给悬浮搜索框),侧栏首卡与内容首卡都按它对齐 */
|
||||
--content-top-offset: 52px;
|
||||
|
||||
/* 主题正文用霞鹜文楷(与演示站同一份 webfont),缺失时回退系统字体 */
|
||||
--font-sans:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||
'LXGW WenKai', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans CN', sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace;
|
||||
|
||||
font-family: var(--font-sans);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './iconfont.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { fetchCatalog, subscribeCatalog } from './api'
|
||||
import type { Category } from './data/types'
|
||||
|
||||
export type CatalogStatus = 'loading' | 'ready' | 'error'
|
||||
|
||||
export type CatalogState = {
|
||||
status: CatalogStatus
|
||||
categories: Category[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type CatalogController = CatalogState & {
|
||||
/** 手动重新拉取;内容库被外部修改后也会自动调用 */
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容目录:首屏拉取 + 监听服务端推送 + 手动重载。
|
||||
*
|
||||
* 与 `useProjects` 的区别是失败策略:项目列表失败会保留旧数据,
|
||||
* 内容目录同样保留旧数据 —— 用户正在改库时偶尔读到坏 JSON 不应该把页面清空。
|
||||
*/
|
||||
export function useCatalog(): CatalogController {
|
||||
const [state, setState] = useState<CatalogState>({
|
||||
status: 'loading',
|
||||
categories: [],
|
||||
error: null,
|
||||
})
|
||||
const controllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
/**
|
||||
* 拉一次内容目录。
|
||||
*
|
||||
* 用 ref 保存当前的 AbortController:外部改动可能连续触发多次刷新,
|
||||
* 只保留最后一次请求,避免旧响应把新数据覆盖回去。
|
||||
*/
|
||||
const load = useCallback(() => {
|
||||
controllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
|
||||
fetchCatalog(controller.signal)
|
||||
.then((catalog) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setState({
|
||||
status: 'ready',
|
||||
categories: catalog.categories,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setState((previous) => ({
|
||||
// 已经有内容时只提示错误,不清空列表
|
||||
status: previous.categories.length > 0 ? 'ready' : 'error',
|
||||
categories: previous.categories,
|
||||
error: toErrorMessage(error),
|
||||
}))
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 首屏加载:setState 只发生在 Promise 回调里,避免 effect 内同步 setState
|
||||
useEffect(() => {
|
||||
load()
|
||||
return () => controllerRef.current?.abort()
|
||||
}, [load])
|
||||
|
||||
// 内容库被外部修改时,服务端推送一次「变了」,这里重新拉取
|
||||
useEffect(() => subscribeCatalog(load), [load])
|
||||
|
||||
return { ...state, reload: load }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { parseViewPath, viewPath, type View } from './data/view'
|
||||
|
||||
/**
|
||||
* 视图 ↔ 地址同步。
|
||||
*
|
||||
* 三个入口都要覆盖:首屏(解析地址)、点击(写地址)、浏览器前进后退(popstate)。
|
||||
* 视图本身仍然放在 React state 里,地址只是它的镜像,避免每次渲染都去读 URL;
|
||||
* 地址与视图的换算规则收在 `data/view.ts`,这里只管 history。
|
||||
*/
|
||||
|
||||
/** 存在 history.state 里的键:当前这条记录是应用内第几页(0 = 没有上一页) */
|
||||
const HISTORY_INDEX_KEY = 'stashIndex'
|
||||
|
||||
type StashHistoryState = { [HISTORY_INDEX_KEY]?: number }
|
||||
|
||||
/** 读取当前历史深度;不是本应用写的记录一律当作 0 */
|
||||
function historyIndex(): number {
|
||||
const state = window.history.state as StashHistoryState | null
|
||||
const index = state === null ? undefined : state[HISTORY_INDEX_KEY]
|
||||
return typeof index === 'number' ? index : 0
|
||||
}
|
||||
|
||||
/** 当前 history.state 的副本,写入前先保留别人(如浏览器扩展)放的字段 */
|
||||
function currentHistoryState(): StashHistoryState {
|
||||
const state = window.history.state as StashHistoryState | null
|
||||
return state === null ? {} : { ...state }
|
||||
}
|
||||
|
||||
export type ViewRoute = {
|
||||
/** 当前视图,初值来自地址栏 */
|
||||
view: View
|
||||
/** 打开一个视图:默认压一条新历史,`replace` 表示替换当前这条 */
|
||||
go: (view: View, options?: { replace?: boolean }) => void
|
||||
/**
|
||||
* 返回。
|
||||
*
|
||||
* 应用内有上一页时交给浏览器(`history.back()`),
|
||||
* 直接打开条目地址(收藏 / 刷新后没有上一页)时退到 `fallback`,
|
||||
* 这时它替换当前记录,避免在历史里留下一条「自己指向自己」的条目。
|
||||
*/
|
||||
back: (fallback: View) => void
|
||||
}
|
||||
|
||||
export function useViewRoute(): ViewRoute {
|
||||
const [view, setView] = useState<View>(() => parseViewPath(window.location.pathname))
|
||||
|
||||
// 首屏把地址规范化:去掉多余的斜杠 / 尾部斜杠,未知地址归到首页,
|
||||
// 并补上历史深度(直接打开时是 0,说明应用内没有上一页)
|
||||
useEffect(() => {
|
||||
const canonical = viewPath(parseViewPath(window.location.pathname))
|
||||
const state = currentHistoryState()
|
||||
const recorded = state[HISTORY_INDEX_KEY] !== undefined
|
||||
|
||||
if (canonical === window.location.pathname && recorded) return
|
||||
|
||||
window.history.replaceState({ ...state, [HISTORY_INDEX_KEY]: historyIndex() }, '', canonical)
|
||||
}, [])
|
||||
|
||||
// 前进后退:地址变了,视图跟着变
|
||||
useEffect(() => {
|
||||
const handlePopState = () => setView(parseViewPath(window.location.pathname))
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
return () => window.removeEventListener('popstate', handlePopState)
|
||||
}, [])
|
||||
|
||||
const go = useCallback((next: View, options?: { replace?: boolean }) => {
|
||||
const path = viewPath(next)
|
||||
const index = historyIndex()
|
||||
|
||||
// 已经在同一页(例如重复点同一个侧栏项):不写历史,也不改深度
|
||||
if (path === window.location.pathname) {
|
||||
setView(next)
|
||||
return
|
||||
}
|
||||
|
||||
const replace = options?.replace === true
|
||||
const state: StashHistoryState = {
|
||||
...currentHistoryState(),
|
||||
[HISTORY_INDEX_KEY]: replace ? index : index + 1,
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
window.history.replaceState(state, '', path)
|
||||
} else {
|
||||
window.history.pushState(state, '', path)
|
||||
}
|
||||
|
||||
setView(next)
|
||||
}, [])
|
||||
|
||||
const back = useCallback(
|
||||
(fallback: View) => {
|
||||
if (historyIndex() > 0) {
|
||||
window.history.back()
|
||||
return
|
||||
}
|
||||
|
||||
go(fallback, { replace: true })
|
||||
},
|
||||
[go],
|
||||
)
|
||||
|
||||
return { view, go, back }
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>服务与推荐列表完整布局</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f5f6f8;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
padding: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 统一外框卡片(图二样式) */
|
||||
.section-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* 顶部标题栏 */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 蓝色指示条/图标 */
|
||||
.title-bar {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background-color: #1677ff;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 标题容器,方便角标定位 */
|
||||
.title-text-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* 右上角红色角标 */
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -18px;
|
||||
background-color: #ff4d4f;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
box-shadow: 0 1px 3px rgba(255, 77, 79, 0.3);
|
||||
}
|
||||
|
||||
/* 卡片右上角更多按钮 */
|
||||
.more-icon {
|
||||
color: #8c8c8c;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.more-icon:hover {
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
/* ------------------- 样式 1:常规应用卡片网格 ------------------- */
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
background-color: #f9f9fb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-item:hover {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
border-color: #e6e6e6;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background-color: #e8f3ff;
|
||||
color: #1677ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ------------------- 样式 2:图四 推荐电影(竖版海报卡片) ------------------- */
|
||||
.movie-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.movie-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.movie-item {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 2 / 3; /* 经典电影海报高宽比 */
|
||||
background-color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.movie-item:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.movie-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 底部文字渐变蒙层 */
|
||||
.movie-info {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 20px 8px 8px;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.4) 60%, transparent 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.movie-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ------------------- 样式 3:图五 现场报道/文章(横版网格卡片) ------------------- */
|
||||
.article-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.article-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.article-item {
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4 / 3; /* 横版比例 */
|
||||
background-color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.article-item:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.article-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 底部文字渐变蒙层 */
|
||||
.article-info {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 16px 6px 6px;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
opacity: 0.95;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- 1. 本地服务 -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title-wrapper">
|
||||
<div class="title-bar"></div>
|
||||
<div class="title-text-container">
|
||||
<span class="section-title">本地服务</span>
|
||||
<span class="badge">1</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="more-icon">•••</div>
|
||||
</div>
|
||||
|
||||
<div class="app-grid">
|
||||
<div class="app-item">
|
||||
<div class="app-icon">G</div>
|
||||
<div class="app-info">
|
||||
<div class="app-name">git_helper</div>
|
||||
<div class="app-desc">/git_helper/</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 常用推荐 -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title-wrapper">
|
||||
<div class="title-bar"></div>
|
||||
<div class="title-text-container">
|
||||
<span class="section-title">常用推荐</span>
|
||||
<span class="badge">4</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="more-icon">•••</div>
|
||||
</div>
|
||||
|
||||
<div class="app-grid">
|
||||
<div class="app-item">
|
||||
<div class="app-icon">G</div>
|
||||
<div class="app-info">
|
||||
<div class="app-name">GitHub</div>
|
||||
<div class="app-desc">代码托管与开源社区</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-item">
|
||||
<div class="app-icon" style="background-color: #e6f7ff; color: #1890ff;">Z</div>
|
||||
<div class="app-info">
|
||||
<div class="app-name">知乎</div>
|
||||
<div class="app-desc">深度内容问答社区</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-item">
|
||||
<div class="app-icon" style="background-color: #fff0f6; color: #eb2f96;">B</div>
|
||||
<div class="app-info">
|
||||
<div class="app-name">哔哩哔哩</div>
|
||||
<div class="app-desc">弹幕视频分享网站</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-item">
|
||||
<div class="app-icon" style="background-color: #f6ffed; color: #52c41a;">V</div>
|
||||
<div class="app-info">
|
||||
<div class="app-name">V2EX</div>
|
||||
<div class="app-desc">创意工作者讨论社区</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 图四样式:推荐电影(竖版海报卡片) -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title-wrapper">
|
||||
<div class="title-bar" style="background-color: #722ed1;"></div>
|
||||
<div class="title-text-container">
|
||||
<span class="section-title">推荐电影</span>
|
||||
<span class="badge">6</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="more-icon">•••</div>
|
||||
</div>
|
||||
|
||||
<div class="movie-grid">
|
||||
<div class="movie-item">
|
||||
<img class="movie-cover" src="https://picsum.photos/seed/m1/300/450" alt="海报">
|
||||
<div class="movie-info">
|
||||
<div class="movie-title">千与千寻 千と千尋の神隠し (2001)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movie-item">
|
||||
<img class="movie-cover" src="https://picsum.photos/seed/m2/300/450" alt="海报">
|
||||
<div class="movie-info">
|
||||
<div class="movie-title">美丽人生 La vita è bella (1997)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movie-item">
|
||||
<img class="movie-cover" src="https://picsum.photos/seed/m3/300/450" alt="海报">
|
||||
<div class="movie-info">
|
||||
<div class="movie-title">这个杀手不太冷 Léon (1994)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movie-item">
|
||||
<img class="movie-cover" src="https://picsum.photos/seed/m4/300/450" alt="海报">
|
||||
<div class="movie-info">
|
||||
<div class="movie-title">阿甘正传 Forrest Gump (1994)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movie-item">
|
||||
<img class="movie-cover" src="https://picsum.photos/seed/m5/300/450" alt="海报">
|
||||
<div class="movie-info">
|
||||
<div class="movie-title">霸王别姬 (1993)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movie-item">
|
||||
<img class="movie-cover" src="https://picsum.photos/seed/m6/300/450" alt="海报">
|
||||
<div class="movie-info">
|
||||
<div class="movie-title">肖申克的救赎 The Shawshank...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 图五样式:CES 现场报道(横版图片两行网格) -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title-wrapper">
|
||||
<div class="title-bar" style="background-color: #fa8c16;"></div>
|
||||
<div class="title-text-container">
|
||||
<span class="section-title">CES 现场报道</span>
|
||||
<span class="badge">12</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="more-icon">•••</div>
|
||||
</div>
|
||||
|
||||
<div class="article-grid">
|
||||
<!-- 第一行 -->
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a1/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">可塑性极强的玻璃,在设计师手里有了更多可能</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a2/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">废旧共享单车如何变成微型共享书屋?</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a3/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">荧光灯管与混凝土块的组合,可以玩出多少花样?</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a4/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">混凝土做的模块化收纳框,还能改成搁架用</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a5/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">这个用拉伸材料做的头盔,还能帮你呼叫救护车</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a6/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">你如何选择字体,简单易读还是好看有趣?</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行 -->
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a7/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">家里空间小?你需要几把可以悬挂在墙上的椅子</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a8/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">宜家厨房家具又被改造了,色彩是亮点</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a9/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">这款诞生50年的扶手椅,推出了特别版配色</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a10/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">让家和办公室界限变得模糊的一组家具</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a11/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">这个酒店设计奖,挖掘过去一年体验最好的酒店</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a12/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">一款可移动办公桌,挂墙上当作白板就能写写画画</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,920 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>STASH - 完整联动与版心对齐布局</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth; /* 启用原生平滑滚动 */
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f5f6f8;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
color: #333;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 统一版心最大宽度约束 */
|
||||
.layout-container {
|
||||
max-width: 1380px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* ------------------ 1. 顶部 Header 组件 (宽度对齐下方) ------------------ */
|
||||
.header-outer {
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: #0f172a;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.header-center-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background-color: #e6f4ff;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.header-icon-btn {
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.header-icon-btn:hover {
|
||||
background-color: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* 搜索栏区域(宽度与下方卡片版心保持对齐) */
|
||||
.search-container {
|
||||
padding: 16px 0 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 16px 0 40px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background-color: #ffffff;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ------------------ 主体 Layout 布局 ------------------ */
|
||||
.main-layout {
|
||||
display: flex;
|
||||
margin: 20px auto 40px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ------------------ 2. 左侧侧边栏组件 ------------------ */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 76px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.sidebar-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* 用户卡片 */
|
||||
.user-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background-color: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.verified-badge {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: #f59e0b;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
background-color: #f1f5f9;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin: 6px 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.user-desc {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.user-ip {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* 本地服务状态卡片 */
|
||||
.status-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background-color: #f1f5f9;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-pill::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: #94a3b8;
|
||||
}
|
||||
|
||||
.status-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background-color: #fff;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
border-color: #cbd5e1;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
/* 侧边分类导航菜单(对应右侧主标题) */
|
||||
.menu-card {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.menu-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
padding: 6px 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: #f8fafc;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
background-color: #e6f4ff;
|
||||
color: #1677ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ------------------ 3. 右侧主内容区域 ------------------ */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 大框样式(图二规范) */
|
||||
.section-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid #f0f0f0;
|
||||
scroll-margin-top: 80px; /* 留出顶部固定 Header 的高度避免遮挡 */
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background-color: #1677ff;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.title-text-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* 角标相对于标题右上角定位 */
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -18px;
|
||||
background-color: #ff4d4f;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
box-shadow: 0 1px 3px rgba(255, 77, 79, 0.3);
|
||||
}
|
||||
|
||||
.more-icon {
|
||||
color: #8c8c8c;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 2px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.more-icon:hover {
|
||||
color: #595959;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 常规卡片网格 */
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
background-color: #f9f9fb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-item:hover {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
border-color: #e6e6e6;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background-color: #e8f3ff;
|
||||
color: #1677ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 推荐电影网格 */
|
||||
.movie-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.movie-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.movie-item {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 2 / 3;
|
||||
background-color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.movie-item:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.movie-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.movie-info {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 20px 8px 8px;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.4) 60%, transparent 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.movie-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* CES 现场报道网格 */
|
||||
.article-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.article-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.article-item {
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4 / 3;
|
||||
background-color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.article-item:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.article-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.article-info {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 16px 6px 6px;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 1. 顶部 Header (固定顶部且与下方容器宽度一致) -->
|
||||
<header class="header-outer">
|
||||
<div class="layout-container header-inner">
|
||||
<div class="header-left">
|
||||
<div class="logo-icon">S</div>
|
||||
<span class="logo-text">STASH</span>
|
||||
</div>
|
||||
|
||||
<div class="header-center-info">
|
||||
<div class="info-item">
|
||||
<span>共 1 个本地应用</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-tag">本地</span>
|
||||
<span>git_helper</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-tag" style="background-color: #f0fdf4; color: #16a34a;">状态</span>
|
||||
<span>等待服务连接</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="header-icon-btn" title="搜索">🔍</div>
|
||||
<div class="header-icon-btn" title="个人账户">👤</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 搜索框区域 -->
|
||||
<div class="layout-container search-container">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input type="text" class="search-input" placeholder="搜索本地项目...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体布局 -->
|
||||
<div class="layout-container main-layout">
|
||||
|
||||
<!-- 2. 左侧侧边栏组件 -->
|
||||
<aside class="sidebar">
|
||||
|
||||
<!-- 用户信息卡片 -->
|
||||
<div class="sidebar-card user-card">
|
||||
<div class="avatar-wrapper">
|
||||
<div class="avatar">👤</div>
|
||||
<div class="verified-badge">✓</div>
|
||||
</div>
|
||||
<div class="user-name">本地管理员</div>
|
||||
<div class="user-role">管理员</div>
|
||||
<div class="user-desc">本地静态站点托管与预览,所有项目都运行在这台机器上。</div>
|
||||
<div class="user-ip">IP地址: 局域网</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地服务状态卡片 -->
|
||||
<div class="sidebar-card">
|
||||
<div class="status-header">
|
||||
<span class="status-title">本地服务</span>
|
||||
<span class="status-pill">未连接</span>
|
||||
</div>
|
||||
<div class="status-info-row">
|
||||
<span class="status-label">项目数</span>
|
||||
<span class="status-value">1</span>
|
||||
</div>
|
||||
<div class="status-info-row">
|
||||
<span class="status-label">访问地址</span>
|
||||
<span class="status-value">127.0.0.1:8080</span>
|
||||
</div>
|
||||
<div class="status-actions">
|
||||
<button class="action-btn">🔄 重新扫描</button>
|
||||
<button class="action-btn">🔗 查看接口</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类导航菜单(与右侧主标题一致) -->
|
||||
<div class="sidebar-card menu-card">
|
||||
<div class="menu-group-title">快速导航</div>
|
||||
|
||||
<a href="#section-local" class="menu-item active" onclick="setActive(this)">
|
||||
<span>本地服务</span>
|
||||
<span style="font-size: 11px;">1</span>
|
||||
</a>
|
||||
<a href="#section-recommend" class="menu-item" onclick="setActive(this)">
|
||||
<span>常用推荐</span>
|
||||
<span style="font-size: 11px;">4</span>
|
||||
</a>
|
||||
<a href="#section-movie" class="menu-item" onclick="setActive(this)">
|
||||
<span>推荐电影</span>
|
||||
<span style="font-size: 11px;">6</span>
|
||||
</a>
|
||||
<a href="#section-ces" class="menu-item" onclick="setActive(this)">
|
||||
<span>CES 现场报道</span>
|
||||
<span style="font-size: 11px;">12</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- 3. 右侧主内容区域 -->
|
||||
<main class="content-area">
|
||||
|
||||
<!-- 1. 本地服务 -->
|
||||
<section id="section-local" class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title-wrapper">
|
||||
<div class="title-bar"></div>
|
||||
<div class="title-text-container">
|
||||
<span class="section-title">本地服务</span>
|
||||
<span class="badge">1</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="more-icon" title="更多操作">•••</div>
|
||||
</div>
|
||||
|
||||
<div class="app-grid">
|
||||
<div class="app-item">
|
||||
<div class="app-icon">G</div>
|
||||
<div class="app-info">
|
||||
<div class="app-name">git_helper</div>
|
||||
<div class="app-desc">/git_helper/</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. 常用推荐 -->
|
||||
<section id="section-recommend" class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title-wrapper">
|
||||
<div class="title-bar"></div>
|
||||
<div class="title-text-container">
|
||||
<span class="section-title">常用推荐</span>
|
||||
<span class="badge">4</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="more-icon" title="更多操作">•••</div>
|
||||
</div>
|
||||
|
||||
<div class="app-grid">
|
||||
<div class="app-item">
|
||||
<div class="app-icon">G</div>
|
||||
<div class="app-info">
|
||||
<div class="app-name">GitHub</div>
|
||||
<div class="app-desc">代码托管与开源社区</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-item">
|
||||
<div class="app-icon" style="background-color: #e6f7ff; color: #1890ff;">Z</div>
|
||||
<div class="app-info">
|
||||
<div class="app-name">知乎</div>
|
||||
<div class="app-desc">深度内容问答社区</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-item">
|
||||
<div class="app-icon" style="background-color: #fff0f6; color: #eb2f96;">B</div>
|
||||
<div class="app-info">
|
||||
<div class="app-name">哔哩哔哩</div>
|
||||
<div class="app-desc">弹幕视频分享网站</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-item">
|
||||
<div class="app-icon" style="background-color: #f6ffed; color: #52c41a;">V</div>
|
||||
<div class="app-info">
|
||||
<div class="app-name">V2EX</div>
|
||||
<div class="app-desc">创意工作者讨论社区</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. 推荐电影 -->
|
||||
<section id="section-movie" class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title-wrapper">
|
||||
<div class="title-bar" style="background-color: #722ed1;"></div>
|
||||
<div class="title-text-container">
|
||||
<span class="section-title">推荐电影</span>
|
||||
<span class="badge">6</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="more-icon" title="更多操作">•••</div>
|
||||
</div>
|
||||
|
||||
<div class="movie-grid">
|
||||
<div class="movie-item">
|
||||
<img class="movie-cover" src="https://picsum.photos/seed/m1/300/450" alt="海报">
|
||||
<div class="movie-info">
|
||||
<div class="movie-title">千与千寻 千と千尋の神隠し (2001)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movie-item">
|
||||
<img class="movie-cover" src="https://picsum.photos/seed/m2/300/450" alt="海报">
|
||||
<div class="movie-info">
|
||||
<div class="movie-title">美丽人生 La vita è bella (1997)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movie-item">
|
||||
<img class="movie-cover" src="https://picsum.photos/seed/m3/300/450" alt="海报">
|
||||
<div class="movie-info">
|
||||
<div class="movie-title">这个杀手不太冷 Léon (1994)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movie-item">
|
||||
<img class="movie-cover" src="https://picsum.photos/seed/m4/300/450" alt="海报">
|
||||
<div class="movie-info">
|
||||
<div class="movie-title">阿甘正传 Forrest Gump (1994)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movie-item">
|
||||
<img class="movie-cover" src="https://picsum.photos/seed/m5/300/450" alt="海报">
|
||||
<div class="movie-info">
|
||||
<div class="movie-title">霸王别姬 (1993)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movie-item">
|
||||
<img class="movie-cover" src="https://picsum.photos/seed/m6/300/450" alt="海报">
|
||||
<div class="movie-info">
|
||||
<div class="movie-title">肖申克的救赎 The Shawshank...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. CES 现场报道 -->
|
||||
<section id="section-ces" class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title-wrapper">
|
||||
<div class="title-bar" style="background-color: #fa8c16;"></div>
|
||||
<div class="title-text-container">
|
||||
<span class="section-title">CES 现场报道</span>
|
||||
<span class="badge">12</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="more-icon" title="更多操作">•••</div>
|
||||
</div>
|
||||
|
||||
<div class="article-grid">
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a1/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">可塑性极强的玻璃,在设计师手里有了更多可能</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a2/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">废旧共享单车如何变成微型共享书屋?</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a3/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">荧光灯管与混凝土块的组合,可以玩出多少花样?</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a4/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">混凝土做的模块化收纳框,还能改成搁架用</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a5/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">这个用拉伸材料做的头盔,还能帮你呼叫救护车</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-item">
|
||||
<img class="article-cover" src="https://picsum.photos/seed/a6/300/225" alt="文章图片">
|
||||
<div class="article-info">
|
||||
<div class="article-title">你如何选择字体,简单易读还是好看有趣?</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 切换左侧菜单激活高亮样式
|
||||
function setActive(element) {
|
||||
const items = document.querySelectorAll('.menu-item');
|
||||
items.forEach(item => item.classList.remove('active'));
|
||||
element.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user