Compare commits
7
Commits
1effbea3dd
...
27e411fd2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27e411fd2c | ||
|
|
4621f8cb22 | ||
|
|
8889515373 | ||
|
|
bbfbc77b30 | ||
|
|
b1f7589538 | ||
|
|
f25fc8b4ba | ||
|
|
dec82080d3 |
+10
-42
@@ -100,48 +100,16 @@ CSS 变量 `--font-sans` / `--font-mono` 定义在 `index.css`。
|
|||||||
|
|
||||||
## 3. 图标
|
## 3. 图标
|
||||||
|
|
||||||
### 3.1 图标字体
|
图标统一来自组件库 `@stash/ui`:
|
||||||
|
|
||||||
主题使用自建 iconfont(线性风格,Project id 4809463)。
|
```tsx
|
||||||
Stash 自托管了一份 **只含所需字形** 的子集:
|
import { Icon } from '@stash/ui'
|
||||||
|
|
||||||
- 字体文件:[`stash-web/src/assets/iconfont.woff2`](stash-web/src/assets/iconfont.woff2)
|
<Icon name="search_3_line" />
|
||||||
- 样式:[`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;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
可用名称见 `@stash/ui` 导出的 `ICON_NAMES`,尺寸与颜色分别由 `size` / CSS `color` 控制。`stash-web` 不再维护本地 iconfont 或 `Icon.tsx`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 布局
|
## 4. 布局
|
||||||
@@ -311,10 +279,10 @@ background: linear-gradient(to top, rgba(0,0,0,.85) 0%, rgba(0,0,0,.4) 60%, tran
|
|||||||
| 需求 | 改动位置 |
|
| 需求 | 改动位置 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 改主色 / 灰阶 / 圆角 | [`stash-web/src/index.css`](stash-web/src/index.css) 的 `:root` |
|
| 改主色 / 灰阶 / 圆角 | [`stash-web/src/index.css`](stash-web/src/index.css) 的 `:root` |
|
||||||
| 新增图标 | 把 codepoint 加进 [`stash-web/src/iconfont.css`](stash-web/src/iconfont.css),在 `Icon.tsx` 包一个组件 |
|
| 新增图标 | 在 `@stash/ui` 的 iconfont 中补图标,再通过 `<Icon name>` 使用 |
|
||||||
| 新增分类 | 内容库 `category` 表(首次启动前可改 [`src/db/seed.json`](src/db/seed.json)),首页与明细页自动适配 |
|
| 新增分组 | 后台「分组管理」新建根分组 / 子分组,内容挂在分组上 |
|
||||||
| 调整侧栏导航 | 内容库 `nav_entry` 表,标签 / 层级 / 跳转目标都可在库里改 |
|
| 调整侧栏导航 | 后台「侧边栏」勾选要显示的卡片与分组 |
|
||||||
| 新增卡片类型 | 在 `data/types.ts` 增加 kind,`HomeView` / `DetailView` 各加一个分支 |
|
| 新增卡片类型 | 在 `AppNext.tsx` 的 `renderEntries` / `renderCard` 增加分支 |
|
||||||
| 调整分区样式 | [`stash-web/src/App.css`](stash-web/src/App.css) 的「分区卡片」段落 |
|
| 调整分区样式 | [`stash-web/src/App.css`](stash-web/src/App.css) 的「分区卡片」段落 |
|
||||||
| 换背景图 | 替换 `stash-web/src/assets/site-bg.jpg` |
|
| 换背景图 | 替换 `stash-web/src/assets/site-bg.jpg` |
|
||||||
|
|
||||||
|
|||||||
+8
-15
@@ -11,7 +11,7 @@
|
|||||||
> - 新增 `/interface` 接口一览页;侧栏「查看接口」走站内路由,不再直接开 `/api/projects`;
|
> - 新增 `/interface` 接口一览页;侧栏「查看接口」走站内路由,不再直接开 `/api/projects`;
|
||||||
> - 接入 `@stash/ui` 组件库:前后台所有卡片、表格、开关、文字按钮都来自库里,
|
> - 接入 `@stash/ui` 组件库:前后台所有卡片、表格、开关、文字按钮都来自库里,
|
||||||
> 明暗主题(浅色 / 深色 / 跟随系统)由令牌接管;
|
> 明暗主题(浅色 / 深色 / 跟随系统)由令牌接管;
|
||||||
> - 内容库表结构升到 v22 并**有了自动迁移**,卡片模型是 `card` + `card_group`(`section_block` 仅作旧库迁移来源)。
|
> - 内容库表结构升到 v23 并**有了自动迁移**,卡片模型是 `card` + `card_group`;`section_block` 与旧 `node` 兼容字段已删除。
|
||||||
|
|
||||||
## 0. 一句话现状
|
## 0. 一句话现状
|
||||||
|
|
||||||
@@ -135,11 +135,10 @@ README.md 运行、路由、库结构、登录说明
|
|||||||
```
|
```
|
||||||
GET /api/health
|
GET /api/health
|
||||||
GET /api/projects GET /api/projects/events (SSE)
|
GET /api/projects GET /api/projects/events (SSE)
|
||||||
GET /api/catalog GET /api/catalog/events (SSE) 旧结构(兼容)
|
|
||||||
GET /api/catalog2 分组树 + 卡片(新前台/后台)
|
GET /api/catalog2 分组树 + 卡片(新前台/后台)
|
||||||
GET /api/cards 卡片列表
|
GET /api/cards 卡片列表
|
||||||
POST /api/nodes {key,title,parentKey?,format?} 需登录
|
POST /api/nodes {key,title,parentKey?,icon?,sort?,enabled?} 需登录
|
||||||
PATCH /api/nodes/{key} {title,parentKey?,sort?,format?} 需登录
|
PATCH /api/nodes/{key} {title,parentKey?,sort?,icon?,enabled?} 需登录
|
||||||
DELETE /api/nodes/{key} 需登录,级联
|
DELETE /api/nodes/{key} 需登录,级联
|
||||||
POST /api/cards {title} 需登录
|
POST /api/cards {title} 需登录
|
||||||
PATCH /api/cards/{id} {title?,sort?} 需登录
|
PATCH /api/cards/{id} {title?,sort?} 需登录
|
||||||
@@ -162,26 +161,20 @@ DELETE /api/items/{key} 需登录
|
|||||||
POST /api/items/{key}/view
|
POST /api/items/{key}/view
|
||||||
POST /api/items/{key}/like {liked}
|
POST /api/items/{key}/like {liked}
|
||||||
|
|
||||||
POST /api/categories {id,title,kind} 需登录
|
|
||||||
PATCH /api/categories/{id} {title,icon,barColor,kind,previewLimit,emptyText,blockNodeKey,allowGroups} 需登录
|
|
||||||
POST /api/categories/{id}/groups {id,title} 需登录,仅应用分区
|
|
||||||
DELETE /api/categories/{id} 需登录,级联,local 不可删
|
|
||||||
PATCH /api/groups/{id} {title,showInSidebar} 需登录
|
|
||||||
DELETE /api/groups/{id} 需登录,级联组内条目
|
|
||||||
|
|
||||||
GET /api/backup SQLite 快照附件 需登录
|
GET /api/backup SQLite 快照附件 需登录
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. 数据库与迁移
|
## 5. 数据库与迁移
|
||||||
|
|
||||||
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。
|
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。
|
||||||
- 当前 `PRAGMA user_version = 22`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21→22、更高版本拒绝启动。
|
- 当前 `PRAGMA user_version = 23`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21→22→23、更高版本拒绝启动。
|
||||||
- 格式在 `entry.format`(apps / posters / articles),同组可混合;`node` 的旧 `view_mode` 仅保留兼容,不再决定渲染。
|
- 格式在 `entry.format`(apps / posters / articles),同组可混合;`node` 不再保存格式。
|
||||||
|
- v23 迁移删除 `section_block` 表与 `node` 的 `view_mode / empty_text / show_in_sidebar / allow_groups` 字段。
|
||||||
- `settings.sidebar.show_profile / show_service` 控制侧栏两张卡片显隐(默认 true)。
|
- `settings.sidebar.show_profile / show_service` 控制侧栏两张卡片显隐(默认 true)。
|
||||||
- 首页滚动:`#page-scroll`(Layout 的 body)是唯一滚动容器,搜索框固定在其上方;左右两栏顶部对齐,左侧到底后通过 `translateY` 吸底,右侧继续滚动;滚动条隐藏;页头实底,搜索框 50% 透明,顶部另有一层上下渐隐的软虚化(`blur(8px)`)。
|
- 首页滚动:`#page-scroll`(Layout 的 body)是唯一滚动容器,搜索框固定在其上方;左右两栏顶部对齐,左侧到底后通过 `translateY` 吸底,右侧继续滚动;滚动条隐藏;页头实底,搜索框 50% 透明,顶部另有一层上下渐隐的软虚化(`blur(8px)`)。
|
||||||
- 本地服务是运行时卡片,但 `card` 表里有一行 `local-service` 只用于排序;内容来自 `/api/projects`,后端禁止删除/改名/配置分组。
|
- 本地服务是运行时卡片,但 `card` 表里有一行 `local-service` 只用于排序;内容来自 `/api/projects`,后端禁止删除/改名/配置分组。
|
||||||
- `node` 是统一分组树(根节点 `kind='section'`,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。
|
- `node` 是统一分组树(根节点 `kind='section'`,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。
|
||||||
- 主要表:`node`、`entry`、`entry_tag`、`section_block`、`admin`、`session`、`setting`、`audit`。
|
- 主要表:`node`、`entry`、`entry_tag`、`card`、`card_group`、`admin`、`session`、`setting`、`audit`。
|
||||||
- `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。
|
- `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。
|
||||||
- **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。
|
- **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。
|
||||||
|
|
||||||
@@ -223,7 +216,7 @@ GET /api/backup SQLite 快照附件 需登录
|
|||||||
| 分组移动 | 编辑弹窗里可改「上级分组」(防环);根分组不能移到子级 |
|
| 分组移动 | 编辑弹窗里可改「上级分组」(防环);根分组不能移到子级 |
|
||||||
| 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) |
|
| 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) |
|
||||||
| 分组排序 | 可编辑「排序权重」数值;拖拽排序未做 |
|
| 分组排序 | 可编辑「排序权重」数值;拖拽排序未做 |
|
||||||
| 旧接口 | `/api/catalog`、`/api/categories/*`、`/api/groups/*` 仍保留给旧组件/兼容,后续可删 |
|
| 旧接口 | 已删除 `/api/catalog`、`/api/categories/*`、旧 `/api/groups/{id}`;当前只保留 `/api/catalog2` 与 `/api/nodes` |
|
||||||
| 登录限流 | 进程内计数,重启清空 |
|
| 登录限流 | 进程内计数,重启清空 |
|
||||||
|
|
||||||
## 10. 约定与坑
|
## 10. 约定与坑
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
|||||||
(`src/server.rs` 的 `SPA_PREFIXES` / `SPA_PAGES`),托管目录里写错的地址仍然是 404 页面——
|
(`src/server.rs` 的 `SPA_PREFIXES` / `SPA_PAGES`),托管目录里写错的地址仍然是 404 页面——
|
||||||
不做「所有未命中都返回 200」的万能回落,否则分不清是路由拼错还是文件真的不存在;
|
不做「所有未命中都返回 200」的万能回落,否则分不清是路由拼错还是文件真的不存在;
|
||||||
- 地址转换规则在 [`stash-web/src/data/view.ts`](stash-web/src/data/view.ts),
|
- 地址转换规则在 [`stash-web/src/data/view.ts`](stash-web/src/data/view.ts),
|
||||||
与 history 打交道的部分在 [`stash-web/src/useViewRoute.ts`](stash-web/src/useViewRoute.ts)。
|
实际解析与跳转在 [`stash-web/src/AppNext.tsx`](stash-web/src/AppNext.tsx) 的 `useRoute` / `navigate`。
|
||||||
新增一类页面时两处都要改:前端加前缀,服务端把它加进 `SPA_PREFIXES` 或 `SPA_PAGES`;
|
新增一类页面时两处都要改:前端加前缀,服务端把它加进 `SPA_PREFIXES` 或 `SPA_PAGES`;
|
||||||
- 站内跳转统一走 `navigate()`(`AppNext.tsx`),不整页刷新;按住 Ctrl/Cmd 点卡片或链接仍可新标签打开。
|
- 站内跳转统一走 `navigate()`(`AppNext.tsx`),不整页刷新;按住 Ctrl/Cmd 点卡片或链接仍可新标签打开。
|
||||||
|
|
||||||
@@ -153,12 +153,6 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
|||||||
../stash-ui/ # 同级目录:@stash/ui 组件库(令牌 / 组件 / 展示站)
|
../stash-ui/ # 同级目录:@stash/ui 组件库(令牌 / 组件 / 展示站)
|
||||||
```
|
```
|
||||||
|
|
||||||
`templates/` 与 `assets/` 是上一版 Askama 控制台留下的文件,当前已无任何代码引用,
|
|
||||||
可以直接删除。
|
|
||||||
|
|
||||||
`templates/` 与 `assets/` 是上一版 Askama 控制台留下的文件,当前已无任何代码引用,
|
|
||||||
可以直接删除。
|
|
||||||
|
|
||||||
## 前端怎么被内嵌
|
## 前端怎么被内嵌
|
||||||
|
|
||||||
1. `npm run build` 产出 `stash-web/dist`;
|
1. `npm run build` 产出 `stash-web/dist`;
|
||||||
@@ -207,19 +201,15 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
|||||||
| `parent_key` | TEXT? | 父节点编码:分组指向所属分区,分区为 NULL |
|
| `parent_key` | TEXT? | 父节点编码:分组指向所属分区,分区为 NULL |
|
||||||
| `title` | TEXT | 展示名:分区标题 / 分组小标题 |
|
| `title` | TEXT | 展示名:分区标题 / 分组小标题 |
|
||||||
| `kind` | TEXT | `section` 顶层分区 / `group` 分区下的分组 |
|
| `kind` | TEXT | `section` 顶层分区 / `group` 分区下的分组 |
|
||||||
| `view_mode` | TEXT? | 仅分区:`apps` / `posters` / `articles`,决定渲染形态 |
|
| `bar_color` | TEXT? | 标题左侧色条颜色(如 `#3858f6`) |
|
||||||
| `bar_color` | TEXT? | 仅分区:标题左侧 4×16 色条颜色(如 `#3858f6`) |
|
| `icon` | TEXT? | 图标:可以是字符(`🎬`),也可以是 iconfont 图标名(`film_line`) |
|
||||||
| `icon` | TEXT? | 分组图标:可以是字符(`🎬`),也可以是 iconfont 图标名(`film_line`) |
|
|
||||||
| `preview_limit` | INTEGER? | 分组在卡片里的默认预览条数;为空时按类型取默认 |
|
| `preview_limit` | INTEGER? | 分组在卡片里的默认预览条数;为空时按类型取默认 |
|
||||||
| `empty_text` | TEXT? | 分组为空时的提示文案 |
|
| `enabled` | INTEGER | 是否启用,`0` / `1`;停用后侧栏与卡片都不再出现该子树 |
|
||||||
| `show_in_sidebar` | INTEGER | 是否出现在侧栏,`0` / `1`;0 表示只当首页卡片/内容,不进导航 |
|
|
||||||
| `allow_groups` | INTEGER | 是否允许新建子分组,`0` / `1`;与类型无关,后台开关 |
|
|
||||||
| `enabled` | INTEGER | 是否启用,`0` / `1`;**唯一可见性来源**,停用后侧栏与卡片都不再出现该子树 |
|
|
||||||
| `sort` | INTEGER | 同级顺序,越小越靠前 |
|
| `sort` | INTEGER | 同级顺序,越小越靠前 |
|
||||||
|
|
||||||
两条约束写在表上:
|
两条约束写在表上:
|
||||||
|
|
||||||
- `CHECK`:`section` 必须 `parent_key IS NULL` 且有 `view_mode`;`group` 必须有父节点且无 `view_mode`;
|
- `CHECK`:`section` 必须 `parent_key IS NULL`;`group` 必须有父节点;
|
||||||
- `key` 唯一(UNIQUE),全局不重复。
|
- `key` 唯一(UNIQUE),全局不重复。
|
||||||
|
|
||||||
#### entry —— 内容条目(网站 / 电影 / 文章共用)
|
#### entry —— 内容条目(网站 / 电影 / 文章共用)
|
||||||
@@ -229,6 +219,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
|||||||
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
|
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
|
||||||
| `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 |
|
| `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 |
|
||||||
| `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 |
|
| `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 |
|
||||||
|
| `format` | TEXT | 展示格式:`apps` / `posters` / `articles`;逐条设置,同一分组可混合 |
|
||||||
| `name` | TEXT | 名称:网站名 / 电影名 / 文章标题 |
|
| `name` | TEXT | 名称:网站名 / 电影名 / 文章标题 |
|
||||||
| `description` | TEXT? | 一句话简介,列表卡片的副标题 |
|
| `description` | TEXT? | 一句话简介,列表卡片的副标题 |
|
||||||
| `icon` | TEXT? | 字符图标,封面缺失时的占位 |
|
| `icon` | TEXT? | 字符图标,封面缺失时的占位 |
|
||||||
@@ -257,7 +248,7 @@ WHERE COALESCE(p.parent_key, p.key) = 'recommend';
|
|||||||
好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况;
|
好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况;
|
||||||
`node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。
|
`node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。
|
||||||
|
|
||||||
三种内容共用一张表,只是**用到的字段不同**,形态由所属分区的 `view_mode` 决定:
|
三种内容共用一张表,只是**用到的字段不同**,形态由每条自己的 `entry.format` 决定:
|
||||||
|
|
||||||
| 类型 | 常用字段 |
|
| 类型 | 常用字段 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -314,8 +305,8 @@ card_group 卡片包含哪些分组 + 每个分组的预览条数与先后
|
|||||||
主键 `(card_id, group_key)`:同一张卡片里一个分组只出现一次。
|
主键 `(card_id, group_key)`:同一张卡片里一个分组只出现一次。
|
||||||
一张卡片可以混合展示多个分组(不同格式也能混),顺序与条数各自独立。
|
一张卡片可以混合展示多个分组(不同格式也能混),顺序与条数各自独立。
|
||||||
|
|
||||||
旧版的 `section_block` 表仍保留在库里(`CREATE TABLE IF NOT EXISTS` 不会删表),
|
旧版的 `section_block` 表已在 v23 迁移中删除;旧库升级时会先把数据回填到
|
||||||
但只作为从旧库迁移到 `card` / `card_group` 的来源,新代码不再读写它。
|
`card` / `card_group`,再删表,不会丢内容。
|
||||||
|
|
||||||
### 表关系
|
### 表关系
|
||||||
|
|
||||||
@@ -353,7 +344,7 @@ card 1 ──── n card_group ──── 1 node(勾选的分组 + 每个
|
|||||||
| 应用详情 | `entry`(apps) | `name` `description` `icon` `href` `cover` `banner` `badge` `comment` `views` `likes` |
|
| 应用详情 | `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`)`cover` `comment` `summary` `href` + `tags` |
|
||||||
| 图文详情 | `entry` + `entry_tag` | `name`(→`title`)`description` `cover` `body` + `meta` |
|
| 图文详情 | `entry` + `entry_tag` | `name`(→`title`)`description` `cover` `body` + `meta` |
|
||||||
| 侧栏导航 | `node` | 走 `parent_key` 的分组树,`show_in_sidebar = 0` 与 `enabled = 0` 的节点跳过;「本地服务」由前端排除 |
|
| 侧栏导航 | `node` | 走 `parent_key` 的分组树,`enabled = 0` 的节点跳过;「本地服务」由前端排除 |
|
||||||
| 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 |
|
| 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 |
|
||||||
|
|
||||||
### 侧栏导航
|
### 侧栏导航
|
||||||
@@ -376,13 +367,13 @@ CES 现场报道(根分组,无子分组 → 直接打开)
|
|||||||
- 有子分组时渲染成可折叠节点,子项就是子分组;
|
- 有子分组时渲染成可折叠节点,子项就是子分组;
|
||||||
- 没有子分组时整行是链接;
|
- 没有子分组时整行是链接;
|
||||||
- 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层;
|
- 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层;
|
||||||
- `show_in_sidebar = 0` 的节点不出现;`enabled = 0` 的节点连同整棵子树都不出现;
|
- `enabled = 0` 的节点连同整棵子树都不出现;
|
||||||
- 顺序取 `node.sort`(在「分组管理」里改「排序权重」);
|
- 顺序取 `node.sort`(在「分组管理」里改「排序权重」);
|
||||||
- 「本地服务」是前端拼出来的分类,侧栏按 key 直接排除。
|
- 「本地服务」是前端拼出来的分类,侧栏按 key 直接排除。
|
||||||
|
|
||||||
### 首页卡片
|
### 首页卡片
|
||||||
|
|
||||||
一张卡片 = 一个标题 + 一组勾选的分组,内容不混排:
|
一张卡片 = 一个标题 + 一组勾选的分组,分组各自渲染、可以混合格式:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- 看看「常用推荐」这张卡片勾了哪些分组、各自几条
|
-- 看看「常用推荐」这张卡片勾了哪些分组、各自几条
|
||||||
@@ -508,7 +499,6 @@ GET /api/catalog2
|
|||||||
"key": "pin",
|
"key": "pin",
|
||||||
"title": "常用推荐",
|
"title": "常用推荐",
|
||||||
"icon": "🔖",
|
"icon": "🔖",
|
||||||
"format": "apps",
|
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sort": 1,
|
"sort": 1,
|
||||||
"entries": [{ "id": "1", "name": "1", "format": "apps" }],
|
"entries": [{ "id": "1", "name": "1", "format": "apps" }],
|
||||||
@@ -526,11 +516,9 @@ GET /api/catalog2
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
「本地服务」卡片**不在**这个接口里,它由 `/api/projects` 扫描托管目录后在前端拼接。
|
「本地服务」卡片在 `cards` 里也有一行 `local-service`,但内容由 `/api/projects` 运行时生成。
|
||||||
侧栏不再单独下发,前端直接用 `groups` 派生。
|
侧栏不再单独下发,前端直接用 `groups` 派生。
|
||||||
|
|
||||||
`GET /api/catalog` 是旧版结构(`categories` 数组),仅为兼容保留。
|
|
||||||
|
|
||||||
### 订阅内容库变化
|
### 订阅内容库变化
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,198 +0,0 @@
|
|||||||
(() => {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const grid = document.querySelector("#project-grid");
|
|
||||||
const refreshButton = document.querySelector("#refresh-projects");
|
|
||||||
const status = document.querySelector("#refresh-status");
|
|
||||||
const projectCounts = document.querySelectorAll("[data-project-count]");
|
|
||||||
const liveStatus = document.querySelector("#live-status");
|
|
||||||
const liveStatusLabel = document.querySelector("#live-status-label");
|
|
||||||
|
|
||||||
if (!grid || !refreshButton || !status || !liveStatus || !liveStatusLabel) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.classList.add("is-enhanced");
|
|
||||||
|
|
||||||
const createProjectCard = (project) => {
|
|
||||||
if (
|
|
||||||
typeof project.name !== "string" ||
|
|
||||||
typeof project.route_path !== "string" ||
|
|
||||||
!project.route_path.startsWith("/") ||
|
|
||||||
project.route_path.startsWith("//")
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.className = "site-item";
|
|
||||||
link.href = project.route_path;
|
|
||||||
link.title = project.name;
|
|
||||||
|
|
||||||
const cardTop = document.createElement("div");
|
|
||||||
cardTop.className = "site-card-top";
|
|
||||||
|
|
||||||
const avatar = document.createElement("span");
|
|
||||||
avatar.className = "site-avatar";
|
|
||||||
avatar.setAttribute("aria-hidden", "true");
|
|
||||||
avatar.textContent = Array.from(project.name)[0]?.toUpperCase() || "A";
|
|
||||||
|
|
||||||
const open = document.createElement("span");
|
|
||||||
open.className = "site-open";
|
|
||||||
open.setAttribute("aria-hidden", "true");
|
|
||||||
open.innerHTML = `
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M7 17 17 7M8 7h9v9"></path>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
cardTop.append(avatar, open);
|
|
||||||
|
|
||||||
const info = document.createElement("div");
|
|
||||||
info.className = "site-info";
|
|
||||||
|
|
||||||
const name = document.createElement("h3");
|
|
||||||
name.className = "site-name";
|
|
||||||
name.textContent = project.name;
|
|
||||||
|
|
||||||
const description = document.createElement("span");
|
|
||||||
description.className = "site-desc";
|
|
||||||
description.textContent = "本地静态应用";
|
|
||||||
info.append(name, description);
|
|
||||||
|
|
||||||
const footer = document.createElement("div");
|
|
||||||
footer.className = "site-card-footer";
|
|
||||||
|
|
||||||
const path = document.createElement("span");
|
|
||||||
path.className = "site-path";
|
|
||||||
path.textContent = project.route_path;
|
|
||||||
|
|
||||||
const ready = document.createElement("span");
|
|
||||||
ready.className = "site-ready";
|
|
||||||
ready.textContent = "READY";
|
|
||||||
|
|
||||||
footer.append(path, ready);
|
|
||||||
link.append(cardTop, info, footer);
|
|
||||||
return link;
|
|
||||||
};
|
|
||||||
|
|
||||||
let renderedSignature = JSON.stringify(
|
|
||||||
Array.from(grid.querySelectorAll(".site-item")).map((link) => [
|
|
||||||
link.title,
|
|
||||||
link.getAttribute("href"),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectsSignature = (projects) =>
|
|
||||||
JSON.stringify(
|
|
||||||
projects.map((project) => [project.name, project.route_path]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderProjects = (projects) => {
|
|
||||||
const nextSignature = projectsSignature(projects);
|
|
||||||
if (nextSignature === renderedSignature) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
grid.replaceChildren();
|
|
||||||
|
|
||||||
if (projects.length === 0) {
|
|
||||||
const emptyState = document.createElement("div");
|
|
||||||
emptyState.className = "empty-state";
|
|
||||||
emptyState.innerHTML = `
|
|
||||||
<span class="empty-visual" aria-hidden="true">
|
|
||||||
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
|
|
||||||
<path d="M8 13h8M8 16h5"></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<h3>这里还没有项目</h3>
|
|
||||||
<p>在托管根目录下创建子目录,并放入 <code>index.html</code>,刷新后即可在这里看到它。</p>
|
|
||||||
`;
|
|
||||||
grid.append(emptyState);
|
|
||||||
renderedSignature = nextSignature;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cards = projects.map(createProjectCard).filter(Boolean);
|
|
||||||
grid.append(...cards);
|
|
||||||
renderedSignature = nextSignature;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProjectCounts = (count) => {
|
|
||||||
projectCounts.forEach((element) => {
|
|
||||||
element.textContent = String(count);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
refreshButton.addEventListener("click", async () => {
|
|
||||||
refreshButton.disabled = true;
|
|
||||||
refreshButton.classList.add("is-loading");
|
|
||||||
grid.setAttribute("aria-busy", "true");
|
|
||||||
status.textContent = "正在重新扫描项目...";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/projects", {
|
|
||||||
cache: "no-store",
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
if (!Array.isArray(payload.projects)) {
|
|
||||||
throw new Error("项目列表格式无效");
|
|
||||||
}
|
|
||||||
|
|
||||||
const changed = renderProjects(payload.projects);
|
|
||||||
updateProjectCounts(payload.projects.length);
|
|
||||||
status.textContent = changed ? "项目列表已刷新" : "项目列表已是最新";
|
|
||||||
} catch (error) {
|
|
||||||
console.error("刷新项目列表失败", error);
|
|
||||||
status.textContent = "刷新失败,请稍后重试";
|
|
||||||
} finally {
|
|
||||||
refreshButton.disabled = false;
|
|
||||||
refreshButton.classList.remove("is-loading");
|
|
||||||
grid.removeAttribute("aria-busy");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!("EventSource" in window)) {
|
|
||||||
liveStatusLabel.textContent = "手动刷新";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventSource = new EventSource("/api/projects/events");
|
|
||||||
|
|
||||||
eventSource.addEventListener("open", () => {
|
|
||||||
liveStatusLabel.textContent = "实时同步";
|
|
||||||
liveStatus.classList.add("is-live");
|
|
||||||
liveStatus.classList.remove("is-reconnecting");
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener("projects", (event) => {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event.data);
|
|
||||||
if (!Array.isArray(payload.projects)) {
|
|
||||||
throw new Error("项目列表格式无效");
|
|
||||||
}
|
|
||||||
|
|
||||||
const changed = renderProjects(payload.projects);
|
|
||||||
updateProjectCounts(payload.projects.length);
|
|
||||||
if (changed) {
|
|
||||||
status.textContent = "检测到目录变化,项目列表已自动更新";
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("处理项目更新事件失败", error);
|
|
||||||
status.textContent = "收到项目更新,但无法解析";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener("error", () => {
|
|
||||||
liveStatusLabel.textContent = "同步重连中";
|
|
||||||
liveStatus.classList.remove("is-live");
|
|
||||||
liveStatus.classList.add("is-reconnecting");
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
+12
-343
@@ -11,23 +11,10 @@ use rusqlite::Connection;
|
|||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::{Catalog, map_error},
|
db::map_error,
|
||||||
error::StashError,
|
error::StashError,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 容器节点行:分区与分组共用
|
|
||||||
struct NodeRow {
|
|
||||||
key: String,
|
|
||||||
title: String,
|
|
||||||
view_mode: Option<String>,
|
|
||||||
bar_color: Option<String>,
|
|
||||||
icon: Option<String>,
|
|
||||||
preview_limit: Option<i64>,
|
|
||||||
empty_text: Option<String>,
|
|
||||||
show_in_sidebar: bool,
|
|
||||||
allow_groups: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 条目行:与 `entry` 表的列一一对应
|
/// 条目行:与 `entry` 表的列一一对应
|
||||||
struct EntryRow {
|
struct EntryRow {
|
||||||
key: String,
|
key: String,
|
||||||
@@ -47,19 +34,11 @@ struct EntryRow {
|
|||||||
likes: i64,
|
likes: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读出整个内容目录。
|
|
||||||
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
|
|
||||||
Ok(Catalog {
|
|
||||||
categories: load_sections(conn)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 分组树节点(新视图用)
|
/// 分组树节点(新视图用)
|
||||||
struct TreeNode {
|
struct TreeNode {
|
||||||
key: String,
|
key: String,
|
||||||
parent: Option<String>,
|
parent: Option<String>,
|
||||||
title: String,
|
title: String,
|
||||||
view_mode: Option<String>,
|
|
||||||
icon: Option<String>,
|
icon: Option<String>,
|
||||||
bar_color: Option<String>,
|
bar_color: Option<String>,
|
||||||
preview_limit: Option<i64>,
|
preview_limit: Option<i64>,
|
||||||
@@ -67,11 +46,11 @@ struct TreeNode {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。
|
/// 新的原子视图:完整分组树(多层、带条目),供新首页/侧栏/后台使用。
|
||||||
pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT key, parent_key, title, view_mode, icon, bar_color, preview_limit, sort, enabled
|
"SELECT key, parent_key, title, icon, bar_color, preview_limit, sort, enabled
|
||||||
FROM node ORDER BY sort, key",
|
FROM node ORDER BY sort, key",
|
||||||
)
|
)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
@@ -82,12 +61,11 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
|||||||
key: row.get(0)?,
|
key: row.get(0)?,
|
||||||
parent: row.get(1)?,
|
parent: row.get(1)?,
|
||||||
title: row.get(2)?,
|
title: row.get(2)?,
|
||||||
view_mode: row.get(3)?,
|
icon: row.get(3)?,
|
||||||
icon: row.get(4)?,
|
bar_color: row.get(4)?,
|
||||||
bar_color: row.get(5)?,
|
preview_limit: row.get(5)?,
|
||||||
preview_limit: row.get(6)?,
|
sort: row.get(6)?,
|
||||||
sort: row.get(7)?,
|
enabled: row.get(7)?,
|
||||||
enabled: row.get(8)?,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(map_error)?
|
.map_err(map_error)?
|
||||||
@@ -96,48 +74,22 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
|||||||
|
|
||||||
drop(stmt);
|
drop(stmt);
|
||||||
|
|
||||||
let index: HashMap<String, usize> = nodes
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, node)| (node.key.clone(), idx))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// 子节点索引(按 sort 已排好序)
|
// 子节点索引(按 sort 已排好序)
|
||||||
let mut children: HashMap<Option<String>, Vec<usize>> = HashMap::new();
|
let mut children: HashMap<Option<String>, Vec<usize>> = HashMap::new();
|
||||||
for (idx, node) in nodes.iter().enumerate() {
|
for (idx, node) in nodes.iter().enumerate() {
|
||||||
children.entry(node.parent.clone()).or_default().push(idx);
|
children.entry(node.parent.clone()).or_default().push(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每个节点继承最近根分组的格式
|
|
||||||
let mut formats: Vec<String> = vec![String::new(); nodes.len()];
|
|
||||||
for idx in 0..nodes.len() {
|
|
||||||
let mut cursor = Some(idx);
|
|
||||||
let mut format = "apps".to_string();
|
|
||||||
while let Some(current) = cursor {
|
|
||||||
if let Some(mode) = &nodes[current].view_mode {
|
|
||||||
format = mode.clone();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
cursor = nodes[current]
|
|
||||||
.parent
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|parent| index.get(parent).copied());
|
|
||||||
}
|
|
||||||
formats[idx] = format;
|
|
||||||
}
|
|
||||||
|
|
||||||
let tags = load_all_tags(conn)?;
|
let tags = load_all_tags(conn)?;
|
||||||
|
|
||||||
fn render(
|
fn render(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
nodes: &[TreeNode],
|
nodes: &[TreeNode],
|
||||||
children: &HashMap<Option<String>, Vec<usize>>,
|
children: &HashMap<Option<String>, Vec<usize>>,
|
||||||
formats: &[String],
|
|
||||||
tags: &HashMap<String, Vec<String>>,
|
tags: &HashMap<String, Vec<String>>,
|
||||||
idx: usize,
|
idx: usize,
|
||||||
) -> Result<Value, StashError> {
|
) -> Result<Value, StashError> {
|
||||||
let node = &nodes[idx];
|
let node = &nodes[idx];
|
||||||
let format = &formats[idx];
|
|
||||||
let entries = load_node_entries(conn, &node.key)?;
|
let entries = load_node_entries(conn, &node.key)?;
|
||||||
let items = entries
|
let items = entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -148,7 +100,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
|||||||
.get(&Some(node.key.clone()))
|
.get(&Some(node.key.clone()))
|
||||||
.map(|list| {
|
.map(|list| {
|
||||||
list.iter()
|
list.iter()
|
||||||
.map(|child| render(conn, nodes, children, formats, tags, *child))
|
.map(|child| render(conn, nodes, children, tags, *child))
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
})
|
})
|
||||||
.transpose()?
|
.transpose()?
|
||||||
@@ -158,7 +110,6 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
|||||||
"key": node.key,
|
"key": node.key,
|
||||||
"parentKey": node.parent,
|
"parentKey": node.parent,
|
||||||
"title": node.title,
|
"title": node.title,
|
||||||
"format": format,
|
|
||||||
"icon": node.icon.clone().unwrap_or_default(),
|
"icon": node.icon.clone().unwrap_or_default(),
|
||||||
"barColor": node.bar_color.clone().unwrap_or_default(),
|
"barColor": node.bar_color.clone().unwrap_or_default(),
|
||||||
"previewLimit": node.preview_limit,
|
"previewLimit": node.preview_limit,
|
||||||
@@ -173,7 +124,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
|||||||
.get(&None)
|
.get(&None)
|
||||||
.map(|list| {
|
.map(|list| {
|
||||||
list.iter()
|
list.iter()
|
||||||
.map(|idx| render(conn, &nodes, &children, &formats, &tags, *idx))
|
.map(|idx| render(conn, &nodes, &children, &tags, *idx))
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
})
|
})
|
||||||
.transpose()?
|
.transpose()?
|
||||||
@@ -203,164 +154,6 @@ fn load_all_tags(conn: &Connection) -> Result<HashMap<String, Vec<String>>, Stas
|
|||||||
Ok(grouped)
|
Ok(grouped)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。
|
|
||||||
fn load_sections(conn: &Connection) -> Result<Vec<Value>, StashError> {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups
|
|
||||||
FROM node
|
|
||||||
WHERE kind = 'section'
|
|
||||||
ORDER BY sort, key",
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
let sections = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok(NodeRow {
|
|
||||||
key: row.get(0)?,
|
|
||||||
title: row.get(1)?,
|
|
||||||
view_mode: row.get(2)?,
|
|
||||||
bar_color: row.get(3)?,
|
|
||||||
icon: row.get(4)?,
|
|
||||||
preview_limit: row.get(5)?,
|
|
||||||
empty_text: row.get(6)?,
|
|
||||||
show_in_sidebar: row.get(7)?,
|
|
||||||
allow_groups: row.get(8)?,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(map_error)?
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
// 先释放语句再发后续查询,避免同一连接上的借用冲突
|
|
||||||
drop(stmt);
|
|
||||||
|
|
||||||
// 每个分区对应的首页卡片:展示哪个节点、几条
|
|
||||||
let blocks = load_blocks(conn)?;
|
|
||||||
|
|
||||||
sections
|
|
||||||
.iter()
|
|
||||||
.map(|section| build_section(conn, section, blocks.get(§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));
|
|
||||||
object.insert("allowGroups".into(), json!(section.allow_groups));
|
|
||||||
|
|
||||||
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
|
|
||||||
if let Some(limit) = section.preview_limit {
|
|
||||||
object.insert("previewLimit".into(), json!(limit));
|
|
||||||
}
|
|
||||||
if let Some(empty_text) = §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" | "posters" | "articles" => {}
|
|
||||||
other => {
|
|
||||||
return Err(StashError::Database(format!(
|
|
||||||
"分区 {} 的 view_mode 非法: {other}",
|
|
||||||
section.key
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 「允许子分组」开启:分区 → 分组 → 条目;关闭:全部条目平铺
|
|
||||||
let tags = load_tags(conn, §ion.key)?;
|
|
||||||
if section.allow_groups {
|
|
||||||
object.insert(
|
|
||||||
"groups".into(),
|
|
||||||
build_groups(conn, section, &tags)?,
|
|
||||||
);
|
|
||||||
let direct = load_node_entries(conn, §ion.key)?;
|
|
||||||
let items = direct
|
|
||||||
.iter()
|
|
||||||
.map(|entry| item_for(entry, &tags))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
object.insert("items".into(), json!(items));
|
|
||||||
} else {
|
|
||||||
object.insert("groups".into(), json!([]));
|
|
||||||
// 平铺:把(可能存在的)分组里的条目一起取出来,关闭开关时不丢内容
|
|
||||||
let entries = load_section_entries(conn, §ion.key)?;
|
|
||||||
let items = entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| item_for(entry, &tags))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
object.insert("items".into(), json!(items));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Value::Object(object))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按条目自身的格式组装列表项。
|
/// 按条目自身的格式组装列表项。
|
||||||
fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value {
|
fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value {
|
||||||
let mut value = match entry.format.as_str() {
|
let mut value = match entry.format.as_str() {
|
||||||
@@ -374,55 +167,6 @@ fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value {
|
|||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取出某个分区下的分组,并把条目按 `node_key` 分派进去。
|
|
||||||
fn build_groups(
|
|
||||||
conn: &Connection,
|
|
||||||
section: &NodeRow,
|
|
||||||
tags: &HashMap<String, Vec<String>>,
|
|
||||||
) -> Result<Value, StashError> {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT key, title, show_in_sidebar FROM node
|
|
||||||
WHERE kind = 'group' AND parent_key = ?1
|
|
||||||
ORDER BY sort, key",
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
let groups = stmt
|
|
||||||
.query_map([§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 mut rendered_groups = Vec::with_capacity(groups.len());
|
|
||||||
for (group_key, group_title, show_in_sidebar) in groups {
|
|
||||||
let entries = load_node_entries(conn, &group_key)?;
|
|
||||||
let items = entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| item_for(entry, tags))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
rendered_groups.push(json!({
|
|
||||||
// 对外用业务键,不用数字主键
|
|
||||||
"id": group_key,
|
|
||||||
"name": group_title,
|
|
||||||
"showInSidebar": show_in_sidebar,
|
|
||||||
"items": items,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Value::Array(rendered_groups))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。
|
|
||||||
fn app_item(entry: &EntryRow) -> Value {
|
fn app_item(entry: &EntryRow) -> Value {
|
||||||
let mut object = Map::new();
|
let mut object = Map::new();
|
||||||
object.insert("id".into(), json!(entry.key));
|
object.insert("id".into(), json!(entry.key));
|
||||||
@@ -504,55 +248,9 @@ fn insert_optional(object: &mut Map<String, Value>, key: &str, value: &Option<St
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取某个分区下的全部条目,按 `sort` 升序。
|
/// 取某个节点直接挂着的条目,按 `sort` 升序。
|
||||||
///
|
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> {
|
||||||
/// 条目只存 `node_key`,所属分区由父节点推导:
|
|
||||||
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
|
|
||||||
/// 取某个分区下的全部条目(含分组内的),海报 / 图文平铺用。
|
|
||||||
fn load_section_entries(
|
|
||||||
conn: &Connection,
|
|
||||||
section_key: &str,
|
|
||||||
) -> Result<Vec<EntryRow>, StashError> {
|
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
|
||||||
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
|
|
||||||
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
|
|
||||||
COALESCE(e.format, 'apps')
|
|
||||||
FROM entry e
|
|
||||||
JOIN node p ON p.key = e.node_key
|
|
||||||
WHERE COALESCE(p.parent_key, p.key) = ?1
|
|
||||||
ORDER BY e.sort, e.key",
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
let entries = stmt
|
|
||||||
.query_map([section_key], |row| {
|
|
||||||
Ok(EntryRow {
|
|
||||||
key: row.get(0)?,
|
|
||||||
name: row.get(1)?,
|
|
||||||
description: row.get(2)?,
|
|
||||||
icon: row.get(3)?,
|
|
||||||
href: row.get(4)?,
|
|
||||||
cover: row.get(5)?,
|
|
||||||
banner: row.get(6)?,
|
|
||||||
badge: row.get(7)?,
|
|
||||||
comment: row.get(8)?,
|
|
||||||
summary: row.get(9)?,
|
|
||||||
body: row.get(10)?,
|
|
||||||
hot: row.get(11)?,
|
|
||||||
views: row.get(12)?,
|
|
||||||
likes: row.get(13)?,
|
|
||||||
format: row.get(14)?,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(map_error)?
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> { let mut stmt = conn
|
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
|
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
|
||||||
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
|
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
|
||||||
@@ -589,32 +287,3 @@ fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>,
|
|||||||
|
|
||||||
Ok(entries)
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,827 +0,0 @@
|
|||||||
//! 分区(首页卡片 / 侧栏项)的新增与删除。
|
|
||||||
//!
|
|
||||||
//! 分区就是 `node` 表里 `kind='section'` 的行;删除会级联分组、条目与展示块。
|
|
||||||
//! 新增时顺手建一条首页展示块,保证它立刻出现在首页。
|
|
||||||
|
|
||||||
use rusqlite::{Connection, OptionalExtension, params};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::{db::map_error, error::StashError, db::entries};
|
|
||||||
|
|
||||||
const KINDS: [&str; 3] = ["apps", "posters", "articles"];
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct NewCategory {
|
|
||||||
/// 业务键,只允许小写字母、数字与连字符
|
|
||||||
pub id: String,
|
|
||||||
pub title: String,
|
|
||||||
/// apps / posters / articles
|
|
||||||
pub kind: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 修改分区:字段都可选,只改传进来的。
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct UpdateCategory {
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub icon: Option<String>,
|
|
||||||
pub bar_color: Option<String>,
|
|
||||||
/// 改类型(应用 / 海报 / 图文)时会顺带迁移条目归属
|
|
||||||
pub kind: Option<String>,
|
|
||||||
pub preview_limit: Option<i64>,
|
|
||||||
pub empty_text: Option<String>,
|
|
||||||
/// 首页卡片预览哪个节点(分区自己或它下面的分组)
|
|
||||||
pub block_node_key: Option<String>,
|
|
||||||
/// 是否允许子分组(与类型无关,后台开关)
|
|
||||||
pub allow_groups: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 新建分组(子分类)
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct NewGroup {
|
|
||||||
pub id: String,
|
|
||||||
pub title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 修改分组:改名 / 侧栏显隐
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct UpdateGroup {
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub show_in_sidebar: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_id(id: &str) -> bool {
|
|
||||||
!id.is_empty()
|
|
||||||
&& id.len() <= 32
|
|
||||||
&& id
|
|
||||||
.chars()
|
|
||||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create(conn: &mut Connection, new: NewCategory) -> Result<(), StashError> {
|
|
||||||
let id = new.id.trim();
|
|
||||||
let title = new.title.trim();
|
|
||||||
|
|
||||||
if !valid_id(id) {
|
|
||||||
return Err(StashError::InvalidDirectory(
|
|
||||||
"标识只能用小写字母、数字与连字符(≤32)".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if title.is_empty() || title.chars().count() > 40 {
|
|
||||||
return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into()));
|
|
||||||
}
|
|
||||||
if !KINDS.contains(&new.kind.as_str()) {
|
|
||||||
return Err(StashError::InvalidDirectory("类型不合法".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let exists: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| row.get(0))
|
|
||||||
.map_err(map_error)?;
|
|
||||||
if exists > 0 {
|
|
||||||
return Err(StashError::Conflict(format!("分区已存在: {id}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
let tx = conn.transaction().map_err(map_error)?;
|
|
||||||
let sort: i64 = tx
|
|
||||||
.query_row(
|
|
||||||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE kind = 'section'",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, allow_groups, sort, show_in_sidebar)
|
|
||||||
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, 1)",
|
|
||||||
params![id, title, new.kind, i64::from(new.kind == "apps"), sort],
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
let block_sort: i64 = tx
|
|
||||||
.query_row(
|
|
||||||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO section_block (node_key, title, preview_limit, sort) VALUES (?1, NULL, NULL, ?2)",
|
|
||||||
params![id, block_sort],
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
entries::audit(&tx, "category.create", id, title)?;
|
|
||||||
tx.commit().map_err(map_error)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SectionRow {
|
|
||||||
title: String,
|
|
||||||
icon: Option<String>,
|
|
||||||
bar_color: Option<String>,
|
|
||||||
view_mode: Option<String>,
|
|
||||||
preview_limit: Option<i64>,
|
|
||||||
empty_text: Option<String>,
|
|
||||||
allow_groups: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_color(color: &str) -> bool {
|
|
||||||
if color.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let hex = color.strip_prefix('#').unwrap_or("");
|
|
||||||
(hex.len() == 3 || hex.len() == 6) && hex.chars().all(|ch| ch.is_ascii_hexdigit())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 修改分区:标题 / 图标 / 色条 / 预览条数 / 空态文案,以及「归类」类型。
|
|
||||||
///
|
|
||||||
/// 改类型时迁移数据,保证切换后条目不丢:
|
|
||||||
/// - 海报 / 图文 → 应用:确保有一个分组,把直接挂在分区上的条目挪进去,
|
|
||||||
/// 首页卡片也改指向该分组;
|
|
||||||
/// - 应用 → 海报 / 图文:卡片从分组挪回分区,分组保留(不影响条目读取)。
|
|
||||||
pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<(), StashError> {
|
|
||||||
if id == "local" {
|
|
||||||
return Err(StashError::InvalidDirectory(
|
|
||||||
"「本地服务」是运行时分类,不能编辑".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let tx = conn.transaction().map_err(map_error)?;
|
|
||||||
|
|
||||||
let current = tx
|
|
||||||
.query_row(
|
|
||||||
"SELECT title, icon, bar_color, view_mode, preview_limit, empty_text, allow_groups
|
|
||||||
FROM node WHERE key = ?1 AND kind = 'section'",
|
|
||||||
[id],
|
|
||||||
|row| {
|
|
||||||
Ok(SectionRow {
|
|
||||||
title: row.get(0)?,
|
|
||||||
icon: row.get(1)?,
|
|
||||||
bar_color: row.get(2)?,
|
|
||||||
view_mode: row.get(3)?,
|
|
||||||
preview_limit: row.get(4)?,
|
|
||||||
empty_text: row.get(5)?,
|
|
||||||
allow_groups: row.get(6)?,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.optional()
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
let Some(current) = current else {
|
|
||||||
return Err(StashError::NotFound(format!("分区不存在: {id}")));
|
|
||||||
};
|
|
||||||
|
|
||||||
let title = match patch.title {
|
|
||||||
Some(title) => {
|
|
||||||
let title = title.trim().to_string();
|
|
||||||
if title.is_empty() || title.chars().count() > 40 {
|
|
||||||
return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into()));
|
|
||||||
}
|
|
||||||
title
|
|
||||||
}
|
|
||||||
None => current.title,
|
|
||||||
};
|
|
||||||
|
|
||||||
let icon = match patch.icon {
|
|
||||||
Some(icon) => {
|
|
||||||
let icon = icon.trim().to_string();
|
|
||||||
if icon.chars().count() > 8 {
|
|
||||||
return Err(StashError::InvalidDirectory("图标最多 8 个字符".into()));
|
|
||||||
}
|
|
||||||
(!icon.is_empty()).then_some(icon)
|
|
||||||
}
|
|
||||||
None => current.icon,
|
|
||||||
};
|
|
||||||
|
|
||||||
let bar_color = match patch.bar_color {
|
|
||||||
Some(color) => {
|
|
||||||
let color = color.trim().to_ascii_lowercase();
|
|
||||||
if !valid_color(&color) {
|
|
||||||
return Err(StashError::InvalidDirectory(
|
|
||||||
"色条需为 #rgb 或 #rrggbb".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
(!color.is_empty()).then_some(color)
|
|
||||||
}
|
|
||||||
None => current.bar_color,
|
|
||||||
};
|
|
||||||
|
|
||||||
let empty_text = match patch.empty_text {
|
|
||||||
Some(text) => {
|
|
||||||
let text = text.trim().to_string();
|
|
||||||
(!text.is_empty()).then_some(text)
|
|
||||||
}
|
|
||||||
None => current.empty_text,
|
|
||||||
};
|
|
||||||
|
|
||||||
let preview_limit = match patch.preview_limit {
|
|
||||||
Some(limit) => {
|
|
||||||
if !(1..=60).contains(&limit) {
|
|
||||||
return Err(StashError::InvalidDirectory("预览条数需在 1–60 之间".into()));
|
|
||||||
}
|
|
||||||
Some(limit)
|
|
||||||
}
|
|
||||||
None => current.preview_limit,
|
|
||||||
};
|
|
||||||
|
|
||||||
let kind = match patch.kind {
|
|
||||||
Some(kind) => {
|
|
||||||
if !KINDS.contains(&kind.as_str()) {
|
|
||||||
return Err(StashError::InvalidDirectory("类型不合法".into()));
|
|
||||||
}
|
|
||||||
kind
|
|
||||||
}
|
|
||||||
None => current
|
|
||||||
.view_mode
|
|
||||||
.clone()
|
|
||||||
.ok_or_else(|| StashError::Database(format!("分区 {id} 缺少 view_mode")))?,
|
|
||||||
};
|
|
||||||
|
|
||||||
let was_apps = current.view_mode.as_deref() == Some("apps");
|
|
||||||
let is_apps = kind == "apps";
|
|
||||||
let allow_groups = patch.allow_groups.unwrap_or(current.allow_groups);
|
|
||||||
|
|
||||||
if is_apps && !was_apps && allow_groups {
|
|
||||||
// 切到应用且开启子分组:条目必须有分组可挂;没有就建一个「全部」
|
|
||||||
let group_count: i64 = tx
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM node WHERE kind = 'group' AND parent_key = ?1",
|
|
||||||
[id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
let group_key = if group_count == 0 {
|
|
||||||
let group_key = format!("{id}-all");
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO node (key, parent_key, title, kind, sort, show_in_sidebar)
|
|
||||||
VALUES (?1, ?2, '全部', 'group', 1, 1)",
|
|
||||||
params![group_key, id],
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
group_key
|
|
||||||
} else {
|
|
||||||
tx.query_row(
|
|
||||||
"SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1
|
|
||||||
ORDER BY sort, key LIMIT 1",
|
|
||||||
[id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(map_error)?
|
|
||||||
};
|
|
||||||
|
|
||||||
tx.execute(
|
|
||||||
"UPDATE entry SET node_key = ?1 WHERE node_key = ?2",
|
|
||||||
params![group_key, id],
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
tx.execute(
|
|
||||||
"UPDATE section_block SET node_key = ?1 WHERE node_key = ?2",
|
|
||||||
params![group_key, id],
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭子分组:首页卡片如果指着分组,挪回分区,避免标题/预览错位
|
|
||||||
if !allow_groups {
|
|
||||||
tx.execute(
|
|
||||||
"UPDATE section_block SET node_key = ?1, title = NULL
|
|
||||||
WHERE node_key IN (
|
|
||||||
SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1
|
|
||||||
)",
|
|
||||||
[id],
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.execute(
|
|
||||||
"UPDATE node SET title = ?1, icon = ?2, bar_color = ?3, view_mode = ?4,
|
|
||||||
preview_limit = ?5, empty_text = ?6, allow_groups = ?7
|
|
||||||
WHERE key = ?8",
|
|
||||||
params![
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
bar_color,
|
|
||||||
kind,
|
|
||||||
preview_limit,
|
|
||||||
empty_text,
|
|
||||||
allow_groups,
|
|
||||||
id
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
// 首页卡片预览哪个节点:分区自己或它下面的分组
|
|
||||||
if let Some(block_node_key) = patch.block_node_key {
|
|
||||||
let block_node_key = block_node_key.trim().to_string();
|
|
||||||
let valid: i64 = tx
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM node n LEFT JOIN node p ON p.key = n.parent_key
|
|
||||||
WHERE n.key = ?1 AND (n.key = ?2 OR p.key = ?2)",
|
|
||||||
params![block_node_key, id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
if valid == 0 {
|
|
||||||
return Err(StashError::InvalidDirectory(
|
|
||||||
"首页卡片只能预览本分区或它的分组".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let updated = tx
|
|
||||||
.execute(
|
|
||||||
"UPDATE section_block SET node_key = ?1, title = NULL
|
|
||||||
WHERE id IN (
|
|
||||||
SELECT b.id FROM section_block b
|
|
||||||
JOIN node n ON n.key = b.node_key
|
|
||||||
LEFT JOIN node p ON p.key = n.parent_key
|
|
||||||
WHERE COALESCE(p.key, n.key) = ?2
|
|
||||||
)",
|
|
||||||
params![block_node_key, id],
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
if updated == 0 {
|
|
||||||
let sort: i64 = tx
|
|
||||||
.query_row("SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block", [], |row| {
|
|
||||||
row.get(0)
|
|
||||||
})
|
|
||||||
.map_err(map_error)?;
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO section_block (node_key, title, preview_limit, sort)
|
|
||||||
VALUES (?1, NULL, NULL, ?2)",
|
|
||||||
params![block_node_key, sort],
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entries::audit(&tx, "category.update", id, &title)?;
|
|
||||||
tx.commit().map_err(map_error)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在应用类分区下新建分组(子分类)。
|
|
||||||
pub fn create_group(conn: &mut Connection, section_id: &str, new: NewGroup) -> Result<(), StashError> {
|
|
||||||
let id = new.id.trim();
|
|
||||||
let title = new.title.trim();
|
|
||||||
|
|
||||||
if !valid_id(id) {
|
|
||||||
return Err(StashError::InvalidDirectory(
|
|
||||||
"分组标识只能用小写字母、数字与连字符(≤32)".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if title.is_empty() || title.chars().count() > 40 {
|
|
||||||
return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let allow_groups: Option<bool> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT allow_groups FROM node WHERE key = ?1 AND kind = 'section'",
|
|
||||||
[section_id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.optional()
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
let Some(allow_groups) = allow_groups else {
|
|
||||||
return Err(StashError::NotFound(format!("分区不存在: {section_id}")));
|
|
||||||
};
|
|
||||||
if !allow_groups {
|
|
||||||
return Err(StashError::InvalidDirectory(
|
|
||||||
"该分区未开启「允许子分组」".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let exists: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| {
|
|
||||||
row.get(0)
|
|
||||||
})
|
|
||||||
.map_err(map_error)?;
|
|
||||||
if exists > 0 {
|
|
||||||
return Err(StashError::Conflict(format!("分组已存在: {id}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
let sort: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1",
|
|
||||||
[section_id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar)
|
|
||||||
VALUES (?1, ?2, ?3, 'group', NULL, ?4, 1)",
|
|
||||||
params![id, section_id, title, sort],
|
|
||||||
)
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
entries::audit(conn, "group.create", id, title)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 修改分组:改名 / 侧栏显隐。
|
|
||||||
pub fn update_group(conn: &Connection, id: &str, patch: UpdateGroup) -> Result<(), StashError> {
|
|
||||||
let current: Option<String> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT title FROM node WHERE key = ?1 AND kind = 'group'",
|
|
||||||
[id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.optional()
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
let Some(current) = current else {
|
|
||||||
return Err(StashError::NotFound(format!("分组不存在: {id}")));
|
|
||||||
};
|
|
||||||
|
|
||||||
let title = match patch.title {
|
|
||||||
Some(title) => {
|
|
||||||
let title = title.trim().to_string();
|
|
||||||
if title.is_empty() || title.chars().count() > 40 {
|
|
||||||
return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
|
|
||||||
}
|
|
||||||
title
|
|
||||||
}
|
|
||||||
None => current,
|
|
||||||
};
|
|
||||||
|
|
||||||
match patch.show_in_sidebar {
|
|
||||||
Some(show) => conn.execute(
|
|
||||||
"UPDATE node SET title = ?1, show_in_sidebar = ?2 WHERE key = ?3",
|
|
||||||
params![title, show, id],
|
|
||||||
),
|
|
||||||
None => conn.execute(
|
|
||||||
"UPDATE node SET title = ?1 WHERE key = ?2",
|
|
||||||
params![title, id],
|
|
||||||
),
|
|
||||||
}
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
entries::audit(conn, "group.update", id, &title)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除分组:组内条目会一起删除(前端需二次确认)。
|
|
||||||
pub fn remove_group(conn: &Connection, id: &str) -> Result<(), StashError> {
|
|
||||||
let removed = conn
|
|
||||||
.execute("DELETE FROM node WHERE key = ?1 AND kind = 'group'", [id])
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
if removed == 0 {
|
|
||||||
return Err(StashError::NotFound(format!("分组不存在: {id}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
entries::audit(conn, "group.delete", id, "")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(conn: &mut Connection, id: &str) -> Result<(), StashError> {
|
|
||||||
if id == "local" {
|
|
||||||
return Err(StashError::InvalidDirectory("本地服务不能删除".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let removed = conn
|
|
||||||
.execute("DELETE FROM node WHERE key = ?1 AND kind = 'section'", [id])
|
|
||||||
.map_err(map_error)?;
|
|
||||||
|
|
||||||
if removed == 0 {
|
|
||||||
return Err(StashError::NotFound(format!("分区不存在: {id}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
entries::audit(conn, "category.delete", id, "")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn db() -> Connection {
|
|
||||||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
|
||||||
conn.execute_batch(
|
|
||||||
"PRAGMA foreign_keys = ON;
|
|
||||||
CREATE TABLE node (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
key TEXT NOT NULL UNIQUE,
|
|
||||||
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
kind TEXT NOT NULL,
|
|
||||||
view_mode TEXT,
|
|
||||||
icon TEXT,
|
|
||||||
bar_color TEXT,
|
|
||||||
preview_limit INTEGER,
|
|
||||||
empty_text TEXT,
|
|
||||||
sort INTEGER NOT NULL DEFAULT 0,
|
|
||||||
show_in_sidebar INTEGER NOT NULL DEFAULT 1,
|
|
||||||
allow_groups INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE TABLE entry (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
node_key TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE section_block (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
title TEXT,
|
|
||||||
preview_limit INTEGER,
|
|
||||||
sort INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE TABLE audit (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
at INTEGER NOT NULL,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
target TEXT,
|
|
||||||
detail TEXT
|
|
||||||
);",
|
|
||||||
)
|
|
||||||
.expect("应能建表");
|
|
||||||
conn
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn create_then_delete() {
|
|
||||||
let mut conn = db();
|
|
||||||
create(
|
|
||||||
&mut conn,
|
|
||||||
NewCategory {
|
|
||||||
id: "books".into(),
|
|
||||||
title: "书单".into(),
|
|
||||||
kind: "posters".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("应能创建");
|
|
||||||
|
|
||||||
let blocks: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
|
|
||||||
.expect("应能查");
|
|
||||||
assert_eq!(blocks, 1);
|
|
||||||
|
|
||||||
remove(&mut conn, "books").expect("应能删除");
|
|
||||||
let nodes: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0))
|
|
||||||
.expect("应能查");
|
|
||||||
assert_eq!(nodes, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_bad_id_and_duplicate() {
|
|
||||||
let mut conn = db();
|
|
||||||
assert!(
|
|
||||||
create(
|
|
||||||
&mut conn,
|
|
||||||
NewCategory {
|
|
||||||
id: "Bad Id".into(),
|
|
||||||
title: "x".into(),
|
|
||||||
kind: "apps".into()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
|
|
||||||
create(
|
|
||||||
&mut conn,
|
|
||||||
NewCategory {
|
|
||||||
id: "books".into(),
|
|
||||||
title: "书单".into(),
|
|
||||||
kind: "apps".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("应能创建");
|
|
||||||
|
|
||||||
let error = create(
|
|
||||||
&mut conn,
|
|
||||||
NewCategory {
|
|
||||||
id: "books".into(),
|
|
||||||
title: "书单".into(),
|
|
||||||
kind: "apps".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect_err("重复应失败");
|
|
||||||
assert!(matches!(error, StashError::Conflict(_)));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn section(id: &str, title: &str, kind: &str) -> NewCategory {
|
|
||||||
NewCategory {
|
|
||||||
id: id.into(),
|
|
||||||
title: title.into(),
|
|
||||||
kind: kind.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn updates_fields_and_rejects_bad_values() {
|
|
||||||
let mut conn = db();
|
|
||||||
create(&mut conn, section("books", "书单", "posters")).expect("应能创建");
|
|
||||||
|
|
||||||
update(
|
|
||||||
&mut conn,
|
|
||||||
"books",
|
|
||||||
UpdateCategory {
|
|
||||||
title: Some("读过的书".into()),
|
|
||||||
icon: Some("📚".into()),
|
|
||||||
bar_color: Some("#ff6034".into()),
|
|
||||||
preview_limit: Some(8),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("应能更新");
|
|
||||||
|
|
||||||
let (title, icon, color, limit): (String, Option<String>, Option<String>, Option<i64>) =
|
|
||||||
conn.query_row(
|
|
||||||
"SELECT title, icon, bar_color, preview_limit FROM node WHERE key='books'",
|
|
||||||
[],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
|
||||||
)
|
|
||||||
.expect("应能查");
|
|
||||||
assert_eq!(title, "读过的书");
|
|
||||||
assert_eq!(icon.as_deref(), Some("📚"));
|
|
||||||
assert_eq!(color.as_deref(), Some("#ff6034"));
|
|
||||||
assert_eq!(limit, Some(8));
|
|
||||||
|
|
||||||
assert!(update(&mut conn, "books", UpdateCategory { bar_color: Some("red".into()), ..Default::default() }).is_err());
|
|
||||||
assert!(update(&mut conn, "books", UpdateCategory { kind: Some("videos".into()), ..Default::default() }).is_err());
|
|
||||||
assert!(update(&mut conn, "local", UpdateCategory { title: Some("x".into()), ..Default::default() }).is_err());
|
|
||||||
assert!(matches!(
|
|
||||||
update(&mut conn, "none", UpdateCategory::default()).expect_err("应不存在"),
|
|
||||||
StashError::NotFound(_)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn switching_apps_to_posters_keeps_groups_until_disabled() {
|
|
||||||
let mut conn = db();
|
|
||||||
create(&mut conn, section("books", "书单", "apps")).expect("应能创建");
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO node (key, parent_key, title, kind, sort) VALUES ('books-g', 'books', '分组', 'group', 1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.expect("应能建分组");
|
|
||||||
conn.execute("INSERT INTO entry (key, node_key) VALUES ('b1', 'books-g')", [])
|
|
||||||
.expect("应能建条目");
|
|
||||||
// create() 会顺手建一张分区卡片;这里只保留分组卡片,模拟真实配置
|
|
||||||
conn.execute("DELETE FROM section_block", []).expect("应能清卡片");
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO section_block (node_key, title, sort) VALUES ('books-g', '分组', 1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.expect("应能建卡片");
|
|
||||||
|
|
||||||
// 类型切到海报但子分组仍开启:分组和卡片都保留
|
|
||||||
update(
|
|
||||||
&mut conn,
|
|
||||||
"books",
|
|
||||||
UpdateCategory {
|
|
||||||
kind: Some("posters".into()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("应能改类型");
|
|
||||||
let block: String = conn
|
|
||||||
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
|
|
||||||
.expect("应能查");
|
|
||||||
assert_eq!(block, "books-g");
|
|
||||||
|
|
||||||
// 关闭子分组:卡片挪回分区
|
|
||||||
update(
|
|
||||||
&mut conn,
|
|
||||||
"books",
|
|
||||||
UpdateCategory {
|
|
||||||
allow_groups: Some(false),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("应能关闭");
|
|
||||||
let block: String = conn
|
|
||||||
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
|
|
||||||
.expect("应能查");
|
|
||||||
assert_eq!(block, "books", "关闭子分组后卡片应指回分区");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn creates_updates_and_removes_group() {
|
|
||||||
let mut conn = db();
|
|
||||||
create(&mut conn, section("apps1", "应用", "apps")).expect("应能创建");
|
|
||||||
|
|
||||||
create_group(
|
|
||||||
&mut conn,
|
|
||||||
"apps1",
|
|
||||||
NewGroup {
|
|
||||||
id: "g1".into(),
|
|
||||||
title: "常用".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("应能建分组");
|
|
||||||
|
|
||||||
update_group(
|
|
||||||
&conn,
|
|
||||||
"g1",
|
|
||||||
UpdateGroup {
|
|
||||||
title: Some("改名".into()),
|
|
||||||
show_in_sidebar: Some(false),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("应能改分组");
|
|
||||||
|
|
||||||
let (title, show): (String, i64) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT title, show_in_sidebar FROM node WHERE key='g1'",
|
|
||||||
[],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
||||||
)
|
|
||||||
.expect("应能查");
|
|
||||||
assert_eq!(title, "改名");
|
|
||||||
assert_eq!(show, 0);
|
|
||||||
|
|
||||||
remove_group(&conn, "g1").expect("应能删分组");
|
|
||||||
let left: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM node WHERE key='g1'", [], |row| {
|
|
||||||
row.get(0)
|
|
||||||
})
|
|
||||||
.expect("应能查");
|
|
||||||
assert_eq!(left, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn groups_only_under_apps_sections() {
|
|
||||||
let mut conn = db();
|
|
||||||
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
|
|
||||||
assert!(
|
|
||||||
create_group(
|
|
||||||
&mut conn,
|
|
||||||
"films",
|
|
||||||
NewGroup {
|
|
||||||
id: "g1".into(),
|
|
||||||
title: "片单".into()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.is_err(),
|
|
||||||
"未开启子分组的分区不能建组"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 打开「允许子分组」后,海报分区也可以建组
|
|
||||||
update(
|
|
||||||
&mut conn,
|
|
||||||
"films",
|
|
||||||
UpdateCategory {
|
|
||||||
allow_groups: Some(true),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("应能开启");
|
|
||||||
create_group(
|
|
||||||
&mut conn,
|
|
||||||
"films",
|
|
||||||
NewGroup {
|
|
||||||
id: "g1".into(),
|
|
||||||
title: "片单".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("开启后应能建组");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn switching_posters_to_apps_creates_group_and_moves_entries() { let mut conn = db();
|
|
||||||
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
|
|
||||||
conn.execute("INSERT INTO entry (key, node_key) VALUES ('f1', 'films')", [])
|
|
||||||
.expect("应能建条目");
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO section_block (node_key, title, sort) VALUES ('films', '电影', 1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.expect("应能建卡片");
|
|
||||||
|
|
||||||
update(
|
|
||||||
&mut conn,
|
|
||||||
"films",
|
|
||||||
UpdateCategory {
|
|
||||||
kind: Some("apps".into()),
|
|
||||||
allow_groups: Some(true),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("应能改类型");
|
|
||||||
|
|
||||||
let (entry_node, block_node): (String, String) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT (SELECT node_key FROM entry WHERE key='f1'),
|
|
||||||
(SELECT node_key FROM section_block)",
|
|
||||||
[],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
||||||
)
|
|
||||||
.expect("应能查");
|
|
||||||
assert_eq!(entry_node, "films-all", "条目应挂到默认分组");
|
|
||||||
assert_eq!(block_node, "films-all", "首页卡片应指向默认分组");
|
|
||||||
let groups: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM node WHERE kind='group' AND parent_key='films'",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.expect("应能查");
|
|
||||||
assert_eq!(groups, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+16
-52
@@ -1,16 +1,14 @@
|
|||||||
//! 分组树的管理:多层分组、格式、父级移动与排序。
|
//! 分组树的管理:多层分组、父级移动与排序。
|
||||||
//!
|
//!
|
||||||
//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`,带
|
//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`),
|
||||||
//! `view_mode` 格式),下面的节点都叫分组(`kind='group'`),可以继续嵌套。
|
//! 下面的节点都叫分组(`kind='group'`),可以继续嵌套。
|
||||||
//! 条目挂在任意节点上,格式由最近的根分组决定。
|
//! 条目的展示格式由 `entry.format` 决定,分组不再保存格式。
|
||||||
|
|
||||||
use rusqlite::{Connection, OptionalExtension, params};
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::{db::entries, db::map_error, error::StashError};
|
use crate::{db::entries, db::map_error, error::StashError};
|
||||||
|
|
||||||
const FORMATS: [&str; 3] = ["apps", "posters", "articles"];
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct NewNode {
|
pub struct NewNode {
|
||||||
@@ -20,9 +18,6 @@ pub struct NewNode {
|
|||||||
/// 为空表示新建一个根分组
|
/// 为空表示新建一个根分组
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub parent_key: Option<String>,
|
pub parent_key: Option<String>,
|
||||||
/// 根分组的格式:apps / posters / articles(子分组继承,不需要传)
|
|
||||||
#[serde(default)]
|
|
||||||
pub format: Option<String>,
|
|
||||||
/// 图标:可以是 iconfont 名或字符;空字符串表示不显示
|
/// 图标:可以是 iconfont 名或字符;空字符串表示不显示
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub icon: Option<String>,
|
pub icon: Option<String>,
|
||||||
@@ -41,8 +36,6 @@ pub struct UpdateNode {
|
|||||||
/// 传入即移动到新的父节点;不支持移回根(根分组请直接新建)
|
/// 传入即移动到新的父节点;不支持移回根(根分组请直接新建)
|
||||||
pub parent_key: Option<String>,
|
pub parent_key: Option<String>,
|
||||||
pub sort: Option<i64>,
|
pub sort: Option<i64>,
|
||||||
/// 只对根分组生效
|
|
||||||
pub format: Option<String>,
|
|
||||||
/// 图标:空字符串表示清除图标
|
/// 图标:空字符串表示清除图标
|
||||||
pub icon: Option<String>,
|
pub icon: Option<String>,
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
@@ -78,7 +71,7 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
|
|||||||
return Err(StashError::Conflict(format!("分组已存在: {key}")));
|
return Err(StashError::Conflict(format!("分组已存在: {key}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (kind, view_mode) = match new.parent_key.as_deref() {
|
let kind = match new.parent_key.as_deref() {
|
||||||
Some(parent) => {
|
Some(parent) => {
|
||||||
let parent_exists: i64 = conn
|
let parent_exists: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [parent], |row| {
|
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [parent], |row| {
|
||||||
@@ -88,15 +81,9 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
|
|||||||
if parent_exists == 0 {
|
if parent_exists == 0 {
|
||||||
return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
|
return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
|
||||||
}
|
}
|
||||||
("group", None)
|
"group"
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let format = new.format.unwrap_or_else(|| "apps".into());
|
|
||||||
if !FORMATS.contains(&format.as_str()) {
|
|
||||||
return Err(StashError::InvalidDirectory("格式不合法".into()));
|
|
||||||
}
|
|
||||||
("section", Some(format))
|
|
||||||
}
|
}
|
||||||
|
None => "section",
|
||||||
};
|
};
|
||||||
|
|
||||||
let default_sort: i64 = match new.parent_key.as_deref() {
|
let default_sort: i64 = match new.parent_key.as_deref() {
|
||||||
@@ -126,14 +113,13 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
|
|||||||
|
|
||||||
let tx = conn.transaction().map_err(map_error)?;
|
let tx = conn.transaction().map_err(map_error)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, icon, enabled, sort, show_in_sidebar, allow_groups)
|
"INSERT INTO node (key, parent_key, title, kind, icon, enabled, sort)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, 1)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
params![
|
params![
|
||||||
key,
|
key,
|
||||||
new.parent_key,
|
new.parent_key,
|
||||||
title,
|
title,
|
||||||
kind,
|
kind,
|
||||||
view_mode,
|
|
||||||
icon,
|
icon,
|
||||||
enabled,
|
enabled,
|
||||||
sort
|
sort
|
||||||
@@ -146,9 +132,9 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> {
|
pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> {
|
||||||
let current: Option<(String, Option<String>, Option<String>, i64, Option<String>, bool)> = conn
|
let current: Option<(String, Option<String>, i64, Option<String>, bool)> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT title, parent_key, view_mode, sort, icon, enabled FROM node WHERE key = ?1",
|
"SELECT title, parent_key, sort, icon, enabled FROM node WHERE key = ?1",
|
||||||
[key],
|
[key],
|
||||||
|row| {
|
|row| {
|
||||||
Ok((
|
Ok((
|
||||||
@@ -157,15 +143,13 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
|
|||||||
row.get(2)?,
|
row.get(2)?,
|
||||||
row.get(3)?,
|
row.get(3)?,
|
||||||
row.get(4)?,
|
row.get(4)?,
|
||||||
row.get(5)?,
|
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
|
|
||||||
let Some((current_title, current_parent, current_format, current_sort, current_icon, current_enabled)) =
|
let Some((current_title, current_parent, current_sort, current_icon, current_enabled)) = current
|
||||||
current
|
|
||||||
else {
|
else {
|
||||||
return Err(StashError::NotFound(format!("分组不存在: {key}")));
|
return Err(StashError::NotFound(format!("分组不存在: {key}")));
|
||||||
};
|
};
|
||||||
@@ -218,26 +202,6 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
|
|||||||
None => current_parent.clone(),
|
None => current_parent.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_root = parent_key.is_none();
|
|
||||||
let format = if is_root {
|
|
||||||
match patch.format {
|
|
||||||
Some(format) => {
|
|
||||||
if !FORMATS.contains(&format.as_str()) {
|
|
||||||
return Err(StashError::InvalidDirectory("格式不合法".into()));
|
|
||||||
}
|
|
||||||
Some(format)
|
|
||||||
}
|
|
||||||
None => current_format,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 子分组没有自己的格式,跟随根分组
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if is_root && format.is_none() {
|
|
||||||
return Err(StashError::InvalidDirectory("根分组缺少格式".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let icon = match patch.icon {
|
let icon = match patch.icon {
|
||||||
Some(icon) => {
|
Some(icon) => {
|
||||||
let icon = icon.trim().to_string();
|
let icon = icon.trim().to_string();
|
||||||
@@ -268,10 +232,10 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
|
|||||||
};
|
};
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4,
|
"UPDATE node SET title = ?1, parent_key = ?2, sort = ?3,
|
||||||
icon = ?5, enabled = ?6
|
icon = ?4, enabled = ?5
|
||||||
WHERE key = ?7",
|
WHERE key = ?6",
|
||||||
params![title, parent_key, format, final_sort, icon, enabled, key],
|
params![title, parent_key, final_sort, icon, enabled, key],
|
||||||
)
|
)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
entries::audit(&tx, "node.update", key, &title)?;
|
entries::audit(&tx, "node.update", key, &title)?;
|
||||||
|
|||||||
+93
-127
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cards;
|
pub mod cards;
|
||||||
pub mod categories;
|
|
||||||
mod catalog;
|
mod catalog;
|
||||||
pub mod entries;
|
pub mod entries;
|
||||||
pub mod groups;
|
pub mod groups;
|
||||||
@@ -34,15 +33,6 @@ pub use settings::{Settings, SettingsDocument};
|
|||||||
/// 条目编辑(P2)。
|
/// 条目编辑(P2)。
|
||||||
pub use entries::{EntryPatch, EntryRecord};
|
pub use entries::{EntryPatch, EntryRecord};
|
||||||
|
|
||||||
/// 一次查询得到的完整内容目录。
|
|
||||||
///
|
|
||||||
/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Catalog {
|
|
||||||
/// 分类树,结构对应前端 `types.ts` 的 `Category[]`
|
|
||||||
pub categories: Vec<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 内容库句柄。
|
/// 内容库句柄。
|
||||||
///
|
///
|
||||||
/// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来:
|
/// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来:
|
||||||
@@ -107,12 +97,6 @@ impl Db {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读出完整的内容目录,供 `/api/catalog` 直接序列化。
|
|
||||||
pub fn load_catalog(&self) -> Result<Catalog, StashError> {
|
|
||||||
let conn = self.lock()?;
|
|
||||||
catalog::load(&conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。
|
/// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。
|
||||||
///
|
///
|
||||||
/// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。
|
/// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。
|
||||||
@@ -217,41 +201,6 @@ impl Db {
|
|||||||
entries::set_like(&conn, key, liked)
|
entries::set_like(&conn, key, liked)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 分区(首页卡片 / 侧栏项) ----------
|
|
||||||
|
|
||||||
pub fn create_category(&self, new: categories::NewCategory) -> Result<(), StashError> {
|
|
||||||
let mut conn = self.lock()?;
|
|
||||||
categories::create(&mut conn, new)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_category(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
patch: categories::UpdateCategory,
|
|
||||||
) -> Result<(), StashError> {
|
|
||||||
let mut conn = self.lock()?;
|
|
||||||
categories::update(&mut conn, id, patch)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_group(
|
|
||||||
&self,
|
|
||||||
section_id: &str,
|
|
||||||
new: categories::NewGroup,
|
|
||||||
) -> Result<(), StashError> {
|
|
||||||
let mut conn = self.lock()?;
|
|
||||||
categories::create_group(&mut conn, section_id, new)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_group(&self, id: &str, patch: categories::UpdateGroup) -> Result<(), StashError> {
|
|
||||||
let conn = self.lock()?;
|
|
||||||
categories::update_group(&conn, id, patch)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_group(&self, id: &str) -> Result<(), StashError> {
|
|
||||||
let conn = self.lock()?;
|
|
||||||
categories::remove_group(&conn, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 原子模型:分组树 / 卡片 ----------
|
// ---------- 原子模型:分组树 / 卡片 ----------
|
||||||
|
|
||||||
/// 完整分组树(多层,带条目)
|
/// 完整分组树(多层,带条目)
|
||||||
@@ -303,11 +252,6 @@ impl Db {
|
|||||||
let mut conn = self.lock()?;
|
let mut conn = self.lock()?;
|
||||||
cards::set_groups(&mut conn, id, groups)
|
cards::set_groups(&mut conn, id, groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_category(&self, id: &str) -> Result<(), StashError> {
|
|
||||||
let mut conn = self.lock()?;
|
|
||||||
categories::remove(&mut conn, id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
|
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
|
||||||
@@ -363,8 +307,8 @@ mod tests {
|
|||||||
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
|
||||||
.expect("应能统计条目");
|
.expect("应能统计条目");
|
||||||
let blocks: i64 = conn
|
let blocks: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM card", [], |row| row.get(0))
|
||||||
.expect("应能统计展示块");
|
.expect("应能统计首页卡片");
|
||||||
let tags: i64 = conn
|
let tags: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0))
|
||||||
.expect("应能统计标签");
|
.expect("应能统计标签");
|
||||||
@@ -383,27 +327,26 @@ mod tests {
|
|||||||
assert_eq!(tags, 48, "标签与元信息共用 entry_tag");
|
assert_eq!(tags, 48, "标签与元信息共用 entry_tag");
|
||||||
// 正文是一篇一段 Markdown,只有 12 篇文章有
|
// 正文是一篇一段 Markdown,只有 12 篇文章有
|
||||||
assert_eq!(articles_with_body, 12, "只有图文条目带正文");
|
assert_eq!(articles_with_body, 12, "只有图文条目带正文");
|
||||||
assert_eq!(blocks, 3, "每个分区一个首页展示块");
|
assert_eq!(blocks, 3, "每个分区一张首页卡片");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 分区与分组是同一张表:分组必须挂在分区下,且不能有 view_mode。
|
/// 分区与分组是同一张表:分组必须挂在分区下。
|
||||||
#[test]
|
#[test]
|
||||||
fn groups_are_nodes_under_their_section() {
|
fn groups_are_nodes_under_their_section() {
|
||||||
let conn = seeded();
|
let conn = seeded();
|
||||||
|
|
||||||
let (parent_key, view_mode): (String, Option<String>) = conn
|
let parent_key: String = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT p.key, g.view_mode
|
"SELECT p.key
|
||||||
FROM node g
|
FROM node g
|
||||||
JOIN node p ON p.key = g.parent_key
|
JOIN node p ON p.key = g.parent_key
|
||||||
WHERE g.kind = 'group' AND g.key = 'ui'",
|
WHERE g.kind = 'group' AND g.key = 'ui'",
|
||||||
[],
|
[],
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.expect("应能查到分组及其父节点");
|
.expect("应能查到分组及其父节点");
|
||||||
|
|
||||||
assert_eq!(parent_key, "recommend");
|
assert_eq!(parent_key, "recommend");
|
||||||
assert!(view_mode.is_none(), "分组不应带 view_mode");
|
|
||||||
|
|
||||||
// 分区一定是顶层节点
|
// 分区一定是顶层节点
|
||||||
let root_sections: i64 = conn
|
let root_sections: i64 = conn
|
||||||
@@ -419,92 +362,102 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn catalog_matches_the_frontend_model() {
|
fn catalog_matches_the_frontend_model() {
|
||||||
let conn = seeded();
|
let conn = seeded();
|
||||||
let catalog = catalog::load(&conn).expect("应能组装内容目录");
|
let tree = catalog::load_tree(&conn).expect("应能组装分组树");
|
||||||
let categories = &catalog.categories;
|
let roots = tree.as_array().expect("根节点应为数组");
|
||||||
|
assert_eq!(roots.len(), 3);
|
||||||
|
|
||||||
assert_eq!(categories.len(), 3);
|
let apps = roots
|
||||||
|
.iter()
|
||||||
|
.find(|node| node["key"] == "recommend")
|
||||||
|
.expect("应有常用推荐");
|
||||||
|
let groups = apps["children"].as_array().expect("应有子分组");
|
||||||
|
assert_eq!(groups.len(), 5);
|
||||||
|
|
||||||
let apps = &categories[0];
|
let common = groups
|
||||||
assert_eq!(apps["id"], "recommend");
|
.iter()
|
||||||
assert_eq!(apps["kind"], "apps");
|
.find(|node| node["key"] == "common")
|
||||||
assert_eq!(apps["barColor"], "#3858f6");
|
.expect("应有常用站点");
|
||||||
assert_eq!(apps["showInSidebar"], true);
|
let first_app = &common["entries"][0];
|
||||||
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["name"], "GitHub");
|
||||||
|
assert_eq!(first_app["format"], "apps");
|
||||||
assert_eq!(first_app["hot"], true);
|
assert_eq!(first_app["hot"], true);
|
||||||
// 计数从 JSON 提到了独立列,这里确认读取时又合并回来了
|
|
||||||
assert_eq!(first_app["views"], 429);
|
assert_eq!(first_app["views"], 429);
|
||||||
|
|
||||||
let posters = &categories[1];
|
let posters = roots
|
||||||
assert_eq!(posters["kind"], "posters");
|
.iter()
|
||||||
// 海报与图文用 title 而不是 name
|
.find(|node| node["key"] == "movie")
|
||||||
assert_eq!(
|
.expect("应有推荐电影");
|
||||||
posters["items"][0].get("name"),
|
let first_poster = &posters["entries"][0];
|
||||||
None,
|
assert_eq!(first_poster["format"], "posters");
|
||||||
|
assert!(
|
||||||
|
first_poster.get("name").is_none(),
|
||||||
"海报不应同时下发 name 字段"
|
"海报不应同时下发 name 字段"
|
||||||
);
|
);
|
||||||
assert!(posters["items"][0]["title"].is_string());
|
assert!(first_poster["title"].is_string());
|
||||||
// tags 来自 entry_tag 子表
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
posters["items"][0]["tags"]
|
first_poster["tags"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("海报应有标签")
|
.expect("海报应有标签")
|
||||||
.len(),
|
.len(),
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
|
|
||||||
let articles = &categories[2];
|
let articles = roots
|
||||||
assert_eq!(articles["kind"], "articles");
|
.iter()
|
||||||
// 正文是一整段 Markdown,而不是块数组
|
.find(|node| node["key"] == "ces")
|
||||||
let body = articles["items"][0]["body"]
|
.expect("应有 CES 现场报道");
|
||||||
.as_str()
|
let first_article = &articles["entries"][0];
|
||||||
.expect("图文应有正文");
|
assert_eq!(first_article["format"], "articles");
|
||||||
|
let body = first_article["body"].as_str().expect("图文应有正文");
|
||||||
assert!(body.contains("## 设计细节"), "正文应含小标题: {body}");
|
assert!(body.contains("## 设计细节"), "正文应含小标题: {body}");
|
||||||
assert!(body.contains("!["), "正文应含配图语法: {body}");
|
assert!(body.contains("!["), "正文应含配图语法: {body}");
|
||||||
assert!(body.contains("\n\n"), "段落之间用空行分隔");
|
assert!(body.contains("\n\n"), "段落之间用空行分隔");
|
||||||
assert!(
|
assert!(
|
||||||
articles["items"][0].get("blocks").is_none(),
|
first_article.get("blocks").is_none(),
|
||||||
"不应再下发 blocks 数组"
|
"不应再下发 blocks 数组"
|
||||||
);
|
);
|
||||||
// meta 与海报的 tags 共用 entry_tag 表
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
articles["items"][0]["meta"]
|
first_article["meta"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("图文应有元信息")
|
.expect("图文应有元信息")
|
||||||
.len(),
|
.len(),
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- 首页展示块 ----
|
// 卡片只记录勾选的分组;常用推荐卡片展示「常用站点」,最多 4 条
|
||||||
// 常用推荐卡片展示「常用站点」分组,最多 4 条
|
let (group_key, preview_limit): (String, Option<i64>) = conn
|
||||||
assert_eq!(apps["block"]["nodeKey"], "common");
|
.query_row(
|
||||||
assert_eq!(apps["block"]["previewLimit"], 4);
|
"SELECT cg.group_key, cg.preview_limit
|
||||||
// 电影/文章没有分组,块直接指向分区自己
|
FROM card c JOIN card_group cg ON cg.card_id = c.id
|
||||||
assert_eq!(posters["block"]["nodeKey"], "movie");
|
WHERE c.key = 'card-recommend'",
|
||||||
assert_eq!(articles["block"]["nodeKey"], "ces");
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.expect("应能查到常用推荐卡片");
|
||||||
|
assert_eq!(group_key, "common");
|
||||||
|
assert_eq!(preview_limit, Some(4));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除分区时,它的展示块一起消失(外键级联),不会留下指向空分区的卡片。
|
/// 删除分组时,卡片里勾选它的记录一起消失(外键级联)。
|
||||||
#[test]
|
#[test]
|
||||||
fn section_blocks_cascade_with_their_section() {
|
fn card_groups_cascade_with_their_group() {
|
||||||
let conn = seeded();
|
let conn = seeded();
|
||||||
conn.execute("DELETE FROM node WHERE key = 'recommend' AND kind = 'section'", [])
|
|
||||||
.expect("应能删除分区");
|
conn.execute(
|
||||||
|
"DELETE FROM node WHERE kind = 'group' AND key = 'common' AND parent_key = 'recommend'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("应能删除分组");
|
||||||
|
|
||||||
let remaining: i64 = conn
|
let remaining: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
|
.query_row(
|
||||||
.expect("应能统计展示块");
|
"SELECT COUNT(*) FROM card_group WHERE group_key = 'common'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("应能统计卡片分组");
|
||||||
|
|
||||||
assert_eq!(remaining, 2, "三个展示块应随分区删除只剩两个");
|
assert_eq!(remaining, 0, "卡片里对 common 的勾选应被级联删除");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除分组节点时,组内条目也要一起删掉。
|
/// 删除分组节点时,组内条目也要一起删掉。
|
||||||
@@ -534,7 +487,7 @@ mod tests {
|
|||||||
fn primary_key_is_id_and_key_is_unique() {
|
fn primary_key_is_id_and_key_is_unique() {
|
||||||
let conn = seeded();
|
let conn = seeded();
|
||||||
|
|
||||||
for table in ["node", "entry", "section_block"] {
|
for table in ["node", "entry", "card"] {
|
||||||
let pk: String = conn
|
let pk: String = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
&format!("SELECT name FROM pragma_table_info('{table}') WHERE pk = 1"),
|
&format!("SELECT name FROM pragma_table_info('{table}') WHERE pk = 1"),
|
||||||
@@ -576,21 +529,22 @@ mod tests {
|
|||||||
conn.execute("UPDATE node SET key = 'recommend-v2' WHERE key = 'recommend'", [])
|
conn.execute("UPDATE node SET key = 'recommend-v2' WHERE key = 'recommend'", [])
|
||||||
.expect("应能改分区业务键");
|
.expect("应能改分区业务键");
|
||||||
|
|
||||||
let catalog = catalog::load(&conn).expect("应能组装内容目录");
|
let tree = catalog::load_tree(&conn).expect("应能组装分组树");
|
||||||
let apps = catalog
|
let roots = tree.as_array().expect("根节点应为数组");
|
||||||
.categories
|
let apps = roots
|
||||||
.iter()
|
.iter()
|
||||||
.find(|category| category["id"] == "recommend-v2")
|
.find(|node| node["key"] == "recommend-v2")
|
||||||
.expect("应能找到改名后的分区");
|
.expect("应能找到改名后的分区");
|
||||||
|
|
||||||
let total: usize = apps["groups"]
|
fn count(node: &serde_json::Value) -> usize {
|
||||||
|
let own = node["entries"].as_array().map_or(0, |items| items.len());
|
||||||
|
let children = node["children"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("应有分组")
|
.map_or(0, |items| items.iter().map(count).sum());
|
||||||
.iter()
|
own + children
|
||||||
.map(|group| group["items"].as_array().expect("应有条目").len())
|
}
|
||||||
.sum();
|
|
||||||
|
|
||||||
assert_eq!(total, 18, "改名后条目仍应归在这个分区下");
|
assert_eq!(count(apps), 18, "改名后条目仍应归在这个分区下");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。
|
/// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。
|
||||||
@@ -654,7 +608,19 @@ mod tests {
|
|||||||
let fresh = Connection::open_in_memory().expect("内存库应能打开");
|
let fresh = Connection::open_in_memory().expect("内存库应能打开");
|
||||||
fresh
|
fresh
|
||||||
.execute_batch(
|
.execute_batch(
|
||||||
"CREATE TABLE node (key TEXT PRIMARY KEY, parent_key TEXT, title TEXT, kind TEXT, view_mode TEXT);
|
"CREATE TABLE node (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
parent_key TEXT,
|
||||||
|
title TEXT,
|
||||||
|
kind TEXT,
|
||||||
|
view_mode TEXT,
|
||||||
|
bar_color TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
preview_limit INTEGER,
|
||||||
|
empty_text TEXT,
|
||||||
|
show_in_sidebar INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL);
|
CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL);
|
||||||
CREATE TABLE section_block (id INTEGER PRIMARY KEY, node_key TEXT NOT NULL, title TEXT, preview_limit INTEGER, sort INTEGER NOT NULL DEFAULT 0);
|
CREATE TABLE section_block (id INTEGER PRIMARY KEY, node_key TEXT NOT NULL, title TEXT, preview_limit INTEGER, sort INTEGER NOT NULL DEFAULT 0);
|
||||||
PRAGMA user_version = 15;",
|
PRAGMA user_version = 15;",
|
||||||
|
|||||||
+67
-64
@@ -27,9 +27,8 @@ const SEED_JSON: &str = include_str!("seed.json");
|
|||||||
/// 种子会自动重新灌一份干净数据。
|
/// 种子会自动重新灌一份干净数据。
|
||||||
const SCHEMA_SQL: &str = r#"
|
const SCHEMA_SQL: &str = r#"
|
||||||
-- 内容节点:分区与分组共用一张自引用树
|
-- 内容节点:分区与分组共用一张自引用树
|
||||||
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_code 必须为 NULL
|
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_key 必须为 NULL
|
||||||
-- kind = 'group' → 分区下的分组,parent_code 指向所属分区
|
-- kind = 'group' → 分区下的分组,parent_key 指向分区或上层分组
|
||||||
-- view_mode 只对分区有意义,决定首页与明细页怎么渲染
|
|
||||||
CREATE TABLE IF NOT EXISTS node (
|
CREATE TABLE IF NOT EXISTS node (
|
||||||
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT)
|
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT)
|
||||||
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui)
|
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui)
|
||||||
@@ -38,22 +37,15 @@ CREATE TABLE IF NOT EXISTS node (
|
|||||||
title TEXT NOT NULL, -- 展示名:分区标题 / 分组小标题
|
title TEXT NOT NULL, -- 展示名:分区标题 / 分组小标题
|
||||||
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
|
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
|
||||||
-- 容器层级
|
-- 容器层级
|
||||||
view_mode TEXT CHECK (view_mode IN ('apps', 'posters', 'articles')),
|
bar_color TEXT, -- 标题左侧色条颜色
|
||||||
-- 分区渲染形态;分组为 NULL
|
icon TEXT, -- 字符图标
|
||||||
bar_color TEXT, -- 分区标题左侧色条颜色(分组为 NULL)
|
|
||||||
icon TEXT, -- 分区 hero 字符图标(分组为 NULL)
|
|
||||||
preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认
|
preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认
|
||||||
empty_text TEXT, -- 分区为空时的提示文案
|
|
||||||
show_in_sidebar INTEGER NOT NULL DEFAULT 1 CHECK (show_in_sidebar IN (0, 1)),
|
|
||||||
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
|
|
||||||
allow_groups INTEGER NOT NULL DEFAULT 0 CHECK (allow_groups IN (0, 1)),
|
|
||||||
-- 是否允许子分组(与类型无关,后台开关)
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||||
-- 是否启用(禁用后前台侧栏/卡片不展示)
|
-- 是否启用(禁用后前台侧栏/卡片不展示)
|
||||||
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
|
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
|
||||||
CHECK (
|
CHECK (
|
||||||
(kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL)
|
(kind = 'section' AND parent_key IS NULL)
|
||||||
OR (kind = 'group' AND parent_key IS NOT NULL AND view_mode IS NULL)
|
OR (kind = 'group' AND parent_key IS NOT NULL)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,18 +87,6 @@ CREATE TABLE IF NOT EXISTS entry_tag (
|
|||||||
PRIMARY KEY (entry_key, sort)
|
PRIMARY KEY (entry_key, sort)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- 首页卡片:一行一个卡片,说明展示哪个节点
|
|
||||||
-- node_key 指向要展示的节点(通常是分组;电影/文章这类无分组的填分区自己)
|
|
||||||
-- 「属于哪个分区」不单独存:分组看 parent_key,分区就是它自己,查询时推导
|
|
||||||
CREATE TABLE IF NOT EXISTS section_block (
|
|
||||||
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
|
|
||||||
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
-- 要展示的节点业务键
|
|
||||||
title TEXT, -- 块标题;NULL 时用节点 title
|
|
||||||
preview_limit INTEGER, -- 卡片里显示几条;NULL 时用前端默认
|
|
||||||
sort INTEGER NOT NULL DEFAULT 0 -- 卡片顺序
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ========== v16:登录与设置 ==========
|
-- ========== v16:登录与设置 ==========
|
||||||
|
|
||||||
-- 管理员:单用户场景只允许一行
|
-- 管理员:单用户场景只允许一行
|
||||||
@@ -226,6 +206,41 @@ const V22_SQL: &str = r#"
|
|||||||
INSERT OR IGNORE INTO card (key, title, sort) VALUES ('local-service', '本地服务', -1);
|
INSERT OR IGNORE INTO card (key, title, sort) VALUES ('local-service', '本地服务', -1);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// v22 → v23:删除旧分区展示块与 node 上的兼容字段,node 只保留当前模型需要的列。
|
||||||
|
const V23_SQL: &str = r#"
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS section_block;
|
||||||
|
|
||||||
|
CREATE TABLE node_new (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
|
||||||
|
bar_color TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
preview_limit INTEGER,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||||
|
sort INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CHECK (
|
||||||
|
(kind = 'section' AND parent_key IS NULL)
|
||||||
|
OR (kind = 'group' AND parent_key IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO node_new (key, parent_key, title, kind, bar_color, icon, preview_limit, enabled, sort)
|
||||||
|
SELECT key, parent_key, title, kind, bar_color, icon, preview_limit, enabled, sort
|
||||||
|
FROM node;
|
||||||
|
|
||||||
|
DROP TABLE node;
|
||||||
|
ALTER TABLE node_new RENAME TO node;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS node_parent_idx ON node(parent_key, sort);
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
"#;
|
||||||
|
|
||||||
/// v20 → v21:格式从分组下沉到条目;老数据按所属根分组的 view_mode 回填。
|
/// v20 → v21:格式从分组下沉到条目;老数据按所属根分组的 view_mode 回填。
|
||||||
const V21_SQL: &str = r#"
|
const V21_SQL: &str = r#"
|
||||||
ALTER TABLE entry ADD COLUMN format TEXT NOT NULL DEFAULT 'apps';
|
ALTER TABLE entry ADD COLUMN format TEXT NOT NULL DEFAULT 'apps';
|
||||||
@@ -302,7 +317,7 @@ pub struct SeedReport {
|
|||||||
///
|
///
|
||||||
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
||||||
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
||||||
pub const SCHEMA_VERSION: i64 = 22;
|
pub const SCHEMA_VERSION: i64 = 23;
|
||||||
|
|
||||||
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
||||||
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||||
@@ -372,6 +387,11 @@ pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
|||||||
version = 22;
|
version = 22;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if version == 22 {
|
||||||
|
conn.execute_batch(V23_SQL).map_err(map_error)?;
|
||||||
|
version = 23;
|
||||||
|
}
|
||||||
|
|
||||||
conn.pragma_update(None, "user_version", version)
|
conn.pragma_update(None, "user_version", version)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
|
|
||||||
@@ -416,18 +436,14 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups, sort)
|
"INSERT INTO node (key, parent_key, title, kind, bar_color, icon, preview_limit, sort)
|
||||||
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6)",
|
||||||
params![
|
params![
|
||||||
section.key,
|
section.key,
|
||||||
section.title,
|
section.title,
|
||||||
view,
|
|
||||||
section.bar_color,
|
section.bar_color,
|
||||||
section.icon,
|
section.icon,
|
||||||
section.preview_limit,
|
section.preview_limit,
|
||||||
section.empty_text,
|
|
||||||
i64::from(section.show_in_sidebar),
|
|
||||||
i64::from(view == "apps"),
|
|
||||||
section.sort,
|
section.sort,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -437,15 +453,9 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
|||||||
if view == "apps" {
|
if view == "apps" {
|
||||||
for group in §ion.groups {
|
for group in §ion.groups {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO node (key, parent_key, title, kind, show_in_sidebar, sort)
|
"INSERT INTO node (key, parent_key, title, kind, sort)
|
||||||
VALUES (?1, ?2, ?3, 'group', ?4, ?5)",
|
VALUES (?1, ?2, ?3, 'group', ?4)",
|
||||||
params![
|
params![group.key, section.key, group.title, group.sort],
|
||||||
group.key,
|
|
||||||
section.key,
|
|
||||||
group.title,
|
|
||||||
i64::from(group.show_in_sidebar),
|
|
||||||
group.sort
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
report.groups += 1;
|
report.groups += 1;
|
||||||
@@ -461,17 +471,26 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身)
|
// 首页卡片:仓库里直接生成卡片与勾选的分组
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO section_block (node_key, title, preview_limit, sort)
|
"INSERT INTO card (key, title, sort) VALUES (?1, ?2, ?3)",
|
||||||
VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![
|
params![
|
||||||
section.block.node_key,
|
format!("card-{}", section.key),
|
||||||
section.block.title,
|
section
|
||||||
section.block.preview_limit,
|
.block
|
||||||
|
.title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| section.title.clone()),
|
||||||
section.sort,
|
section.sort,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
.map_err(map_error)?;
|
||||||
|
let card_id = tx.last_insert_rowid();
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO card_group (card_id, group_key, sort, preview_limit)
|
||||||
|
VALUES (?1, ?2, 0, ?3)",
|
||||||
|
params![card_id, section.block.node_key, section.block.preview_limit],
|
||||||
|
)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
StashError::Database(format!(
|
StashError::Database(format!(
|
||||||
"分区 {} 的展示节点 {} 无效: {error}",
|
"分区 {} 的展示节点 {} 无效: {error}",
|
||||||
@@ -481,9 +500,6 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
|||||||
report.blocks += 1;
|
report.blocks += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 老结构(分区 → 展示块)同步生成卡片,保证新首页立刻有内容
|
|
||||||
tx.execute_batch(BACKFILL_CARDS_SQL).map_err(map_error)?;
|
|
||||||
|
|
||||||
tx.commit().map_err(map_error)?;
|
tx.commit().map_err(map_error)?;
|
||||||
|
|
||||||
Ok(report)
|
Ok(report)
|
||||||
@@ -565,11 +581,6 @@ struct SeedSection {
|
|||||||
sort: i64,
|
sort: i64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
preview_limit: Option<i64>,
|
preview_limit: Option<i64>,
|
||||||
#[serde(default)]
|
|
||||||
empty_text: Option<String>,
|
|
||||||
/// 是否出现在侧栏;默认 true
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
show_in_sidebar: bool,
|
|
||||||
/// 仅 apps 分区使用
|
/// 仅 apps 分区使用
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
groups: Vec<SeedGroup>,
|
groups: Vec<SeedGroup>,
|
||||||
@@ -601,17 +612,9 @@ struct SeedGroup {
|
|||||||
key: String,
|
key: String,
|
||||||
title: String,
|
title: String,
|
||||||
sort: i64,
|
sort: i64,
|
||||||
/// 是否出现在侧栏;默认 true
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
show_in_sidebar: bool,
|
|
||||||
entries: Vec<SeedEntry>,
|
entries: Vec<SeedEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// serde 默认值:侧栏默认显示
|
|
||||||
fn default_true() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一条内容:字段与 `entry` 表的列一一对应。
|
/// 一条内容:字段与 `entry` 表的列一一对应。
|
||||||
///
|
///
|
||||||
/// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta);
|
/// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta);
|
||||||
|
|||||||
-199
@@ -206,31 +206,6 @@ async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API: 完整内容目录(分区 / 分组 / 条目 + 首页展示块)
|
|
||||||
///
|
|
||||||
/// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。
|
|
||||||
/// 侧栏由这份数据派生,不再单独下发导航。
|
|
||||||
async fn catalog_api(State(state): State<AppState>) -> Response {
|
|
||||||
let db = state.db.clone();
|
|
||||||
|
|
||||||
// rusqlite 是同步的,查询扔进阻塞线程池,别占住 async 执行器
|
|
||||||
match tokio::task::spawn_blocking(move || db.load_catalog()).await {
|
|
||||||
Ok(Ok(catalog)) => Json(serde_json::json!({
|
|
||||||
"categories": catalog.categories,
|
|
||||||
}))
|
|
||||||
.into_response(),
|
|
||||||
Ok(Err(error)) => {
|
|
||||||
// 典型原因是用户手改坏了 JSON,错误信息里已带条目 id
|
|
||||||
warn!("读取内容库失败: {error}");
|
|
||||||
error.into_response()
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
warn!("内容库查询任务崩溃: {error}");
|
|
||||||
StashError::Database(format!("内容库查询任务崩溃: {error}")).into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SSE:内容库被外部修改时通知前端重新拉取。
|
/// SSE:内容库被外部修改时通知前端重新拉取。
|
||||||
///
|
///
|
||||||
/// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件:
|
/// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件:
|
||||||
@@ -964,172 +939,6 @@ async fn set_card_groups(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/categories —— 新建分区(需要登录)
|
|
||||||
async fn create_category(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
Json(body): Json<crate::db::categories::NewCategory>,
|
|
||||||
) -> Response {
|
|
||||||
if !same_origin(&headers) {
|
|
||||||
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
|
|
||||||
}
|
|
||||||
match read_session(&state, &headers).await {
|
|
||||||
Ok(session) if session.authenticated => {}
|
|
||||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
|
||||||
Err(response) => return response,
|
|
||||||
}
|
|
||||||
|
|
||||||
let db = Arc::clone(&state.db);
|
|
||||||
match tokio::task::spawn_blocking(move || db.create_category(body)).await {
|
|
||||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
|
||||||
Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message),
|
|
||||||
Ok(Err(StashError::InvalidDirectory(message))) => {
|
|
||||||
api_error(StatusCode::BAD_REQUEST, &message)
|
|
||||||
}
|
|
||||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
||||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PATCH /api/categories/{id} —— 修改分区(需要登录)
|
|
||||||
async fn update_category(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
Json(body): Json<crate::db::categories::UpdateCategory>,
|
|
||||||
) -> Response {
|
|
||||||
if !same_origin(&headers) {
|
|
||||||
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
|
|
||||||
}
|
|
||||||
match read_session(&state, &headers).await {
|
|
||||||
Ok(session) if session.authenticated => {}
|
|
||||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
|
||||||
Err(response) => return response,
|
|
||||||
}
|
|
||||||
|
|
||||||
let db = Arc::clone(&state.db);
|
|
||||||
match tokio::task::spawn_blocking(move || db.update_category(&id, body)).await {
|
|
||||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
|
||||||
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
|
|
||||||
Ok(Err(StashError::InvalidDirectory(message))) => {
|
|
||||||
api_error(StatusCode::BAD_REQUEST, &message)
|
|
||||||
}
|
|
||||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
||||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/categories/{id}/groups —— 新建分组(需要登录)
|
|
||||||
async fn create_group(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(section_id): Path<String>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
Json(body): Json<crate::db::categories::NewGroup>,
|
|
||||||
) -> Response {
|
|
||||||
if !same_origin(&headers) {
|
|
||||||
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
|
|
||||||
}
|
|
||||||
match read_session(&state, &headers).await {
|
|
||||||
Ok(session) if session.authenticated => {}
|
|
||||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
|
||||||
Err(response) => return response,
|
|
||||||
}
|
|
||||||
|
|
||||||
let db = Arc::clone(&state.db);
|
|
||||||
match tokio::task::spawn_blocking(move || db.create_group(§ion_id, body)).await {
|
|
||||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
|
||||||
Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message),
|
|
||||||
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
|
|
||||||
Ok(Err(StashError::InvalidDirectory(message))) => {
|
|
||||||
api_error(StatusCode::BAD_REQUEST, &message)
|
|
||||||
}
|
|
||||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
||||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PATCH /api/groups/{id} —— 分组改名 / 显隐(需要登录)
|
|
||||||
async fn update_group(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
Json(body): Json<crate::db::categories::UpdateGroup>,
|
|
||||||
) -> Response {
|
|
||||||
if !same_origin(&headers) {
|
|
||||||
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
|
|
||||||
}
|
|
||||||
match read_session(&state, &headers).await {
|
|
||||||
Ok(session) if session.authenticated => {}
|
|
||||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
|
||||||
Err(response) => return response,
|
|
||||||
}
|
|
||||||
|
|
||||||
let db = Arc::clone(&state.db);
|
|
||||||
match tokio::task::spawn_blocking(move || db.update_group(&id, body)).await {
|
|
||||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
|
||||||
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
|
|
||||||
Ok(Err(StashError::InvalidDirectory(message))) => {
|
|
||||||
api_error(StatusCode::BAD_REQUEST, &message)
|
|
||||||
}
|
|
||||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
||||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DELETE /api/groups/{id} —— 删除分组(需要登录,组内条目级联删除)
|
|
||||||
async fn delete_group(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Response {
|
|
||||||
if !same_origin(&headers) {
|
|
||||||
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
|
|
||||||
}
|
|
||||||
match read_session(&state, &headers).await {
|
|
||||||
Ok(session) if session.authenticated => {}
|
|
||||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
|
||||||
Err(response) => return response,
|
|
||||||
}
|
|
||||||
|
|
||||||
let db = Arc::clone(&state.db);
|
|
||||||
match tokio::task::spawn_blocking(move || db.remove_group(&id)).await {
|
|
||||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
|
||||||
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
|
|
||||||
Ok(Err(StashError::InvalidDirectory(message))) => {
|
|
||||||
api_error(StatusCode::BAD_REQUEST, &message)
|
|
||||||
}
|
|
||||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
||||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DELETE /api/categories/{id} —— 删除分区(需要登录,级联内容)
|
|
||||||
async fn delete_category(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Response {
|
|
||||||
if !same_origin(&headers) {
|
|
||||||
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
|
|
||||||
}
|
|
||||||
match read_session(&state, &headers).await {
|
|
||||||
Ok(session) if session.authenticated => {}
|
|
||||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
|
||||||
Err(response) => return response,
|
|
||||||
}
|
|
||||||
|
|
||||||
let db = Arc::clone(&state.db);
|
|
||||||
match tokio::task::spawn_blocking(move || db.remove_category(&id)).await {
|
|
||||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
|
||||||
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
|
|
||||||
Ok(Err(StashError::InvalidDirectory(message))) => {
|
|
||||||
api_error(StatusCode::BAD_REQUEST, &message)
|
|
||||||
}
|
|
||||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
||||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 组装路由:API + 内嵌前端 + 托管目录。
|
/// 组装路由:API + 内嵌前端 + 托管目录。
|
||||||
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
|
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
|
||||||
/// 起监听任务,没法在单元测试里用。
|
/// 起监听任务,没法在单元测试里用。
|
||||||
@@ -1148,7 +957,6 @@ fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/health", get(health_check))
|
.route("/api/health", get(health_check))
|
||||||
.route("/api/projects", get(list_projects_api))
|
.route("/api/projects", get(list_projects_api))
|
||||||
.route("/api/projects/events", get(project_events))
|
.route("/api/projects/events", get(project_events))
|
||||||
.route("/api/catalog", get(catalog_api))
|
|
||||||
.route("/api/catalog2", get(catalog2_api))
|
.route("/api/catalog2", get(catalog2_api))
|
||||||
.route("/api/nodes", post(create_node))
|
.route("/api/nodes", post(create_node))
|
||||||
.route("/api/nodes/{key}", patch(update_node).delete(delete_node))
|
.route("/api/nodes/{key}", patch(update_node).delete(delete_node))
|
||||||
@@ -1166,13 +974,6 @@ fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/items", post(create_item))
|
.route("/api/items", post(create_item))
|
||||||
.route("/api/items/{key}/view", post(post_view))
|
.route("/api/items/{key}/view", post(post_view))
|
||||||
.route("/api/items/{key}/like", post(post_like))
|
.route("/api/items/{key}/like", post(post_like))
|
||||||
.route("/api/categories", post(create_category))
|
|
||||||
.route(
|
|
||||||
"/api/categories/{id}",
|
|
||||||
patch(update_category).delete(delete_category),
|
|
||||||
)
|
|
||||||
.route("/api/categories/{id}/groups", post(create_group))
|
|
||||||
.route("/api/groups/{id}", patch(update_group).delete(delete_group))
|
|
||||||
.route("/api/backup", get(backup_db))
|
.route("/api/backup", get(backup_db))
|
||||||
.route("/", get(web_index))
|
.route("/", get(web_index))
|
||||||
.route("/index.html", get(web_index))
|
.route("/index.html", get(web_index))
|
||||||
|
|||||||
Generated
+1
-1
@@ -24,7 +24,7 @@
|
|||||||
},
|
},
|
||||||
"../../stash-ui": {
|
"../../stash-ui": {
|
||||||
"name": "@stash/ui",
|
"name": "@stash/ui",
|
||||||
"version": "0.1.0",
|
"version": "0.3.1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
|
|||||||
+14
-6
@@ -42,9 +42,9 @@
|
|||||||
/* ---------- 页头 ---------- */
|
/* ---------- 页头 ---------- */
|
||||||
|
|
||||||
.site-header-inner {
|
.site-header-inner {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 720px) minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--su-space-4);
|
gap: var(--su-space-4);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: var(--su-layout-width);
|
max-width: var(--su-layout-width);
|
||||||
@@ -133,14 +133,22 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动信息只占中间约 2/3,并靠右贴近操作图标 */
|
/* 滚动信息居中,左右列等宽 */
|
||||||
|
.header-ticker-slot {
|
||||||
|
justify-self: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.header-ticker {
|
.header-ticker {
|
||||||
max-width: 720px;
|
width: 100%;
|
||||||
margin-left: auto;
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
/* 靠右交给 .header-ticker 的 margin-left: auto;没有滚动信息时由 space-between 兜住 */
|
justify-self: end;
|
||||||
|
/* 靠右对齐到页头右边缘 */
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ import type { Project } from '../../api'
|
|||||||
/**
|
/**
|
||||||
* 新版后台:原子模型。
|
* 新版后台:原子模型。
|
||||||
*
|
*
|
||||||
* - 分组是唯一容器(可多层、带格式);
|
* - 分组是唯一容器(可多层);
|
||||||
* - 内容是条目,挂在分组上;
|
* - 内容是条目,挂在分组上;
|
||||||
* - 侧边栏、首页卡片都只是「勾选分组」的展示组合。
|
* - 侧边栏、首页卡片都只是「勾选分组」的展示组合。
|
||||||
*/
|
*/
|
||||||
@@ -264,6 +264,9 @@ export function AdminNext() {
|
|||||||
<div className="site-header-inner">
|
<div className="site-header-inner">
|
||||||
<Brand href="/" name={brandName} mark={brandMark} logo={brandLogo} />
|
<Brand href="/" name={brandName} mark={brandMark} logo={brandLogo} />
|
||||||
|
|
||||||
|
{/* 占住前台的中间列,让右侧操作和前台的右边缘对齐;后台没有滚动信息 */}
|
||||||
|
<div className="header-ticker-slot" aria-hidden="true" />
|
||||||
|
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<IconButton
|
<IconButton
|
||||||
label={isDark ? '切换到浅色' : '切换到深色'}
|
label={isDark ? '切换到浅色' : '切换到深色'}
|
||||||
@@ -572,7 +575,7 @@ function GroupsPanel({
|
|||||||
return (
|
return (
|
||||||
<Space direction="column" gap="md" align="stretch" className="admin-form">
|
<Space direction="column" gap="md" align="stretch" className="admin-form">
|
||||||
<Text size="sm" tone="muted">
|
<Text size="sm" tone="muted">
|
||||||
说明:分组用于控制前台侧边栏与卡片;可多层嵌套,根分组决定格式。内容(条目)只挂在分组上。
|
说明:分组用于控制前台侧边栏与卡片,可多层嵌套。内容(条目)只挂在分组上,格式由每条内容自己决定。
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div className="group-toolbar">
|
<div className="group-toolbar">
|
||||||
@@ -590,7 +593,7 @@ function GroupsPanel({
|
|||||||
columns={[
|
columns={[
|
||||||
{ key: 'name', title: '分组名称' },
|
{ key: 'name', title: '分组名称' },
|
||||||
{ key: 'sort', title: '排序权重', width: 110 },
|
{ key: 'sort', title: '排序权重', width: 110 },
|
||||||
{ key: 'count', title: '关联网页数', width: 120 },
|
{ key: 'count', title: '关联内容数', width: 120 },
|
||||||
{ key: 'status', title: '状态', width: 90 },
|
{ key: 'status', title: '状态', width: 90 },
|
||||||
{ key: 'actions', title: '操作', width: 260, align: 'right' },
|
{ key: 'actions', title: '操作', width: 260, align: 'right' },
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Badge } from '@stash/ui'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 数量角标:贴在分区标题旁边的小红标。
|
|
||||||
* 组件库的 Badge 已经处理了两位数和 99+ 的宽度,这里只做语义封装。
|
|
||||||
*/
|
|
||||||
type CountBadgeProps = {
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CountBadge({ count }: CountBadgeProps) {
|
|
||||||
return <Badge count={count} tone="danger" />
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Icon } from '@stash/ui'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 语义图标:stash 内部一直按用途引用(SearchIcon / RefreshIcon…),
|
|
||||||
* 这里统一映射到 @stash/ui 的 iconfont 字形,避免各页面记字形名。
|
|
||||||
*/
|
|
||||||
type IconProps = {
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchIcon({ className }: IconProps) {
|
|
||||||
return <Icon name="search_3_line" className={className} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserIcon({ className }: IconProps) {
|
|
||||||
return <Icon name="user_3_line" className={className} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RefreshIcon({ className }: IconProps) {
|
|
||||||
return <Icon name="refresh_3_line" className={className} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LinkIcon({ className }: IconProps) {
|
|
||||||
return <Icon name="link_3_line" className={className} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExternalIcon({ className }: IconProps) {
|
|
||||||
return <Icon name="a-external_link_line1" className={className} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EyeIcon({ className }: IconProps) {
|
|
||||||
return <Icon name="eye_line" className={className} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThumbUpIcon({ className }: IconProps) {
|
|
||||||
return <Icon name="thumb_up_2_line" className={className} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShareIcon({ className }: IconProps) {
|
|
||||||
return <Icon name="share_3_line" className={className} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CloseIcon({ className }: IconProps) {
|
|
||||||
return <Icon name="close_line" className={className} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MoreIcon({ className }: IconProps) {
|
|
||||||
return <Icon name="more_1_line" className={className} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ArrowUpIcon({ className }: IconProps) {
|
|
||||||
return <Icon name="arrow_up_line" className={className} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ArrowDownIcon({ className }: IconProps) {
|
|
||||||
return <Icon name="arrow_down_line" className={className} />
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Cover } from '@stash/ui'
|
|
||||||
import type { ArticleItem } from '../../data/types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 首页图文卡:4:3 封面 + 底部渐变条上的标题。
|
|
||||||
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
|
|
||||||
*/
|
|
||||||
type ArticleCardProps = {
|
|
||||||
item: ArticleItem
|
|
||||||
onOpen?: (itemId: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ArticleCard({ item, onOpen }: ArticleCardProps) {
|
|
||||||
return (
|
|
||||||
<Cover
|
|
||||||
src={item.cover}
|
|
||||||
alt={item.title}
|
|
||||||
caption={item.title}
|
|
||||||
ratio="article"
|
|
||||||
onClick={onOpen === undefined ? undefined : () => onOpen(item.id)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Grid } from '@stash/ui'
|
|
||||||
import type { ArticleItem } from '../../data/types'
|
|
||||||
import { ArticleCard } from './ArticleCard'
|
|
||||||
|
|
||||||
/** 图文网格:桌面 4 列,平板 2 列,手机 1 列 */
|
|
||||||
type ArticleGridProps = {
|
|
||||||
items: ArticleItem[]
|
|
||||||
/** 点击卡片打开文章详情 */
|
|
||||||
onOpen?: (itemId: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ArticleGrid({ items, onOpen }: ArticleGridProps) {
|
|
||||||
return (
|
|
||||||
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="md">
|
|
||||||
{items.map((item) => (
|
|
||||||
<ArticleCard key={item.id} item={item} onOpen={onOpen} />
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { Breadcrumb, Card, Empty, Space, Title } from '@stash/ui'
|
|
||||||
import {
|
|
||||||
categoryCount,
|
|
||||||
type AppItem,
|
|
||||||
type ArticleItem,
|
|
||||||
type Category,
|
|
||||||
type PosterItem,
|
|
||||||
} from '../../data/types'
|
|
||||||
import { viewPath } from '../../data/view'
|
|
||||||
import { AppGrid } from './AppGrid'
|
|
||||||
import { CesCardGrid } from './CesCardGrid'
|
|
||||||
import { GroupBlock } from './GroupBlock'
|
|
||||||
import { PosterGrid } from './PosterGrid'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 分类明细视图:面包屑 + hero(图标 / 标题 / 数量)+ 内容区。
|
|
||||||
*
|
|
||||||
* 内容区按分类类型切换:
|
|
||||||
* - apps:指定分组时只渲染该组,否则单组扁平渲染、多组带小标题分组渲染;
|
|
||||||
* - posters:6 列海报;
|
|
||||||
* - articles:上图下文白卡网格。
|
|
||||||
*/
|
|
||||||
type DetailViewProps = {
|
|
||||||
category: Category
|
|
||||||
/** 从侧栏子菜单进入时指定的分组 */
|
|
||||||
groupId?: string
|
|
||||||
/** 点击面包屑中间项(SPA 路由) */
|
|
||||||
onNavigate: (href: string) => void
|
|
||||||
/** 点击卡片 / 海报进入条目详情 */
|
|
||||||
onOpenItem: (itemId: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DetailView({ category, groupId, onNavigate, onOpenItem }: DetailViewProps) {
|
|
||||||
// 三种类型共用「分区 → 分组 → 条目」结构;这里抹平联合类型,渲染时再按 kind 分派
|
|
||||||
type RenderItem = AppItem | PosterItem | ArticleItem
|
|
||||||
const groups = category.groups as {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
items: RenderItem[]
|
|
||||||
}[]
|
|
||||||
const activeGroup =
|
|
||||||
groupId !== undefined ? groups.find((group) => group.id === groupId) : undefined
|
|
||||||
|
|
||||||
const title = activeGroup?.name ?? category.title
|
|
||||||
const count = activeGroup !== undefined ? activeGroup.items.length : categoryCount(category)
|
|
||||||
const crumbs =
|
|
||||||
activeGroup !== undefined
|
|
||||||
? [
|
|
||||||
{ label: '首页', href: '/' },
|
|
||||||
{ label: category.title, href: viewPath({ type: 'detail', categoryId: category.id }) },
|
|
||||||
{ label: activeGroup.name },
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{ label: '首页', href: '/' },
|
|
||||||
{ label: category.title, href: viewPath({ type: 'detail', categoryId: category.id }) },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** 按类型渲染一组条目 */
|
|
||||||
function renderItems(items: RenderItem[]) {
|
|
||||||
if (category.kind === 'posters') {
|
|
||||||
return <PosterGrid items={items as PosterItem[]} variant="detail" onOpen={onOpenItem} />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (category.kind === 'articles') {
|
|
||||||
return <CesCardGrid items={items as ArticleItem[]} onOpen={onOpenItem} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AppGrid items={items as AppItem[]} variant="detail" onOpen={onOpenItem} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBody() {
|
|
||||||
if (activeGroup !== undefined) {
|
|
||||||
return renderItems(activeGroup.items)
|
|
||||||
}
|
|
||||||
|
|
||||||
const direct = (category.items ?? []) as RenderItem[]
|
|
||||||
// 空分组不占篇幅(管理后台仍然保留,之后可以往里加条目)
|
|
||||||
const filledGroups = groups.filter((group) => group.items.length > 0)
|
|
||||||
|
|
||||||
if (filledGroups.length === 0) {
|
|
||||||
return direct.length > 0 ? (
|
|
||||||
renderItems(direct)
|
|
||||||
) : (
|
|
||||||
<Empty text={'emptyText' in category ? (category.emptyText ?? '暂无内容') : '暂无内容'} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filledGroups.length === 1 && direct.length === 0) {
|
|
||||||
return renderItems(filledGroups[0].items)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Space direction="column" gap="lg" align="stretch">
|
|
||||||
{filledGroups.map((group) => (
|
|
||||||
<GroupBlock
|
|
||||||
key={group.id}
|
|
||||||
name={group.name}
|
|
||||||
barColor={category.barColor !== '' ? category.barColor : undefined}
|
|
||||||
>
|
|
||||||
{renderItems(group.items)}
|
|
||||||
</GroupBlock>
|
|
||||||
))}
|
|
||||||
{direct.length > 0 && (
|
|
||||||
<GroupBlock
|
|
||||||
name="未分组"
|
|
||||||
barColor={category.barColor !== '' ? category.barColor : undefined}
|
|
||||||
>
|
|
||||||
{renderItems(direct)}
|
|
||||||
</GroupBlock>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="detail-view">
|
|
||||||
<Card padding="none" className="detail-nav">
|
|
||||||
<Breadcrumb
|
|
||||||
className="detail-breadcrumb"
|
|
||||||
items={crumbs}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="detail-hero">
|
|
||||||
<span className="detail-hero-icon" aria-hidden="true">
|
|
||||||
{category.icon}
|
|
||||||
</span>
|
|
||||||
<Title level={1} as="h2" weight="strong" className="detail-hero-title">
|
|
||||||
{title}
|
|
||||||
</Title>
|
|
||||||
<span className="detail-hero-count">[{count}]</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderBody()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Space, Title } from '@stash/ui'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 明细页的分组块:色条 + 小标题 + 该组的内容网格。
|
|
||||||
*
|
|
||||||
* 色条沿用分区的 `barColor`,与首页卡片标题保持同一套视觉语言;
|
|
||||||
* 具体网格由调用方传入(应用 / 海报 / 图文共用同一层外壳)。
|
|
||||||
*/
|
|
||||||
type GroupBlockProps = {
|
|
||||||
name: string
|
|
||||||
/** 分区色条颜色;缺省用主题主色 */
|
|
||||||
barColor?: string
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GroupBlock({ name, barColor, children }: GroupBlockProps) {
|
|
||||||
return (
|
|
||||||
<Space direction="column" gap="sm" align="stretch" className="group-block">
|
|
||||||
<div className="group-title">
|
|
||||||
<span
|
|
||||||
className="group-bar"
|
|
||||||
style={barColor !== undefined ? { backgroundColor: barColor } : undefined}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<Title level={4} as="h3">
|
|
||||||
{name}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { categoryCount, flattenAppItems, type Category } from '../../data/types'
|
|
||||||
import { AppGrid } from './AppGrid'
|
|
||||||
import { ArticleGrid } from './ArticleGrid'
|
|
||||||
import { Empty } from '@stash/ui'
|
|
||||||
import { PosterGrid } from './PosterGrid'
|
|
||||||
import { SectionCard } from './SectionCard'
|
|
||||||
import {
|
|
||||||
applySectionSettings,
|
|
||||||
sectionLimit,
|
|
||||||
sectionTitle,
|
|
||||||
type SectionSettings,
|
|
||||||
} from '../../settings'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 首页:把每个分类渲染成一张分区卡片。
|
|
||||||
*
|
|
||||||
* 每个分区只展示前若干条;标题、条数、顺序与显隐都可以在 `/admin`
|
|
||||||
* 的「首页卡片」里覆盖(见 settings.sections)。
|
|
||||||
*/
|
|
||||||
type HomeViewProps = {
|
|
||||||
categories: Category[]
|
|
||||||
sections: SectionSettings
|
|
||||||
onOpenDetail: (categoryId: string) => void
|
|
||||||
onOpenItem: (categoryId: string, itemId: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HomeView({ categories, sections, onOpenDetail, onOpenItem }: HomeViewProps) {
|
|
||||||
const cards = applySectionSettings(categories, sections)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="home-view">
|
|
||||||
{cards.map((category) => (
|
|
||||||
<SectionCard
|
|
||||||
key={category.id}
|
|
||||||
// 卡片标题跟着实际展示的内容走:配了展示块就用块标题(如「常用站点」),
|
|
||||||
// 没配(如「本地服务」)才用分类标题;设置里的覆盖值优先
|
|
||||||
title={sectionTitle(
|
|
||||||
category.id,
|
|
||||||
category.block?.title ?? category.title,
|
|
||||||
sections,
|
|
||||||
)}
|
|
||||||
count={cardCount(category)}
|
|
||||||
barColor={category.barColor}
|
|
||||||
onMore={() => onOpenDetail(category.id)}
|
|
||||||
>
|
|
||||||
{renderPreview(category, sections, onOpenItem)}
|
|
||||||
</SectionCard>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 卡片右上角的数量角标。
|
|
||||||
*
|
|
||||||
* 配了「分组展示块」时,卡片实际展示的是那个分组,所以数量也应该只数它;
|
|
||||||
* 否则(电影 / 文章 / 本地服务)数整个分区。
|
|
||||||
*/
|
|
||||||
function cardCount(category: Category): number {
|
|
||||||
if (category.block !== undefined) {
|
|
||||||
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
|
|
||||||
if (group !== undefined) return group.items.length
|
|
||||||
}
|
|
||||||
|
|
||||||
return categoryCount(category)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 某个分区配的「首页预览分组」;没配或找不到返回 undefined */
|
|
||||||
function blockGroup<T>(category: { block?: { nodeKey?: string }; groups: { id: string; items: T[] }[] }) {
|
|
||||||
if (category.block?.nodeKey === undefined) return undefined
|
|
||||||
return category.groups.find((group) => group.id === category.block?.nodeKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
|
|
||||||
function renderPreview(
|
|
||||||
category: Category,
|
|
||||||
sections: SectionSettings,
|
|
||||||
onOpenItem: (categoryId: string, itemId: string) => void,
|
|
||||||
) {
|
|
||||||
if (category.kind === 'apps') {
|
|
||||||
// 配了展示块且指向分组时只展示该组;展示块指向分区本身(「整个分区」)时展示全部条目
|
|
||||||
const group = blockGroup(category)
|
|
||||||
const source = group !== undefined ? group.items : flattenAppItems(category)
|
|
||||||
const fallback = category.block?.previewLimit ?? category.previewLimit ?? 4
|
|
||||||
const limit = sectionLimit(category.id, fallback, sections)
|
|
||||||
const items = source.slice(0, limit)
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return <Empty text={category.emptyText ?? '暂无内容'} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AppGrid items={items} onOpen={(itemId) => onOpenItem(category.id, itemId)} />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (category.kind === 'posters') {
|
|
||||||
const group = blockGroup(category)
|
|
||||||
const source = group
|
|
||||||
? group.items
|
|
||||||
: [...category.items, ...category.groups.flatMap((item) => item.items)]
|
|
||||||
return (
|
|
||||||
<PosterGrid
|
|
||||||
items={source.slice(0, sectionLimit(category.id, category.previewLimit ?? 6, sections))}
|
|
||||||
onOpen={(itemId) => onOpenItem(category.id, itemId)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const group = blockGroup(category)
|
|
||||||
const source = group
|
|
||||||
? group.items
|
|
||||||
: [...category.items, ...category.groups.flatMap((item) => item.items)]
|
|
||||||
return (
|
|
||||||
<ArticleGrid
|
|
||||||
items={source.slice(0, sectionLimit(category.id, category.previewLimit ?? 4, sections))}
|
|
||||||
onOpen={(itemId) => onOpenItem(category.id, itemId)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -76,6 +76,7 @@ export function SiteHeader({
|
|||||||
logo={site.brand.logo}
|
logo={site.brand.logo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="header-ticker-slot">
|
||||||
{headerEnabled && (
|
{headerEnabled && (
|
||||||
<Ticker speed={headerSpeed} gap={headerGap} label="运行状态" className="header-ticker">
|
<Ticker speed={headerSpeed} gap={headerGap} label="运行状态" className="header-ticker">
|
||||||
{headerItems.map((item, index) => (
|
{headerItems.map((item, index) => (
|
||||||
@@ -90,6 +91,7 @@ export function SiteHeader({
|
|||||||
))}
|
))}
|
||||||
</Ticker>
|
</Ticker>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
{site.actions.theme && (
|
{site.actions.theme && (
|
||||||
|
|||||||
@@ -64,19 +64,23 @@ export function ServicePanel({
|
|||||||
重新扫描
|
重新扫描
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<a
|
<Button
|
||||||
className="link-button"
|
size="sm"
|
||||||
href="/interface"
|
variant="secondary"
|
||||||
|
className="service-action"
|
||||||
|
icon={<Icon name="link_3_line" />}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
// 无修饰键时接管点击,走站内路由,只换右侧内容;按住修饰键仍可新标签打开
|
// 无修饰键时走站内路由;按住修饰键仍可新标签打开接口页
|
||||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||||
event.preventDefault()
|
window.open('/interface', '_blank', 'noopener')
|
||||||
|
return
|
||||||
|
}
|
||||||
onOpenApi()
|
onOpenApi()
|
||||||
}}
|
}}
|
||||||
|
title="查看接口一览"
|
||||||
>
|
>
|
||||||
<Icon name="link_3_line" />
|
|
||||||
查看接口
|
查看接口
|
||||||
</a>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { Category } from './types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按关键字过滤分类内容。
|
|
||||||
*
|
|
||||||
* 应用类分类会逐组过滤并丢掉空组,海报 / 图文类按标题或描述匹配;
|
|
||||||
* 空关键字直接原样返回,避免无谓的对象重建。
|
|
||||||
*/
|
|
||||||
export function filterCategories(categories: Category[], keyword: string): Category[] {
|
|
||||||
const query = keyword.trim().toLowerCase()
|
|
||||||
if (query === '') return categories
|
|
||||||
|
|
||||||
const result: Category[] = []
|
|
||||||
|
|
||||||
for (const category of categories) {
|
|
||||||
if (category.kind === 'apps') {
|
|
||||||
const groups = category.groups
|
|
||||||
.map((group) => ({
|
|
||||||
...group,
|
|
||||||
items: group.items.filter((item) => matches(query, item.name, item.desc)),
|
|
||||||
}))
|
|
||||||
.filter((group) => group.items.length > 0)
|
|
||||||
|
|
||||||
if (groups.length > 0) {
|
|
||||||
result.push({ ...category, groups })
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (category.kind === 'posters') {
|
|
||||||
const items = category.items.filter((item) => matches(query, item.title))
|
|
||||||
if (items.length > 0) {
|
|
||||||
result.push({ ...category, items })
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = category.items.filter((item) => matches(query, item.title, item.desc))
|
|
||||||
if (items.length > 0) {
|
|
||||||
result.push({ ...category, items })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function matches(query: string, ...fields: string[]): boolean {
|
|
||||||
return fields.some((field) => field.toLowerCase().includes(query))
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { AppCategory, AppItem, Category } from './types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 「本地服务」分类的组装逻辑。
|
|
||||||
*
|
|
||||||
* 这一块不来自内容库:托管目录里有哪些项目是由文件系统决定的,
|
|
||||||
* 后端每次扫描后通过 `/api/projects` 推送,所以本地分类始终在前端拼出来,
|
|
||||||
* 内容库只负责其余的分类(常用推荐 / 推荐电影 / CES 现场报道)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 后端 `/api/projects` 返回的项目结构 */
|
|
||||||
export type HostedProject = {
|
|
||||||
name: string
|
|
||||||
route_path: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把托管项目映射成应用条目。
|
|
||||||
*
|
|
||||||
* id 用项目名(就是目录名):它在前端各处(首页卡片、明细、详情)唯一,
|
|
||||||
* 又正好是可以直接放进地址栏的一段——条目详情地址是 `/c/local/<分组>/i/<项目名>`。
|
|
||||||
* 访问地址仍然用 `route_path`(`/demo/`),两者不再混用。
|
|
||||||
* 本地项目没有封面图,详情页会退化成字符头像。
|
|
||||||
*/
|
|
||||||
function toLocalItem(project: HostedProject): AppItem {
|
|
||||||
return {
|
|
||||||
id: project.name,
|
|
||||||
name: project.name,
|
|
||||||
desc: project.route_path,
|
|
||||||
icon: project.name.slice(0, 1).toUpperCase(),
|
|
||||||
href: project.route_path,
|
|
||||||
comment: '本地静态站点,由 Stash 直接托管。',
|
|
||||||
badge: '本地项目',
|
|
||||||
views: 0,
|
|
||||||
likes: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 生成「本地服务」分类;没有项目时给出一句引导文案 */
|
|
||||||
export function buildLocalCategory(projects: HostedProject[]): AppCategory {
|
|
||||||
const items = projects.map(toLocalItem)
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: 'local',
|
|
||||||
kind: 'apps',
|
|
||||||
title: '本地服务',
|
|
||||||
barColor: '#3858f6',
|
|
||||||
icon: '🖥️',
|
|
||||||
groups: items.length > 0 ? [{ id: 'local-all', name: '全部项目', items }] : [],
|
|
||||||
previewLimit: 8,
|
|
||||||
emptyText: '还没有项目:在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拼接首页全部分类:本地服务永远排第一,其后跟进内容库返回的分类。
|
|
||||||
*/
|
|
||||||
export function withLocalCategory(
|
|
||||||
projects: HostedProject[],
|
|
||||||
categories: Category[],
|
|
||||||
): Category[] {
|
|
||||||
return [buildLocalCategory(projects), ...categories]
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* 导航目标。
|
|
||||||
*
|
|
||||||
* 侧栏现在直接由内容树(分区 → 分组)派生,不再有独立的导航数据模型;
|
|
||||||
* 这里只保留「点击后要去哪」的最小类型。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type NavTarget = {
|
|
||||||
categoryId: string
|
|
||||||
/** 指定后明细页只展示该分组 */
|
|
||||||
groupId?: string
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,6 @@ export type GroupNode = {
|
|||||||
key: string
|
key: string
|
||||||
parentKey?: string | null
|
parentKey?: string | null
|
||||||
title: string
|
title: string
|
||||||
format: GroupFormat
|
|
||||||
icon: string
|
icon: string
|
||||||
barColor: string
|
barColor: string
|
||||||
previewLimit?: number | null
|
previewLimit?: number | null
|
||||||
@@ -189,13 +188,12 @@ export async function setCardGroups(
|
|||||||
export function flattenGroups(
|
export function flattenGroups(
|
||||||
groups: GroupNode[],
|
groups: GroupNode[],
|
||||||
depth = 0,
|
depth = 0,
|
||||||
): { key: string; title: string; depth: number; format: GroupFormat; enabled: boolean }[] {
|
): { key: string; title: string; depth: number; enabled: boolean }[] {
|
||||||
return groups.flatMap((group) => [
|
return groups.flatMap((group) => [
|
||||||
{
|
{
|
||||||
key: group.key,
|
key: group.key,
|
||||||
title: group.title,
|
title: group.title,
|
||||||
depth,
|
depth,
|
||||||
format: group.format,
|
|
||||||
enabled: group.enabled !== false,
|
enabled: group.enabled !== false,
|
||||||
},
|
},
|
||||||
...flattenGroups(group.children, depth + 1),
|
...flattenGroups(group.children, depth + 1),
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* 登录后展示的用户模块数据。
|
|
||||||
*
|
|
||||||
* 后端目前没有鉴权 / 用户信息接口,这里先用占位数据,
|
|
||||||
* 等类似 `/api/me` 的接口就绪后替换为真实数据即可。
|
|
||||||
*/
|
|
||||||
export type UserProfile = {
|
|
||||||
name: string
|
|
||||||
role: string
|
|
||||||
bio: string
|
|
||||||
location: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PLACEHOLDER_PROFILE: UserProfile = {
|
|
||||||
name: '本地管理员',
|
|
||||||
role: '管理员',
|
|
||||||
bio: '本地静态站点托管与预览,所有项目都运行在这台机器上。',
|
|
||||||
location: '局域网',
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
import { fetchCatalog, subscribeCatalog } from './api'
|
|
||||||
import type { Category } from './data/types'
|
|
||||||
|
|
||||||
export type CatalogStatus = 'loading' | 'ready' | 'error'
|
|
||||||
|
|
||||||
export type CatalogState = {
|
|
||||||
status: CatalogStatus
|
|
||||||
categories: Category[]
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CatalogController = CatalogState & {
|
|
||||||
/** 手动重新拉取;内容库被外部修改后也会自动调用 */
|
|
||||||
reload: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function toErrorMessage(error: unknown): string {
|
|
||||||
return error instanceof Error ? error.message : String(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 内容目录:首屏拉取 + 监听服务端推送 + 手动重载。
|
|
||||||
*
|
|
||||||
* 与 `useProjects` 的区别是失败策略:项目列表失败会保留旧数据,
|
|
||||||
* 内容目录同样保留旧数据 —— 用户正在改库时偶尔读到坏 JSON 不应该把页面清空。
|
|
||||||
*/
|
|
||||||
export function useCatalog(): CatalogController {
|
|
||||||
const [state, setState] = useState<CatalogState>({
|
|
||||||
status: 'loading',
|
|
||||||
categories: [],
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
const controllerRef = useRef<AbortController | null>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拉一次内容目录。
|
|
||||||
*
|
|
||||||
* 用 ref 保存当前的 AbortController:外部改动可能连续触发多次刷新,
|
|
||||||
* 只保留最后一次请求,避免旧响应把新数据覆盖回去。
|
|
||||||
*/
|
|
||||||
const load = useCallback(() => {
|
|
||||||
controllerRef.current?.abort()
|
|
||||||
const controller = new AbortController()
|
|
||||||
controllerRef.current = controller
|
|
||||||
|
|
||||||
fetchCatalog(controller.signal)
|
|
||||||
.then((catalog) => {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setState({
|
|
||||||
status: 'ready',
|
|
||||||
categories: catalog.categories,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setState((previous) => ({
|
|
||||||
// 已经有内容时只提示错误,不清空列表
|
|
||||||
status: previous.categories.length > 0 ? 'ready' : 'error',
|
|
||||||
categories: previous.categories,
|
|
||||||
error: toErrorMessage(error),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 首屏加载:setState 只发生在 Promise 回调里,避免 effect 内同步 setState
|
|
||||||
useEffect(() => {
|
|
||||||
load()
|
|
||||||
return () => controllerRef.current?.abort()
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
// 内容库被外部修改时,服务端推送一次「变了」,这里重新拉取
|
|
||||||
useEffect(() => subscribeCatalog(load), [load])
|
|
||||||
|
|
||||||
return { ...state, reload: load }
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { parseViewPath, viewPath, type View } from './data/view'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 视图 ↔ 地址同步。
|
|
||||||
*
|
|
||||||
* 三个入口都要覆盖:首屏(解析地址)、点击(写地址)、浏览器前进后退(popstate)。
|
|
||||||
* 视图本身仍然放在 React state 里,地址只是它的镜像,避免每次渲染都去读 URL;
|
|
||||||
* 地址与视图的换算规则收在 `data/view.ts`,这里只管 history。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 存在 history.state 里的键:当前这条记录是应用内第几页(0 = 没有上一页) */
|
|
||||||
const HISTORY_INDEX_KEY = 'stashIndex'
|
|
||||||
|
|
||||||
type StashHistoryState = { [HISTORY_INDEX_KEY]?: number }
|
|
||||||
|
|
||||||
/** 读取当前历史深度;不是本应用写的记录一律当作 0 */
|
|
||||||
function historyIndex(): number {
|
|
||||||
const state = window.history.state as StashHistoryState | null
|
|
||||||
const index = state === null ? undefined : state[HISTORY_INDEX_KEY]
|
|
||||||
return typeof index === 'number' ? index : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 当前 history.state 的副本,写入前先保留别人(如浏览器扩展)放的字段 */
|
|
||||||
function currentHistoryState(): StashHistoryState {
|
|
||||||
const state = window.history.state as StashHistoryState | null
|
|
||||||
return state === null ? {} : { ...state }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ViewRoute = {
|
|
||||||
/** 当前视图,初值来自地址栏 */
|
|
||||||
view: View
|
|
||||||
/** 打开一个视图:默认压一条新历史,`replace` 表示替换当前这条 */
|
|
||||||
go: (view: View, options?: { replace?: boolean }) => void
|
|
||||||
/**
|
|
||||||
* 返回。
|
|
||||||
*
|
|
||||||
* 应用内有上一页时交给浏览器(`history.back()`),
|
|
||||||
* 直接打开条目地址(收藏 / 刷新后没有上一页)时退到 `fallback`,
|
|
||||||
* 这时它替换当前记录,避免在历史里留下一条「自己指向自己」的条目。
|
|
||||||
*/
|
|
||||||
back: (fallback: View) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useViewRoute(): ViewRoute {
|
|
||||||
const [view, setView] = useState<View>(() => parseViewPath(window.location.pathname))
|
|
||||||
|
|
||||||
// 首屏把地址规范化:去掉多余的斜杠 / 尾部斜杠,未知地址归到首页,
|
|
||||||
// 并补上历史深度(直接打开时是 0,说明应用内没有上一页)
|
|
||||||
useEffect(() => {
|
|
||||||
const canonical = viewPath(parseViewPath(window.location.pathname))
|
|
||||||
const state = currentHistoryState()
|
|
||||||
const recorded = state[HISTORY_INDEX_KEY] !== undefined
|
|
||||||
|
|
||||||
if (canonical === window.location.pathname && recorded) return
|
|
||||||
|
|
||||||
window.history.replaceState({ ...state, [HISTORY_INDEX_KEY]: historyIndex() }, '', canonical)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 前进后退:地址变了,视图跟着变
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePopState = () => setView(parseViewPath(window.location.pathname))
|
|
||||||
window.addEventListener('popstate', handlePopState)
|
|
||||||
return () => window.removeEventListener('popstate', handlePopState)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const go = useCallback((next: View, options?: { replace?: boolean }) => {
|
|
||||||
const path = viewPath(next)
|
|
||||||
const index = historyIndex()
|
|
||||||
|
|
||||||
// 已经在同一页(例如重复点同一个侧栏项):不写历史,也不改深度
|
|
||||||
if (path === window.location.pathname) {
|
|
||||||
setView(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const replace = options?.replace === true
|
|
||||||
const state: StashHistoryState = {
|
|
||||||
...currentHistoryState(),
|
|
||||||
[HISTORY_INDEX_KEY]: replace ? index : index + 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replace) {
|
|
||||||
window.history.replaceState(state, '', path)
|
|
||||||
} else {
|
|
||||||
window.history.pushState(state, '', path)
|
|
||||||
}
|
|
||||||
|
|
||||||
setView(next)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const back = useCallback(
|
|
||||||
(fallback: View) => {
|
|
||||||
if (historyIndex() > 0) {
|
|
||||||
window.history.back()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
go(fallback, { replace: true })
|
|
||||||
},
|
|
||||||
[go],
|
|
||||||
)
|
|
||||||
|
|
||||||
return { view, go, back }
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>404 - 资源未找到</title>
|
|
||||||
<link rel="stylesheet" href="/__stash__/dashboard.css">
|
|
||||||
</head>
|
|
||||||
<body class="error-page">
|
|
||||||
<main class="error-box">
|
|
||||||
<div class="error-code">404</div>
|
|
||||||
<h1>资源或页面不存在</h1>
|
|
||||||
<p>请求的路径 <code>{{ path }}</code> 不存在或缺少 index.html</p>
|
|
||||||
<a href="/public" class="button">返回应用大厅</a>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="color-scheme" content="light">
|
|
||||||
<meta name="theme-color" content="#f7f8fa">
|
|
||||||
<title>Stash · 本地应用工作区</title>
|
|
||||||
<link rel="stylesheet" href="/__stash__/dashboard.css">
|
|
||||||
<script src="/__stash__/dashboard.js" defer></script>
|
|
||||||
</head>
|
|
||||||
<body class="dashboard-page">
|
|
||||||
<div class="ambient ambient-blue" aria-hidden="true"></div>
|
|
||||||
<div class="ambient ambient-warm" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<div class="workspace">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<header class="sidebar-header">
|
|
||||||
<a class="brand" href="/public" aria-label="Stash 首页">
|
|
||||||
<span class="brand-mark" aria-hidden="true">
|
|
||||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<rect x="3" y="3" width="7" height="7" rx="2"></rect>
|
|
||||||
<rect x="14" y="3" width="7" height="7" rx="2"></rect>
|
|
||||||
<rect x="3" y="14" width="7" height="7" rx="2"></rect>
|
|
||||||
<path d="M17.5 14v7M14 17.5h7"></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span class="brand-name">stash<span>.</span></span>
|
|
||||||
</a>
|
|
||||||
<span class="local-badge">LOCAL</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="sidebar-intro">
|
|
||||||
<p class="eyebrow"><span class="eyebrow-dot"></span>YOUR WORKSPACE</p>
|
|
||||||
<h1>把项目,<br>放在手边。</h1>
|
|
||||||
<p class="sidebar-lead">Stash 正在整理当前目录下的静态前端应用,让每次预览都从一个清爽入口开始。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="overview-card" aria-label="工作区概览">
|
|
||||||
<div class="overview-label">项目概览</div>
|
|
||||||
<div class="project-total">
|
|
||||||
<strong data-project-count>{{ projects.len() }}</strong>
|
|
||||||
<span>个应用</span>
|
|
||||||
</div>
|
|
||||||
<div class="service-status">
|
|
||||||
<span class="status-dot"></span>
|
|
||||||
<span>本地服务运行中</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="sidebar-actions">
|
|
||||||
<button class="refresh-button js-only" id="refresh-projects" type="button" aria-label="重新扫描项目">
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M20 11a8.1 8.1 0 0 0-15.5-2M4 4v5h5"></path>
|
|
||||||
<path d="M4 13a8.1 8.1 0 0 0 15.5 2M20 20v-5h-5"></path>
|
|
||||||
</svg>
|
|
||||||
<span>重新扫描</span>
|
|
||||||
</button>
|
|
||||||
<p class="sidebar-hint">目录变化会自动同步,也可以手动重新扫描。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="sidebar-footer">
|
|
||||||
<span>STATIC HOSTING</span>
|
|
||||||
<span class="footer-ready"><i></i>READY</span>
|
|
||||||
</footer>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="content">
|
|
||||||
<header class="content-header">
|
|
||||||
<div class="content-heading">
|
|
||||||
<p class="eyebrow">PROJECT LIBRARY</p>
|
|
||||||
<h2>本地应用</h2>
|
|
||||||
<p>一个入口,快速抵达每个正在开发或预览的页面。</p>
|
|
||||||
</div>
|
|
||||||
<div class="content-meta">
|
|
||||||
<span class="live-pill" id="live-status"><i></i><span id="live-status-label">服务在线</span></span>
|
|
||||||
<span class="count-pill"><strong data-project-count>{{ projects.len() }}</strong> 个项目</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="site-grid" id="project-grid" aria-live="polite">
|
|
||||||
{% if projects.is_empty() %}
|
|
||||||
<div class="empty-state">
|
|
||||||
<span class="empty-visual" aria-hidden="true">
|
|
||||||
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
|
|
||||||
<path d="M8 13h8M8 16h5"></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<h3>这里还没有项目</h3>
|
|
||||||
<p>在托管根目录下创建子目录,并放入 <code>index.html</code>,刷新后即可在这里看到它。</p>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
{% for project in projects %}
|
|
||||||
<a class="site-item" href="{{ project.route_path }}" title="{{ project.name }}">
|
|
||||||
<div class="site-card-top">
|
|
||||||
<span class="site-avatar" aria-hidden="true">{{ project.initial() }}</span>
|
|
||||||
<span class="site-open" aria-hidden="true">
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M7 17 17 7M8 7h9v9"></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="site-info">
|
|
||||||
<h3 class="site-name">{{ project.name }}</h3>
|
|
||||||
<span class="site-desc">本地静态应用</span>
|
|
||||||
</div>
|
|
||||||
<div class="site-card-footer">
|
|
||||||
<span class="site-path">{{ project.route_path }}</span>
|
|
||||||
<span class="site-ready">READY</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
<p class="refresh-status" id="refresh-status" role="status" aria-live="polite"></p>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,499 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>服务与推荐列表完整布局</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: #f5f6f8;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
padding: 24px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 统一外框卡片(图二样式) */
|
|
||||||
.section-card {
|
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 顶部标题栏 */
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 蓝色指示条/图标 */
|
|
||||||
.title-bar {
|
|
||||||
width: 4px;
|
|
||||||
height: 16px;
|
|
||||||
background-color: #1677ff;
|
|
||||||
border-radius: 2px;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 标题容器,方便角标定位 */
|
|
||||||
.title-text-container {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1f2329;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 右上角红色角标 */
|
|
||||||
.badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -8px;
|
|
||||||
right: -18px;
|
|
||||||
background-color: #ff4d4f;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: bold;
|
|
||||||
height: 16px;
|
|
||||||
min-width: 16px;
|
|
||||||
padding: 0 4px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: 1;
|
|
||||||
box-shadow: 0 1px 3px rgba(255, 77, 79, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 卡片右上角更多按钮 */
|
|
||||||
.more-icon {
|
|
||||||
color: #8c8c8c;
|
|
||||||
font-size: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.more-icon:hover {
|
|
||||||
color: #595959;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------- 样式 1:常规应用卡片网格 ------------------- */
|
|
||||||
.app-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 14px;
|
|
||||||
background-color: #f9f9fb;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-item:hover {
|
|
||||||
background-color: #ffffff;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
|
||||||
border-color: #e6e6e6;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: #e8f3ff;
|
|
||||||
color: #1677ff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 16px;
|
|
||||||
margin-right: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #262626;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8c8c8c;
|
|
||||||
margin-top: 2px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------- 样式 2:图四 推荐电影(竖版海报卡片) ------------------- */
|
|
||||||
.movie-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(6, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.movie-grid { grid-template-columns: repeat(3, 1fr); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-item {
|
|
||||||
position: relative;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
aspect-ratio: 2 / 3; /* 经典电影海报高宽比 */
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-item:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 底部文字渐变蒙层 */
|
|
||||||
.movie-info {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 20px 8px 8px;
|
|
||||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.4) 60%, transparent 100%);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.3;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------- 样式 3:图五 现场报道/文章(横版网格卡片) ------------------- */
|
|
||||||
.article-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(6, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.article-grid { grid-template-columns: repeat(3, 1fr); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-item {
|
|
||||||
position: relative;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
aspect-ratio: 4 / 3; /* 横版比例 */
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-item:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 底部文字渐变蒙层 */
|
|
||||||
.article-info {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 16px 6px 6px;
|
|
||||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-title {
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.3;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
opacity: 0.95;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
|
|
||||||
<!-- 1. 本地服务 -->
|
|
||||||
<div class="section-card">
|
|
||||||
<div class="section-header">
|
|
||||||
<div class="section-title-wrapper">
|
|
||||||
<div class="title-bar"></div>
|
|
||||||
<div class="title-text-container">
|
|
||||||
<span class="section-title">本地服务</span>
|
|
||||||
<span class="badge">1</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="more-icon">•••</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="app-grid">
|
|
||||||
<div class="app-item">
|
|
||||||
<div class="app-icon">G</div>
|
|
||||||
<div class="app-info">
|
|
||||||
<div class="app-name">git_helper</div>
|
|
||||||
<div class="app-desc">/git_helper/</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 2. 常用推荐 -->
|
|
||||||
<div class="section-card">
|
|
||||||
<div class="section-header">
|
|
||||||
<div class="section-title-wrapper">
|
|
||||||
<div class="title-bar"></div>
|
|
||||||
<div class="title-text-container">
|
|
||||||
<span class="section-title">常用推荐</span>
|
|
||||||
<span class="badge">4</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="more-icon">•••</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="app-grid">
|
|
||||||
<div class="app-item">
|
|
||||||
<div class="app-icon">G</div>
|
|
||||||
<div class="app-info">
|
|
||||||
<div class="app-name">GitHub</div>
|
|
||||||
<div class="app-desc">代码托管与开源社区</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="app-item">
|
|
||||||
<div class="app-icon" style="background-color: #e6f7ff; color: #1890ff;">Z</div>
|
|
||||||
<div class="app-info">
|
|
||||||
<div class="app-name">知乎</div>
|
|
||||||
<div class="app-desc">深度内容问答社区</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="app-item">
|
|
||||||
<div class="app-icon" style="background-color: #fff0f6; color: #eb2f96;">B</div>
|
|
||||||
<div class="app-info">
|
|
||||||
<div class="app-name">哔哩哔哩</div>
|
|
||||||
<div class="app-desc">弹幕视频分享网站</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="app-item">
|
|
||||||
<div class="app-icon" style="background-color: #f6ffed; color: #52c41a;">V</div>
|
|
||||||
<div class="app-info">
|
|
||||||
<div class="app-name">V2EX</div>
|
|
||||||
<div class="app-desc">创意工作者讨论社区</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 3. 图四样式:推荐电影(竖版海报卡片) -->
|
|
||||||
<div class="section-card">
|
|
||||||
<div class="section-header">
|
|
||||||
<div class="section-title-wrapper">
|
|
||||||
<div class="title-bar" style="background-color: #722ed1;"></div>
|
|
||||||
<div class="title-text-container">
|
|
||||||
<span class="section-title">推荐电影</span>
|
|
||||||
<span class="badge">6</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="more-icon">•••</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="movie-grid">
|
|
||||||
<div class="movie-item">
|
|
||||||
<img class="movie-cover" src="https://picsum.photos/seed/m1/300/450" alt="海报">
|
|
||||||
<div class="movie-info">
|
|
||||||
<div class="movie-title">千与千寻 千と千尋の神隠し (2001)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="movie-item">
|
|
||||||
<img class="movie-cover" src="https://picsum.photos/seed/m2/300/450" alt="海报">
|
|
||||||
<div class="movie-info">
|
|
||||||
<div class="movie-title">美丽人生 La vita è bella (1997)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="movie-item">
|
|
||||||
<img class="movie-cover" src="https://picsum.photos/seed/m3/300/450" alt="海报">
|
|
||||||
<div class="movie-info">
|
|
||||||
<div class="movie-title">这个杀手不太冷 Léon (1994)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="movie-item">
|
|
||||||
<img class="movie-cover" src="https://picsum.photos/seed/m4/300/450" alt="海报">
|
|
||||||
<div class="movie-info">
|
|
||||||
<div class="movie-title">阿甘正传 Forrest Gump (1994)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="movie-item">
|
|
||||||
<img class="movie-cover" src="https://picsum.photos/seed/m5/300/450" alt="海报">
|
|
||||||
<div class="movie-info">
|
|
||||||
<div class="movie-title">霸王别姬 (1993)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="movie-item">
|
|
||||||
<img class="movie-cover" src="https://picsum.photos/seed/m6/300/450" alt="海报">
|
|
||||||
<div class="movie-info">
|
|
||||||
<div class="movie-title">肖申克的救赎 The Shawshank...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 4. 图五样式:CES 现场报道(横版图片两行网格) -->
|
|
||||||
<div class="section-card">
|
|
||||||
<div class="section-header">
|
|
||||||
<div class="section-title-wrapper">
|
|
||||||
<div class="title-bar" style="background-color: #fa8c16;"></div>
|
|
||||||
<div class="title-text-container">
|
|
||||||
<span class="section-title">CES 现场报道</span>
|
|
||||||
<span class="badge">12</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="more-icon">•••</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="article-grid">
|
|
||||||
<!-- 第一行 -->
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a1/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">可塑性极强的玻璃,在设计师手里有了更多可能</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a2/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">废旧共享单车如何变成微型共享书屋?</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a3/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">荧光灯管与混凝土块的组合,可以玩出多少花样?</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a4/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">混凝土做的模块化收纳框,还能改成搁架用</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a5/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">这个用拉伸材料做的头盔,还能帮你呼叫救护车</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a6/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">你如何选择字体,简单易读还是好看有趣?</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 第二行 -->
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a7/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">家里空间小?你需要几把可以悬挂在墙上的椅子</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a8/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">宜家厨房家具又被改造了,色彩是亮点</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a9/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">这款诞生50年的扶手椅,推出了特别版配色</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a10/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">让家和办公室界限变得模糊的一组家具</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a11/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">这个酒店设计奖,挖掘过去一年体验最好的酒店</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a12/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">一款可移动办公桌,挂墙上当作白板就能写写画画</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,920 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>STASH - 完整联动与版心对齐布局</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth; /* 启用原生平滑滚动 */
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: #f5f6f8;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
color: #333;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 统一版心最大宽度约束 */
|
|
||||||
.layout-container {
|
|
||||||
max-width: 1380px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------ 1. 顶部 Header 组件 (宽度对齐下方) ------------------ */
|
|
||||||
.header-outer {
|
|
||||||
background-color: #ffffff;
|
|
||||||
border-bottom: 1px solid #e8e8e8;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-inner {
|
|
||||||
height: 60px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-icon {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
background-color: #0f172a;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-text {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #0f172a;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-center-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #e6f4ff;
|
|
||||||
color: #1677ff;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-icon-btn {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-icon-btn:hover {
|
|
||||||
background-color: #f1f5f9;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 搜索栏区域(宽度与下方卡片版心保持对齐) */
|
|
||||||
.search-container {
|
|
||||||
padding: 16px 0 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 520px;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
width: 100%;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 16px 0 40px;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
background-color: #ffffff;
|
|
||||||
font-size: 13px;
|
|
||||||
outline: none;
|
|
||||||
transition: all 0.2s;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input:focus {
|
|
||||||
border-color: #1677ff;
|
|
||||||
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-icon {
|
|
||||||
position: absolute;
|
|
||||||
left: 14px;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 14px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------ 主体 Layout 布局 ------------------ */
|
|
||||||
.main-layout {
|
|
||||||
display: flex;
|
|
||||||
margin: 20px auto 40px;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------ 2. 左侧侧边栏组件 ------------------ */
|
|
||||||
.sidebar {
|
|
||||||
width: 260px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
position: sticky;
|
|
||||||
top: 76px;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-card {
|
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 18px;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 用户卡片 */
|
|
||||||
.user-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-wrapper {
|
|
||||||
position: relative;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #dbeafe;
|
|
||||||
color: #1d4ed8;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verified-badge {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
right: 2px;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background-color: #f59e0b;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 10px;
|
|
||||||
border: 2px solid #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1e293b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-role {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #64748b;
|
|
||||||
background-color: #f1f5f9;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin: 6px 0 10px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-ip {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 本地服务状态卡片 */
|
|
||||||
.status-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1e293b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background-color: #f1f5f9;
|
|
||||||
color: #64748b;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill::before {
|
|
||||||
content: '';
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-info-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-label {
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-value {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1e293b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 6px 0;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
background-color: #fff;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #475569;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:hover {
|
|
||||||
border-color: #cbd5e1;
|
|
||||||
background-color: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 侧边分类导航菜单(对应右侧主标题) */
|
|
||||||
.menu-card {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-group-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #94a3b8;
|
|
||||||
padding: 6px 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #334155;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
text-decoration: none;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item:hover {
|
|
||||||
background-color: #f8fafc;
|
|
||||||
color: #1677ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item.active {
|
|
||||||
background-color: #e6f4ff;
|
|
||||||
color: #1677ff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------ 3. 右侧主内容区域 ------------------ */
|
|
||||||
.content-area {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 大框样式(图二规范) */
|
|
||||||
.section-card {
|
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
scroll-margin-top: 80px; /* 留出顶部固定 Header 的高度避免遮挡 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-bar {
|
|
||||||
width: 4px;
|
|
||||||
height: 16px;
|
|
||||||
background-color: #1677ff;
|
|
||||||
border-radius: 2px;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-text-container {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1f2329;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 角标相对于标题右上角定位 */
|
|
||||||
.badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -8px;
|
|
||||||
right: -18px;
|
|
||||||
background-color: #ff4d4f;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: bold;
|
|
||||||
height: 16px;
|
|
||||||
min-width: 16px;
|
|
||||||
padding: 0 4px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: 1;
|
|
||||||
box-shadow: 0 1px 3px rgba(255, 77, 79, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.more-icon {
|
|
||||||
color: #8c8c8c;
|
|
||||||
font-size: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.more-icon:hover {
|
|
||||||
color: #595959;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 常规卡片网格 */
|
|
||||||
.app-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 14px;
|
|
||||||
background-color: #f9f9fb;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-item:hover {
|
|
||||||
background-color: #ffffff;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
|
||||||
border-color: #e6e6e6;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: #e8f3ff;
|
|
||||||
color: #1677ff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 16px;
|
|
||||||
margin-right: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #262626;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8c8c8c;
|
|
||||||
margin-top: 2px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 推荐电影网格 */
|
|
||||||
.movie-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(6, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.movie-grid { grid-template-columns: repeat(3, 1fr); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-item {
|
|
||||||
position: relative;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-item:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-info {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 20px 8px 8px;
|
|
||||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.4) 60%, transparent 100%);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.3;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CES 现场报道网格 */
|
|
||||||
.article-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(6, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.article-grid { grid-template-columns: repeat(3, 1fr); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-item {
|
|
||||||
position: relative;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
aspect-ratio: 4 / 3;
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-item:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-info {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 16px 6px 6px;
|
|
||||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-title {
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.3;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<!-- 1. 顶部 Header (固定顶部且与下方容器宽度一致) -->
|
|
||||||
<header class="header-outer">
|
|
||||||
<div class="layout-container header-inner">
|
|
||||||
<div class="header-left">
|
|
||||||
<div class="logo-icon">S</div>
|
|
||||||
<span class="logo-text">STASH</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-center-info">
|
|
||||||
<div class="info-item">
|
|
||||||
<span>共 1 个本地应用</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-tag">本地</span>
|
|
||||||
<span>git_helper</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-tag" style="background-color: #f0fdf4; color: #16a34a;">状态</span>
|
|
||||||
<span>等待服务连接</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="header-icon-btn" title="搜索">🔍</div>
|
|
||||||
<div class="header-icon-btn" title="个人账户">👤</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- 搜索框区域 -->
|
|
||||||
<div class="layout-container search-container">
|
|
||||||
<div class="search-box">
|
|
||||||
<span class="search-icon">🔍</span>
|
|
||||||
<input type="text" class="search-input" placeholder="搜索本地项目...">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 主体布局 -->
|
|
||||||
<div class="layout-container main-layout">
|
|
||||||
|
|
||||||
<!-- 2. 左侧侧边栏组件 -->
|
|
||||||
<aside class="sidebar">
|
|
||||||
|
|
||||||
<!-- 用户信息卡片 -->
|
|
||||||
<div class="sidebar-card user-card">
|
|
||||||
<div class="avatar-wrapper">
|
|
||||||
<div class="avatar">👤</div>
|
|
||||||
<div class="verified-badge">✓</div>
|
|
||||||
</div>
|
|
||||||
<div class="user-name">本地管理员</div>
|
|
||||||
<div class="user-role">管理员</div>
|
|
||||||
<div class="user-desc">本地静态站点托管与预览,所有项目都运行在这台机器上。</div>
|
|
||||||
<div class="user-ip">IP地址: 局域网</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 本地服务状态卡片 -->
|
|
||||||
<div class="sidebar-card">
|
|
||||||
<div class="status-header">
|
|
||||||
<span class="status-title">本地服务</span>
|
|
||||||
<span class="status-pill">未连接</span>
|
|
||||||
</div>
|
|
||||||
<div class="status-info-row">
|
|
||||||
<span class="status-label">项目数</span>
|
|
||||||
<span class="status-value">1</span>
|
|
||||||
</div>
|
|
||||||
<div class="status-info-row">
|
|
||||||
<span class="status-label">访问地址</span>
|
|
||||||
<span class="status-value">127.0.0.1:8080</span>
|
|
||||||
</div>
|
|
||||||
<div class="status-actions">
|
|
||||||
<button class="action-btn">🔄 重新扫描</button>
|
|
||||||
<button class="action-btn">🔗 查看接口</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分类导航菜单(与右侧主标题一致) -->
|
|
||||||
<div class="sidebar-card menu-card">
|
|
||||||
<div class="menu-group-title">快速导航</div>
|
|
||||||
|
|
||||||
<a href="#section-local" class="menu-item active" onclick="setActive(this)">
|
|
||||||
<span>本地服务</span>
|
|
||||||
<span style="font-size: 11px;">1</span>
|
|
||||||
</a>
|
|
||||||
<a href="#section-recommend" class="menu-item" onclick="setActive(this)">
|
|
||||||
<span>常用推荐</span>
|
|
||||||
<span style="font-size: 11px;">4</span>
|
|
||||||
</a>
|
|
||||||
<a href="#section-movie" class="menu-item" onclick="setActive(this)">
|
|
||||||
<span>推荐电影</span>
|
|
||||||
<span style="font-size: 11px;">6</span>
|
|
||||||
</a>
|
|
||||||
<a href="#section-ces" class="menu-item" onclick="setActive(this)">
|
|
||||||
<span>CES 现场报道</span>
|
|
||||||
<span style="font-size: 11px;">12</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- 3. 右侧主内容区域 -->
|
|
||||||
<main class="content-area">
|
|
||||||
|
|
||||||
<!-- 1. 本地服务 -->
|
|
||||||
<section id="section-local" class="section-card">
|
|
||||||
<div class="section-header">
|
|
||||||
<div class="section-title-wrapper">
|
|
||||||
<div class="title-bar"></div>
|
|
||||||
<div class="title-text-container">
|
|
||||||
<span class="section-title">本地服务</span>
|
|
||||||
<span class="badge">1</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="more-icon" title="更多操作">•••</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="app-grid">
|
|
||||||
<div class="app-item">
|
|
||||||
<div class="app-icon">G</div>
|
|
||||||
<div class="app-info">
|
|
||||||
<div class="app-name">git_helper</div>
|
|
||||||
<div class="app-desc">/git_helper/</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 2. 常用推荐 -->
|
|
||||||
<section id="section-recommend" class="section-card">
|
|
||||||
<div class="section-header">
|
|
||||||
<div class="section-title-wrapper">
|
|
||||||
<div class="title-bar"></div>
|
|
||||||
<div class="title-text-container">
|
|
||||||
<span class="section-title">常用推荐</span>
|
|
||||||
<span class="badge">4</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="more-icon" title="更多操作">•••</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="app-grid">
|
|
||||||
<div class="app-item">
|
|
||||||
<div class="app-icon">G</div>
|
|
||||||
<div class="app-info">
|
|
||||||
<div class="app-name">GitHub</div>
|
|
||||||
<div class="app-desc">代码托管与开源社区</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="app-item">
|
|
||||||
<div class="app-icon" style="background-color: #e6f7ff; color: #1890ff;">Z</div>
|
|
||||||
<div class="app-info">
|
|
||||||
<div class="app-name">知乎</div>
|
|
||||||
<div class="app-desc">深度内容问答社区</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="app-item">
|
|
||||||
<div class="app-icon" style="background-color: #fff0f6; color: #eb2f96;">B</div>
|
|
||||||
<div class="app-info">
|
|
||||||
<div class="app-name">哔哩哔哩</div>
|
|
||||||
<div class="app-desc">弹幕视频分享网站</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="app-item">
|
|
||||||
<div class="app-icon" style="background-color: #f6ffed; color: #52c41a;">V</div>
|
|
||||||
<div class="app-info">
|
|
||||||
<div class="app-name">V2EX</div>
|
|
||||||
<div class="app-desc">创意工作者讨论社区</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 3. 推荐电影 -->
|
|
||||||
<section id="section-movie" class="section-card">
|
|
||||||
<div class="section-header">
|
|
||||||
<div class="section-title-wrapper">
|
|
||||||
<div class="title-bar" style="background-color: #722ed1;"></div>
|
|
||||||
<div class="title-text-container">
|
|
||||||
<span class="section-title">推荐电影</span>
|
|
||||||
<span class="badge">6</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="more-icon" title="更多操作">•••</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="movie-grid">
|
|
||||||
<div class="movie-item">
|
|
||||||
<img class="movie-cover" src="https://picsum.photos/seed/m1/300/450" alt="海报">
|
|
||||||
<div class="movie-info">
|
|
||||||
<div class="movie-title">千与千寻 千と千尋の神隠し (2001)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="movie-item">
|
|
||||||
<img class="movie-cover" src="https://picsum.photos/seed/m2/300/450" alt="海报">
|
|
||||||
<div class="movie-info">
|
|
||||||
<div class="movie-title">美丽人生 La vita è bella (1997)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="movie-item">
|
|
||||||
<img class="movie-cover" src="https://picsum.photos/seed/m3/300/450" alt="海报">
|
|
||||||
<div class="movie-info">
|
|
||||||
<div class="movie-title">这个杀手不太冷 Léon (1994)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="movie-item">
|
|
||||||
<img class="movie-cover" src="https://picsum.photos/seed/m4/300/450" alt="海报">
|
|
||||||
<div class="movie-info">
|
|
||||||
<div class="movie-title">阿甘正传 Forrest Gump (1994)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="movie-item">
|
|
||||||
<img class="movie-cover" src="https://picsum.photos/seed/m5/300/450" alt="海报">
|
|
||||||
<div class="movie-info">
|
|
||||||
<div class="movie-title">霸王别姬 (1993)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="movie-item">
|
|
||||||
<img class="movie-cover" src="https://picsum.photos/seed/m6/300/450" alt="海报">
|
|
||||||
<div class="movie-info">
|
|
||||||
<div class="movie-title">肖申克的救赎 The Shawshank...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 4. CES 现场报道 -->
|
|
||||||
<section id="section-ces" class="section-card">
|
|
||||||
<div class="section-header">
|
|
||||||
<div class="section-title-wrapper">
|
|
||||||
<div class="title-bar" style="background-color: #fa8c16;"></div>
|
|
||||||
<div class="title-text-container">
|
|
||||||
<span class="section-title">CES 现场报道</span>
|
|
||||||
<span class="badge">12</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="more-icon" title="更多操作">•••</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="article-grid">
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a1/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">可塑性极强的玻璃,在设计师手里有了更多可能</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a2/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">废旧共享单车如何变成微型共享书屋?</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a3/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">荧光灯管与混凝土块的组合,可以玩出多少花样?</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a4/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">混凝土做的模块化收纳框,还能改成搁架用</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a5/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">这个用拉伸材料做的头盔,还能帮你呼叫救护车</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="article-item">
|
|
||||||
<img class="article-cover" src="https://picsum.photos/seed/a6/300/225" alt="文章图片">
|
|
||||||
<div class="article-info">
|
|
||||||
<div class="article-title">你如何选择字体,简单易读还是好看有趣?</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 切换左侧菜单激活高亮样式
|
|
||||||
function setActive(element) {
|
|
||||||
const items = document.querySelectorAll('.menu-item');
|
|
||||||
items.forEach(item => item.classList.remove('active'));
|
|
||||||
element.classList.add('active');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,741 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>导航主页</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-color: #f7f9fb;
|
|
||||||
--card-bg: #ffffff;
|
|
||||||
--primary-color: #3b82f6;
|
|
||||||
--text-main: #1e293b;
|
|
||||||
--text-sub: #64748b;
|
|
||||||
--border-color: #e2e8f0;
|
|
||||||
--radius-main: 12px;
|
|
||||||
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.03), 0 1px 3px rgba(0, 0, 0, 0.02);
|
|
||||||
--shadow-hover: 0 10px 20px rgba(0, 0, 0, 0.06), 0 4px 6px rgba(0, 0, 0, 0.03);
|
|
||||||
|
|
||||||
--sidebar-width: 240px;
|
|
||||||
--top-nav-height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 1. 锁死大窗口,禁止全局滚动 */
|
|
||||||
html, body {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 通用卡片容器 */
|
|
||||||
.card-box {
|
|
||||||
background-color: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-main);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 2. 顶部直角导航栏 */
|
|
||||||
.top-header-bar {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: var(--top-nav-height);
|
|
||||||
background-color: var(--card-bg);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
border-radius: 0 !important;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 24px;
|
|
||||||
z-index: 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
font-weight: 800;
|
|
||||||
font-size: 18px;
|
|
||||||
color: #0f172a;
|
|
||||||
text-decoration: none;
|
|
||||||
width: 180px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-icon {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
background-color: #000;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticker-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
max-width: 550px;
|
|
||||||
height: 36px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
margin: 0 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);
|
|
||||||
-webkit-mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticker-content {
|
|
||||||
display: flex;
|
|
||||||
gap: 40px;
|
|
||||||
white-space: nowrap;
|
|
||||||
animation: ticker 18s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticker-wrapper:hover .ticker-content {
|
|
||||||
animation-play-state: paused;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticker-item {
|
|
||||||
font-size: 13.5px;
|
|
||||||
color: var(--text-sub);
|
|
||||||
text-decoration: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticker-tag {
|
|
||||||
background-color: #eff6ff;
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ticker {
|
|
||||||
0% { transform: translateX(0); }
|
|
||||||
100% { transform: translateX(-50%); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #475569;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:hover {
|
|
||||||
background-color: #f1f5f9;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn svg {
|
|
||||||
width: 19px;
|
|
||||||
height: 19px;
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke: currentColor;
|
|
||||||
fill: none;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 搜索框:全局固定且全屏水平居中 ---------------- */
|
|
||||||
.search-row {
|
|
||||||
position: fixed;
|
|
||||||
top: 76px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 100;
|
|
||||||
width: 480px;
|
|
||||||
height: 44px;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engine-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-sub);
|
|
||||||
cursor: pointer;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engine-btn:hover {
|
|
||||||
background-color: #f1f5f9;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.engine-btn svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke: currentColor;
|
|
||||||
fill: none;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
width: 100%;
|
|
||||||
height: 36px;
|
|
||||||
font-size: 13.5px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
outline: none;
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 3. 双列核心网格 ---------------- */
|
|
||||||
.layout-grid {
|
|
||||||
margin-top: var(--top-nav-height);
|
|
||||||
height: calc(100vh - var(--top-nav-height));
|
|
||||||
padding: 36px 24px 0 20px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: var(--sidebar-width) 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-column {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 68px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-column {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
padding-top: 52px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-card {
|
|
||||||
height: 52px;
|
|
||||||
padding: 0 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: #eff6ff;
|
|
||||||
color: var(--primary-color);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name {
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-main);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-desc {
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: var(--text-sub);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-card {
|
|
||||||
padding: 8px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-group {
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item-wrapper {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 9px 12px;
|
|
||||||
color: var(--text-sub);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background-color: #eff6ff;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow-icon {
|
|
||||||
font-size: 13px;
|
|
||||||
transition: transform 0.3s;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-family: monospace;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item-wrapper.open > .nav-link .arrow-icon {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-menu {
|
|
||||||
list-style: none;
|
|
||||||
padding-left: 12px;
|
|
||||||
max-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: max-height 0.3s cubic-bezier(0, 1, 0, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item-wrapper.open .sub-menu {
|
|
||||||
max-height: 500px;
|
|
||||||
transition: max-height 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-menu .nav-link {
|
|
||||||
padding: 7px 12.5px;
|
|
||||||
font-size: 12.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-card {
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 9px 12px;
|
|
||||||
color: var(--text-sub);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn:hover {
|
|
||||||
background-color: #eff6ff;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 4. 右侧独立滚动区域 */
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 4px;
|
|
||||||
padding-top: 16px;
|
|
||||||
padding-bottom: 40px;
|
|
||||||
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0px, black 22px, black 100%);
|
|
||||||
mask-image: linear-gradient(to bottom, transparent 0px, black 22px, black 100%);
|
|
||||||
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content::-webkit-scrollbar {
|
|
||||||
display: none !important;
|
|
||||||
width: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-group {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-main);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title::before {
|
|
||||||
content: "";
|
|
||||||
display: inline-block;
|
|
||||||
width: 4px;
|
|
||||||
height: 15px;
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
margin-right: 8px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background-color: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: #f1f5f9;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--primary-color);
|
|
||||||
margin-right: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-sub);
|
|
||||||
margin-top: 2px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-to-top {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 30px;
|
|
||||||
right: 30px;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
background-color: #e2e8f0;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: #1e293b;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
text-decoration: none;
|
|
||||||
z-index: 90;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-to-top:hover {
|
|
||||||
background-color: #cbd5e1;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-to-top svg {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 2.5;
|
|
||||||
fill: none;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<!-- 顶栏 -->
|
|
||||||
<header class="top-header-bar">
|
|
||||||
<a href="#" class="site-logo">
|
|
||||||
<div class="logo-icon">S</div>
|
|
||||||
<span>SUXING</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="ticker-wrapper">
|
|
||||||
<div class="ticker-content">
|
|
||||||
<a href="#" class="ticker-item"><span class="ticker-tag">公告</span> 欢迎使用最新版个人导航站!</a>
|
|
||||||
<a href="#" class="ticker-item"><span class="ticker-tag">更新</span> WordPress 主题升级至 v3.2 版本</a>
|
|
||||||
<a href="#" class="ticker-item"><span class="ticker-tag">推荐</span> 常用前端工具箱已更新资源</a>
|
|
||||||
<a href="#" class="ticker-item"><span class="ticker-tag">公告</span> 欢迎使用最新版个人导航站!</a>
|
|
||||||
<a href="#" class="ticker-item"><span class="ticker-tag">更新</span> WordPress 主题升级至 v3.2 版本</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="top-actions">
|
|
||||||
<button class="action-btn" title="暗黑模式">
|
|
||||||
<svg viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn" title="快捷方式">
|
|
||||||
<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn" title="全局搜索">
|
|
||||||
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- 搜索框独立置于全屏水平居中位置 -->
|
|
||||||
<div class="search-row">
|
|
||||||
<div class="search-bar card-box">
|
|
||||||
<button class="engine-btn" title="切换搜索引擎">
|
|
||||||
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
|
||||||
</button>
|
|
||||||
<input type="text" class="search-input" placeholder="输入关键字搜索全网资源...">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 主布局网格 -->
|
|
||||||
<div class="layout-grid">
|
|
||||||
|
|
||||||
<!-- 左侧固定边栏 -->
|
|
||||||
<div class="left-column">
|
|
||||||
<div class="user-card card-box">
|
|
||||||
<div class="avatar">S</div>
|
|
||||||
<div>
|
|
||||||
<div class="user-name">Suxing Admin</div>
|
|
||||||
<div class="user-desc">个人导航站</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-card card-box">
|
|
||||||
<ul class="nav-group">
|
|
||||||
<li class="nav-item-wrapper">
|
|
||||||
<a href="#common" class="nav-link active">
|
|
||||||
<span>常用推荐</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item-wrapper">
|
|
||||||
<div class="nav-link has-sub">
|
|
||||||
<span>素材资源</span>
|
|
||||||
<span class="arrow-icon">›</span>
|
|
||||||
</div>
|
|
||||||
<ul class="sub-menu">
|
|
||||||
<li><a href="#design" class="nav-link"><span>UI 设计资源</span></a></li>
|
|
||||||
<li><a href="#icon" class="nav-link"><span>矢量图标</span></a></li>
|
|
||||||
<li><a href="#photo" class="nav-link"><span>免费壁纸</span></a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item-wrapper">
|
|
||||||
<div class="nav-link has-sub">
|
|
||||||
<span>开发工具</span>
|
|
||||||
<span class="arrow-icon">›</span>
|
|
||||||
</div>
|
|
||||||
<ul class="sub-menu">
|
|
||||||
<li><a href="#dev" class="nav-link"><span>前端调试工具</span></a></li>
|
|
||||||
<li><a href="#code" class="nav-link"><span>在线编辑器</span></a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="submit-card card-box">
|
|
||||||
<a href="#submit" class="submit-btn">提交网站</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧区域 -->
|
|
||||||
<div class="right-column">
|
|
||||||
<!-- 右侧独立滚动内容 -->
|
|
||||||
<main class="main-content" id="scrollContainer">
|
|
||||||
<div id="common" class="section-group">
|
|
||||||
<div class="section-title">常用推荐</div>
|
|
||||||
<div class="grid">
|
|
||||||
<a href="#" class="card">
|
|
||||||
<div class="card-icon">G</div>
|
|
||||||
<div>
|
|
||||||
<div class="card-title">GitHub</div>
|
|
||||||
<div class="card-desc">代码托管与开源社区</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="card">
|
|
||||||
<div class="card-icon">Z</div>
|
|
||||||
<div>
|
|
||||||
<div class="card-title">知乎</div>
|
|
||||||
<div class="card-desc">深度内容问答社区</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="card">
|
|
||||||
<div class="card-icon">B</div>
|
|
||||||
<div>
|
|
||||||
<div class="card-title">哔哩哔哩</div>
|
|
||||||
<div class="card-desc">弹幕视频分享网站</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="card">
|
|
||||||
<div class="card-icon">V</div>
|
|
||||||
<div>
|
|
||||||
<div class="card-title">V2EX</div>
|
|
||||||
<div class="card-desc">创意工作者讨论社区</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="dev" class="section-group">
|
|
||||||
<div class="section-title">前端调试工具 (开发工具)</div>
|
|
||||||
<div class="grid">
|
|
||||||
<a href="#" class="card">
|
|
||||||
<div class="card-icon">S</div>
|
|
||||||
<div>
|
|
||||||
<div class="card-title">Stack Overflow</div>
|
|
||||||
<div class="card-desc">程序员技术问答</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="card">
|
|
||||||
<div class="card-icon">J</div>
|
|
||||||
<div>
|
|
||||||
<div class="card-title">掘金</div>
|
|
||||||
<div class="card-desc">开发者技术社区</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="code" class="section-group">
|
|
||||||
<div class="section-title">在线编辑器</div>
|
|
||||||
<div class="grid">
|
|
||||||
<a href="#" class="card">
|
|
||||||
<div class="card-icon">C</div>
|
|
||||||
<div>
|
|
||||||
<div class="card-title">CodePen</div>
|
|
||||||
<div class="card-desc">前端在线代码演练场</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="design" class="section-group">
|
|
||||||
<div class="section-title">UI 设计资源</div>
|
|
||||||
<div class="grid">
|
|
||||||
<a href="#" class="card">
|
|
||||||
<div class="card-icon">D</div>
|
|
||||||
<div>
|
|
||||||
<div class="card-title">Dribbble</div>
|
|
||||||
<div class="card-desc">全球设计作品分享社区</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 回到顶部按钮 -->
|
|
||||||
<a href="javascript:void(0)" class="back-to-top" id="backToTopBtn" title="回到顶部">
|
|
||||||
<svg viewBox="0 0 24 24">
|
|
||||||
<line x1="5" y1="4" x2="19" y2="4"></line>
|
|
||||||
<path d="M12 20V8M6 14l6-6 6 6"></path>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.querySelectorAll('.has-sub').forEach(trigger => {
|
|
||||||
trigger.addEventListener('click', () => {
|
|
||||||
trigger.closest('.nav-item-wrapper').classList.toggle('open');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const scrollContainer = document.getElementById('scrollContainer');
|
|
||||||
const backToTopBtn = document.getElementById('backToTopBtn');
|
|
||||||
|
|
||||||
backToTopBtn.addEventListener('click', () => {
|
|
||||||
scrollContainer.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('a.nav-link[href^="#"]').forEach(link => {
|
|
||||||
link.addEventListener('click', function(e) {
|
|
||||||
const targetId = this.getAttribute('href');
|
|
||||||
if (targetId === '#') return;
|
|
||||||
|
|
||||||
const targetElement = document.querySelector(targetId);
|
|
||||||
if (targetElement) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const containerTop = scrollContainer.getBoundingClientRect().top;
|
|
||||||
const elementTop = targetElement.getBoundingClientRect().top;
|
|
||||||
const currentScroll = scrollContainer.scrollTop;
|
|
||||||
const offsetPosition = currentScroll + (elementTop - containerTop) - 24;
|
|
||||||
|
|
||||||
scrollContainer.scrollTo({
|
|
||||||
top: offsetPosition,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.nav-link').forEach(el => el.classList.remove('active'));
|
|
||||||
this.classList.add('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user