diff --git a/.gitignore b/.gitignore
index 3a8cabc..24f0845 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,24 @@
+# ---------- Rust ----------
/target
-.idea
+
+# ---------- 前端 ----------
+# 依赖与构建产物:dist 由 npm run build 生成,再被 build.rs 内嵌进二进制
+node_modules/
+stash-web/dist/
+stash-web/dist-ssr/
+*.tsbuildinfo
+.vite/
+
+# ---------- 生成的代码快照 ----------
+repomix-output.xml
+
+# ---------- IDE / 编辑器 ----------
+/.idea
+*.iml
+.vscode/
+
+# ---------- 日志与系统文件 ----------
+*.log
+.DS_Store
+Thumbs.db
+*.local
diff --git a/ALRIGHT-DESIGN.md b/ALRIGHT-DESIGN.md
new file mode 100644
index 0000000..a10fd0e
--- /dev/null
+++ b/ALRIGHT-DESIGN.md
@@ -0,0 +1,325 @@
+# Alright 主题设计规范(Stash 前端)
+
+本文件记录 Stash 控制台在视觉上对齐 [Alright 主题](https://alright.demo.nicetheme.xyz/) 的各项标准,
+用于后续新增组件、调整样式时保持一致。
+
+- 参考站点:
+- 生效范围:`stash-web/`(React 控制台)
+- 令牌定义处:[`stash-web/src/index.css`](stash-web/src/index.css)
+- 组件样式:[`stash-web/src/App.css`](stash-web/src/App.css)
+
+> 所有色值、字号、圆角均取自主题的 `:root` 设计变量,不是肉眼取色。
+
+---
+
+## 1. 设计令牌
+
+### 1.1 灰阶
+
+| 令牌 | 值 | 用途 |
+| --- | --- | --- |
+| `--gray-1` | `#f7f8fa` | 页面背景、浅底块 |
+| `--gray-2` | `#f6f6f6` | 边框、次级浅底 |
+| `--gray-3` | `#eeeeee` | hover 底色 |
+| `--gray-4` | `#c9cdd4` | 分隔线、禁用态 |
+| `--gray-5` | `#86909c` | 说明文字 |
+| `--gray-6` | `#4e5969` | 次级文字 |
+| `--gray-7` | `#2c343c` | 强调文字 |
+| `--gray-8` | `#1d2129` | 主文字 |
+
+### 1.2 语义色
+
+| 令牌 | 值 | 用途 |
+| --- | --- | --- |
+| `--color-primary` | `#3858f6` | 主色:链接、选中态、色条、图标 |
+| `--color-primary` 10% | `rgba(56, 88, 246, 0.1)` | 主色浅底(`--primary-soft`) |
+| `--color-danger` | `#ff3e3e` | 数量角标、错误提示 |
+| `--color-warning` | `#ffba3a` | 橙色强调(CES 色条) |
+| `--color-success` | `#4abd86` | 在线状态 |
+| 渐变红 | `linear-gradient(to right, #ff6034, #ff3e3e)` | 热门标记 |
+| HOT 渐变 | `#ff7b51 → #ff0e0e` | HOT 图形徽标 |
+| 认证渐变 | `#ffb74a → #fd900f` | 头像认证角标 |
+
+### 1.3 圆角
+
+| 令牌 | 值 | 用途 |
+| --- | --- | --- |
+| `--radius-xs` | `4px` | 小标签、徽标 |
+| `--radius-sm` | `6px` | 小组件 |
+| `--radius-main`(md) | `12px` | 卡片、面板 |
+| `--radius-lg` | `24px` | 大容器 |
+| `max` | `999px` | 胶囊、圆形 |
+
+### 1.4 阴影
+
+主题的阴影非常克制,只有两层:
+
+```css
+--shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02); /* 常态 */
+--shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06); /* 悬停 */
+```
+
+**注意**:应用条目、作者列表这类「行」不应该有阴影或描边。
+
+### 1.5 字号阶梯
+
+| 令牌 | 字号 | 行高 | 用途 |
+| --- | --- | --- | --- |
+| `xs` | 10px | 14px | 角标 |
+| `sm` | 12px | 16px | 描述、元信息、HOT 旁注 |
+| `md` | 14px | 20px | 正文、按钮、导航 |
+| `lg` | 16px | 24px | 分区标题、条目标题 |
+| `xl` | 24px | — | 详情页大标题 |
+
+字重:主题的「加粗」是 `500`,不是 700。
+
+### 1.6 动效时长
+
+| 令牌 | 值 | 用途 |
+| --- | --- | --- |
+| `base` | 0.3s | 展开/收起、淡入淡出 |
+| `fast` | 0.2s | 颜色、背景变化 |
+| enter | `ease-out` | 出现 |
+| leave | `ease-in` | 消失 |
+
+---
+
+## 2. 字体
+
+| 角色 | 字体 | 说明 |
+| --- | --- | --- |
+| 正文 | **LXGW WenKai(霞鹜文楷)** | 与演示站同一份 webfont,`cdn.staticfile.net` |
+| 回退 | `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif` | 字体加载失败时 |
+| 数字(主题) | `Outfit-Medium` | 仅主题的 `font-price` / `font-number` 使用,Stash 未引入 |
+| 等宽 | `ui-monospace, Consolas, monospace` | 路由地址等路径文本 |
+
+引入位置:[`stash-web/index.html`](stash-web/index.html) 的 ``,
+CSS 变量 `--font-sans` / `--font-mono` 定义在 `index.css`。
+
+---
+
+## 3. 图标
+
+### 3.1 图标字体
+
+主题使用自建 iconfont(线性风格,Project id 4809463)。
+Stash 自托管了一份 **只含所需字形** 的子集:
+
+- 字体文件:[`stash-web/src/assets/iconfont.woff2`](stash-web/src/assets/iconfont.woff2)
+- 样式:[`stash-web/src/iconfont.css`](stash-web/src/iconfont.css)
+- 组件封装:[`stash-web/src/components/common/Icon.tsx`](stash-web/src/components/common/Icon.tsx)
+
+| 用途 | 主题字形 | 码点 | Stash 类名 |
+| --- | --- | --- | --- |
+| 搜索 | `search_3_line` | `\e942` | `.icon-search` |
+| 用户 / 登录 | `user_3_line` | `\e966` | `.icon-user` |
+| 重新扫描 | `refresh_3_line` | `\e93d` | `.icon-refresh` |
+| 查看接口 | `link_line` | `\ea37` | `.icon-link` |
+| 跳转 | `arrow_right_up_line` | `\e8be` | `.icon-external` |
+| 浏览量 | `eye_line` | `\e8fd` | `.icon-eye` |
+| 点赞 | `thumb_up_2_line` | `\e957` | `.icon-thumb-up` |
+| 分享 | `share_3_line` | `\e946` | `.icon-share` |
+| 清空搜索 | `close_line` | `\e8e4` | `.icon-close` |
+| 分区更多 | `more_3_line` | `\e91f` | `.icon-more` |
+| 回到顶部 | `arrow_to_up_line` | `\e8c1` | `.icon-arrow-up` |
+| 返回 | `arrow_left_line` | `\e8b5` | `.icon-arrow-left` |
+| 导航展开 | `arrow_down_line` | `\e8b7` | `.icon-arrow-down`(未使用,见下) |
+
+用法:``,尺寸用 `font-size`、颜色用 `color` 控制。
+
+### 3.2 CSS 绘制的箭头
+
+主题的**导航折叠箭头不是图标字体**,而是一个 6×6 的描边方块旋转而成:
+
+```css
+.nav-link.has-children::after {
+ content: '';
+ width: 6px; height: 6px;
+ border-left: 1px solid currentColor;
+ border-bottom: 1px solid currentColor;
+ transform: rotate(315deg); /* 展开时 135deg */
+ opacity: 0.4;
+}
+```
+
+---
+
+## 4. 布局
+
+### 4.1 整体结构
+
+```
+┌──────────────────────────────────────────────┐
+│ SiteHeader(固定 60px,白底、底部 1px 分隔) │
+├──────────────────────────────────────────────┤
+│ 搜索框(固定,480×44,居中) │
+│ ┌──────────┬─────────────────────────────┐ │
+│ │ Sidebar │ 内容区(内部滚动) │ │
+│ │ 240px │ 分区卡片 / 明细页 │ │
+│ └──────────┴─────────────────────────────┘ │
+└──────────────────────────────────────────────┘
+```
+
+| 项 | 值 | 说明 |
+| --- | --- | --- |
+| 版心宽度 | `1380px` | `--layout-max-width`,超宽屏居中 |
+| 顶栏高度 | `60px` | `--top-nav-height` |
+| 侧栏宽度 | `240px` | `--sidebar-width`(主题为 260px,Stash 收窄) |
+| 栅格间距 | `20px` | 侧栏与内容区之间 |
+| 内容区顶部让位 | `52px` | `--content-top-offset`,给悬浮搜索框 |
+| 搜索框 | `480 × 44`,距顶 `76px` | 相对整页居中 |
+
+### 4.2 页面背景
+
+主题在 body 上叠了一层固定背景图:
+
+```css
+.site-background {
+ position: fixed; inset: 0 auto auto 0;
+ width: 100%; height: 65vh;
+ background: url('./assets/site-bg.jpg') top center / cover no-repeat;
+ pointer-events: none;
+}
+.site-background::after { /* 底部 80% 渐隐到页面底色 */
+ content: ''; position: absolute; inset: auto 0 0 0; height: 80%;
+ background-image: linear-gradient(0deg, var(--bg-color) 0%, transparent 100%);
+}
+```
+
+滚动时背景不动,形成轻微视差。
+
+---
+
+## 5. 组件规范
+
+### 5.1 分区卡片(首页每一块)
+
+| 属性 | 值 |
+| --- | --- |
+| 容器 | 白底、`1px solid var(--border-color)`、圆角 12、内边距 20、`var(--shadow-card)` |
+| 标题色条 | `4 × 16px`,圆角 2,分类色(本地/推荐 `#3858f6`、电影 `#ff6034`、CES `#ffba3a`) |
+| 标题 | 16px / 500 |
+| 数量角标 | 红色 `#ff3e3e`、10px 字号、圆角 8、贴在标题右上角(`top:-8px; right:-18px`) |
+| 更多按钮 | `.icon-more`,16px,右对齐 |
+
+### 5.2 应用条目(对齐主题「推荐作者」)
+
+对应标记:
+
+```html
+
+
+
![]()
+
+
+
+
+```
+
+| 元素 | 规格 |
+| --- | --- |
+| 行 | `display:flex; gap:15px`,**无底色、无描边、无阴影** |
+| 头像 | 48×48,`border-radius:100%`,图片 `object-fit:cover` |
+| 认证角标 | 10×10,绝对定位右下角,内联 `badge_5` 的 SVG |
+| 名字 | 16px / 行高 24px / 500 |
+| HOT 徽标 | 24×16 的**图片**(内联主题 `--hot-icon` SVG),不是文字 |
+| 描述 | 12px / 行高 16px / `--text-muted`,单行省略 |
+| 悬停 | 名字变主色,不加阴影 |
+
+> HOT 与认证角标只出现在 `hot: true` 的条目上,不是每个都有。
+
+### 5.3 海报 / 图文
+
+| 网格 | 列数 | 间距 | 比例 |
+| --- | --- | --- | --- |
+| 应用 | 4(≤1100px 变 2) | 12px | — |
+| 电影海报 | 6(≤1100px 变 3) | 12px | 2:3 |
+| 首页图文 | 4(≤1100px 变 2) | 12px | 4:3 |
+| 明细页图文白卡 | 4(≤1100px 变 2) | 16px | 封面 16:10 |
+
+封面下方统一用「黑色渐变 → 透明」遮罩压住白色标题:
+
+```css
+background: linear-gradient(to top, rgba(0,0,0,.85) 0%, rgba(0,0,0,.4) 60%, transparent 100%);
+```
+
+### 5.4 侧栏
+
+| 模块 | 规格 |
+| --- | --- |
+| 用户卡 | 64px 圆形头像 + 认证角标、14px 名字、11–12px 角色/简介/属地 |
+| 本地服务面板 | 标题 14px、状态胶囊、两行统计(`dt` 12px / `dd` 12px 等宽)、两个操作按钮 |
+| 分类导航 | 行高 `9px 12px` 内边距、圆角 8、14px;子项 12px、缩进 12px |
+
+### 5.5 返回顶部
+
+对应主题 `.scroll-toolbar .totop-button`:
+
+```css
+.back-to-top {
+ position: fixed; top: 50%; right: 30px;
+ width: 42px; height: 42px;
+ border-radius: 50%;
+ background-color: var(--border-color);
+ color: var(--text-sub);
+ opacity: 0; visibility: hidden; /* 默认隐藏 */
+ transform: translateY(-50%) translateY(15px);
+ transition: opacity .3s, visibility .3s, transform .3s;
+}
+.back-to-top.is-visible {
+ opacity: 1; visibility: visible;
+ transform: translateY(-50%);
+}
+```
+
+滚动超过 4px 才出现,回到顶部后自动隐藏;窄屏改为 `right:15px; bottom:30%`。
+
+---
+
+## 6. 动效
+
+| 效果 | 实现 |
+| --- | --- |
+| 视图切换(首页 ↔ 明细 ↔ 条目) | `view-in`:0.22s 淡入 + 上移 6px |
+| 侧栏子菜单展开 | `grid-template-rows: 0fr → 1fr`,0.28s,全程 `overflow:hidden`(不产生滚动条) |
+| 导航箭头 | `rotate(315deg) → 135deg`,0.3s |
+| 分区卡片 / 海报 / 图文悬停 | `translateY(-2px)`,0.2s |
+| 应用条目悬停 | 仅名字改为主色 |
+| 回到顶部 | 淡入 + 上移 15px,0.3s |
+| 刷新按钮加载 | `spin 0.9s linear infinite` |
+| 内容区顶部遮罩 | 滚动后 0.2s 淡入 |
+
+---
+
+## 7. 与演示站的有意差异
+
+| 项 | 主题 | Stash | 原因 |
+| --- | --- | --- | --- |
+| 侧栏宽度 | 260px | 240px | 控制台信息密度略高 |
+| 深色模式 | 支持 | 未实现 | 当前只做浅色主题 |
+| 数字字体 | Outfit-Medium | 未引入 | 少一个外部字体请求 |
+| 图标集 | 全量 446 个 | 13 个子集 | 控制体积(woff2 52KB) |
+| 导航箭头位置 | 紧贴文字右侧 | 行末右对齐 | 视觉上更整齐 |
+
+---
+
+## 8. 扩展指引
+
+| 需求 | 改动位置 |
+| --- | --- |
+| 改主色 / 灰阶 / 圆角 | [`stash-web/src/index.css`](stash-web/src/index.css) 的 `:root` |
+| 新增图标 | 把 codepoint 加进 [`stash-web/src/iconfont.css`](stash-web/src/iconfont.css),在 `Icon.tsx` 包一个组件 |
+| 新增分类 | [`stash-web/src/data/catalog.ts`](stash-web/src/data/catalog.ts) 加一条 `Category`,首页与明细页自动适配 |
+| 新增卡片类型 | 在 `data/types.ts` 增加 kind,`HomeView` / `DetailView` 各加一个分支 |
+| 调整分区样式 | [`stash-web/src/App.css`](stash-web/src/App.css) 的「分区卡片」段落 |
+| 换背景图 | 替换 `stash-web/src/assets/site-bg.jpg` |
+
+---
+
+## 9. 变更记录
+
+- 2026-10-22:按本规范完成整体换肤(配色、字体、图标、背景、应用条目、返回顶部),
+ 并将侧栏导航箭头改为与主题一致的 CSS 箭头。
diff --git a/README.md b/README.md
index 123903d..bbc36ae 100644
--- a/README.md
+++ b/README.md
@@ -1,151 +1,112 @@
# Stash
-Stash 是一个基于 Rust、Tokio 和 Axum 实现的本地静态页面托管工具。它既可以直接托管一个静态站点,也可以扫描托管目录下的多个前端项目,并通过内置导航页统一访问。
+Stash 是一个用 Rust 编写的本地静态页面托管工具。编译后的 React 控制台会被内嵌进二进制,
+所以发布物只有一个 `stash` 可执行文件,不需要额外部署前端静态资源。
-## 核心功能
-
-### 单站点托管
-
-当托管根目录中存在 `index.html` 时,访问 `/` 会直接返回该页面。页面依赖的 JavaScript、CSS、图片、字体等资源继续通过静态文件服务加载,因此可以直接运行常见的纯静态前端项目。
-
-### 多项目导航
-
-当根目录没有 `index.html` 时,Stash 会扫描根目录下的一级子目录,并将包含 `index.html` 或 `index.htm` 的目录识别为前端项目。
-
-- 自动排除以 `.` 开头的隐藏目录。
-- 项目按名称进行不区分大小写的升序排列。
-- 每个项目生成一个卡片链接,地址格式为 `/<项目目录名>/`。
-- 项目名称首字符会作为圆形标记显示。
-- 没有识别到项目时显示空状态提示。
-- 导航页采用 4 列、2 列、1 列的响应式布局,分别适配桌面、平板和手机。
-- 项目和目录列表在每次请求时重新扫描,新增项目通常无需重启服务。
-- 文件监听会在项目快照发生变化后主动通知已打开的导航页,无需手动刷新。
-
-### 静态文件服务
-
-Stash 使用 `tower-http` 的 `ServeDir` 托管根目录内容:
-
-- 自动为目录追加 `index.html`。
-- 根据文件扩展名返回对应的 MIME 类型。
-- 未找到的路径会返回内置的中文 404 页面,并显示请求路径。
-- 每次 HTTP 请求都会经过 Trace 层,可通过调整日志级别观察请求日志。
-- 内置控制台资源通过保留路径 `/__stash__/` 提供,避免与用户托管的项目文件冲突。
-
-### 内置控制台
-
-- 使用 Askama 在编译期渲染项目导航页和 404 页面。
-- 页面 HTML、CSS 和 JavaScript 分别维护,不再使用字符串替换拼接卡片。
-- 项目名称和请求路径由模板引擎自动进行 HTML 转义。
-- 项目目录名会进行 URL 路径段编码,再生成项目访问地址。
-- 少量 JavaScript 通过 SSE 订阅项目变化,并在不刷新整个页面的情况下更新卡片;脚本不可用时,服务端渲染的导航和手动 API 仍可正常使用。
-
-### 同源 API
-
-服务提供两个只读 JSON 接口和一个 SSE 推送接口,可供导航页或同源前端调用。
-
-### CORS
-
-默认允许任意来源、任意请求方法和任意请求头访问服务,适合本地联调。通过 `--no-cors` 可以关闭 CORS 标头注入。
-
-### 服务管理
-
-- 默认监听 `127.0.0.1:8080`。
-- 支持自定义绑定地址、端口和托管目录。
-- 默认托管目录 `./public` 不存在时会自动创建。
-- 用户显式指定的托管目录不存在时,会输出错误并退出。
-- 端口绑定失败、目录无效等错误会以清晰的错误信息返回。
-- 支持分别通过 `Ctrl+C` 以及 Unix 系统的 `SIGTERM` 触发优雅停机。
-
-### 可复用核心库
-
-`stash-core` 同时作为库导出 `Config`、`StashError`、`start`、`ServerHandle`、`scan_projects` 和 `AppProject`,既可由 `stash-cli` 使用,也可以被其他 Rust 程序嵌入或复用。
+它能做两件事:托管目录下的多个静态前端项目、给控制台提供同源 JSON / SSE 接口。
## 快速开始
-### 环境要求
+```bash
+# 1. 先构建前端(dist 会被内嵌,缺了它 cargo build 会直接失败)
+cd stash-web
+npm install
+npm run build
+cd ..
-- Rust 工具链,需支持 Rust 2024 Edition。
-- Cargo。
+# 2. 编译并运行
+cargo run # 托管 ./public,监听 127.0.0.1:8080
-### 启动示例
+# 自定义目录 / 端口 / 对外暴露
+cargo run -- --root ./sites --port 3000
+cargo run -- --root ./sites --host 0.0.0.0 --port 3000
+```
+
+启动后打开终端里打印的地址即可,例如 `http://127.0.0.1:8080/`。
+默认托管目录 `./public` 不存在时会自动创建。
+
+环境要求:
+
+- Rust 工具链(2024 Edition)
+- Node.js 24,推荐用 [Volta](https://volta.sh/) 管理;`stash-web/package.json` 里已用 `volta` 字段固定版本
+
+## 工作方式
+
+请求按下面的顺序命中:
+
+| 路径 | 处理方式 |
+| --- | --- |
+| `/api/*` | JSON / SSE 接口 |
+| `/`、`/index.html` | 内嵌前端的 `index.html`(`no-cache`) |
+| `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) |
+| `/favicon.svg` | 内嵌前端根目录文件 |
+| 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` |
+| 仍未命中 | 内置 404 页面(显示请求路径) |
+
+托管目录下的每个一级子目录,只要包含 `index.html` 或 `index.htm`,就会被识别为一个项目,
+通过 `/<目录名>/` 访问。目录名会做 URL 路径段编码。
+
+## 目录结构
+
+```text
+.
+├── Cargo.toml # 单 crate(lib + bin),依赖清单
+├── Cargo.lock
+├── build.rs # 把 stash-web/dist 同步到 $OUT_DIR/web
+├── src/ # Rust 后端
+│ ├── main.rs # CLI 参数、启动横幅、停机信号
+│ ├── lib.rs # 库导出
+│ ├── config.rs # 服务配置
+│ ├── error.rs # 错误类型与 JSON 错误响应
+│ ├── server.rs # 路由、内嵌静态资源、SSE、生命周期
+│ ├── listing.rs # 托管目录扫描
+│ └── watcher.rs # 文件监听、防抖、快照广播
+├── stash-web/ # React + Vite + TypeScript 控制台
+│ ├── package.json # 含 volta 版本固定
+│ ├── vite.config.ts # 开发期 /api 代理到 127.0.0.1:8080
+│ └── src/
+└── public/ # 默认托管目录(可放多个前端项目)
+```
+
+`templates/` 与 `assets/` 是上一版 Askama 控制台留下的文件,当前已无任何代码引用,
+可以直接删除。
+
+## 前端怎么被内嵌
+
+1. `npm run build` 产出 `stash-web/dist`;
+2. `build.rs` 在编译 Rust 之前把 `dist` 复制到 `$OUT_DIR/web`(先清空,避免旧 hash 文件残留);
+3. `src/server.rs` 用 `include_dir!("$OUT_DIR/web")` 在编译期把整棵目录树嵌进二进制;
+4. 二进制运行时不再读取任何前端文件。
+
+构建顺序不能颠倒:`dist` 不存在时 `build.rs` 会带着提示信息直接失败,而不是产出一个没有界面的二进制。
+
+`build.rs` 声明了 `rerun-if-changed`,改动 `dist` 后重新 `cargo build` 会自动重新嵌入。
+
+## 前端开发
```bash
-# 托管默认目录 ./public,监听 127.0.0.1:8080
-cargo run -p stash-cli
-
-# 托管指定目录并修改端口
-cargo run -p stash-cli -- --root ./sites --port 3000
-
-# 允许局域网设备访问
-cargo run -p stash-cli -- --root ./sites --host 0.0.0.0 --port 3000
-
-# 关闭 CORS
-cargo run -p stash-cli -- --root ./sites --no-cors
+cd stash-web
+npm install
+npm run dev # http://127.0.0.1:5173,/api 自动代理到后端 8080
+npm run build # 产物输出到 stash-web/dist
+npm run lint # oxlint
```
-启动成功后访问终端中显示的地址即可,例如:
-
-```text
-http://127.0.0.1:8080/
-```
-
-### 构建发布版本
-
-```bash
-cargo build --release -p stash-cli
-```
-
-构建产物为 `target/release/stash`;Windows 下为 `target/release/stash.exe`。
-
-## 托管目录示例
-
-### 单个静态站点
-
-```text
-public/
-├── index.html
-├── assets/
-│ ├── app.js
-│ └── style.css
-└── images/
- └── logo.png
-```
-
-此时访问 `/` 会直接展示 `public/index.html`。
-
-### 多个前端项目
-
-```text
-public/
-├── admin-console/
-│ └── index.html
-├── docs/
-│ └── index.html
-└── landing-page/
- └── index.html
-```
-
-由于根目录没有 `index.html`,访问 `/` 时会展示项目导航页。对应访问地址为:
-
-```text
-/admin-console/
-/docs/
-/landing-page/
-```
+开发时前后端分开跑:一个终端 `cargo run`,另一个终端 `npm run dev`。
+控制台的用户资料目前是占位数据(见 `stash-web/src/profile.ts`),后端尚未提供鉴权接口。
## 命令行参数
| 参数 | 默认值 | 说明 |
| --- | --- | --- |
-| `-r, --root
` | `./public` | 静态页面托管根目录,支持相对路径和绝对路径 |
-| `-p, --port ` | `8080` | HTTP 服务监听端口 |
-| `--host ` | `127.0.0.1` | 监听地址;使用 `0.0.0.0` 可供局域网访问 |
-| `--no-listing` | 关闭 | 设计用于关闭根目录项目导航页 |
+| `-r, --root ` | `./public` | 静态页面托管根目录 |
+| `-p, --port ` | `8080` | 监听端口 |
+| `--host ` | `127.0.0.1` | 监听地址,`0.0.0.0` 供局域网访问 |
| `--no-cors` | 关闭 | 关闭全局 CORS 标头注入 |
-| `-h, --help` | - | 查看命令行帮助 |
-| `-V, --version` | - | 查看程序版本 |
+| `-h, --help` | - | 帮助 |
+| `-V, --version` | - | 版本 |
-注意:`--no-listing` 当前已由命令行解析并写入 `Config.directory_listing`,但根路径处理器尚未读取该配置。因此根目录没有 `index.html` 时,导航页目前仍会显示。
+`--no-listing` 参数目前仍能被解析,但由于控制台已改为内嵌前端,它不再影响任何行为。
## HTTP API
@@ -155,40 +116,25 @@ public/
GET /api/health
```
-响应:
-
```json
-{
- "status": "ok",
- "service": "stash"
-}
+{ "status": "ok", "service": "stash" }
```
-### 获取项目列表
+### 项目列表
```http
GET /api/projects
```
-响应示例:
-
```json
{
"projects": [
- {
- "name": "admin-console",
- "route_path": "/admin-console/"
- },
- {
- "name": "docs",
- "route_path": "/docs/"
- }
+ { "name": "admin-console", "route_path": "/admin-console/" },
+ { "name": "docs", "route_path": "/docs/" }
]
}
```
-该接口与导航页使用相同的项目扫描规则。即使托管根目录包含 `index.html`,接口仍会返回当前根目录下可识别的项目列表。
-
### 订阅项目变化
```http
@@ -196,60 +142,29 @@ GET /api/projects/events
Accept: text/event-stream
```
-连接建立后,服务会立即发送当前项目快照,随后在文件监听检测到项目列表变化时推送新快照:
+连接建立后立即推送一次当前快照,之后在项目列表变化时推送:
```text
event: projects
data: {"projects":[{"name":"docs","route_path":"/docs/"}]}
```
-服务端会递归监听托管目录,将短时间内的连续文件事件合并为一次扫描,并且只在项目名称或访问路径实际变化时广播。SSE 连接每 15 秒发送一次保活消息,浏览器断开后会自动重连。
-
-## 项目结构
-
-```text
-.
-├── Cargo.toml # Rust workspace 配置
-├── Cargo.lock # 依赖锁定文件
-├── README.md
-├── stash-cli/
-│ ├── Cargo.toml
-│ └── src/
-│ └── main.rs # CLI 参数解析、启动信息和停机信号
-└── stash-core/
- ├── Cargo.toml
- ├── assets/
- │ ├── dashboard.css # 控制台与 404 页面样式
- │ └── dashboard.js # 项目列表刷新增强
- ├── src/
- │ ├── config.rs # 服务配置
- │ ├── error.rs # 错误类型与 HTTP 错误响应
- │ ├── lib.rs # 核心库导出
- │ ├── listing.rs # 项目扫描与页面渲染
- │ ├── server.rs # Axum 路由、SSE、静态托管与服务器生命周期
- │ └── watcher.rs # 文件监听、事件防抖和项目快照广播
- └── templates/
- ├── index.html # Askama 多项目导航页模板
- └── 404.html # Askama 404 页面模板
-```
+服务端递归监听托管目录,把短时间内的连续事件合并成一次扫描,只在项目名称或访问路径真正变化时广播;
+每 15 秒发送一次保活消息,浏览器断开后自动重连。
## 测试
-运行核心库测试:
-
```bash
-cargo test -p stash-core
+cargo test # 目录扫描、隐藏目录过滤、排序、路径编码、文件监听广播
+cd stash-web && npm run build # tsc -b + vite build,同时做类型检查
```
-当前测试覆盖项目目录扫描、隐藏目录过滤、一级目录限制、项目排序、项目路径编码、空状态渲染、项目内容 HTML 转义、404 页面渲染与路径转义,以及新增项目后的文件监听广播。
-
## 已知限制
-- 项目扫描只检查托管根目录的一级子目录,不递归查找更深层项目。
-- 根目录存在 `index.html` 时会优先进入单站点模式,不再展示项目导航页。
-- 静态服务只为目录自动追加 `index.html`;导航扫描虽然也识别 `index.htm`,但建议项目统一使用 `index.html`。
-- 未配置 SPA History API 回退。刷新前端路由等不存在的物理路径时,会返回 404 页面。
-- SSE 仅用于服务端向浏览器推送项目快照,不提供浏览器向服务端发送命令的双向通道。
-- 文件事件会经过约 150ms 防抖后再扫描,因此项目出现在导航页中可能有短暂延迟。
-- 启动横幅中显示的接口地址为 `/api/pages`,实际实现的项目列表接口是 `/api/projects`。
-- 服务没有身份认证机制,默认仅建议绑定 `127.0.0.1`。使用 `0.0.0.0` 暴露到局域网前,应确认目录内容适合被其他设备访问。
+- 控制台是单页应用但没有客户端路由,因此没有 SPA history 回退;刷新不存在的物理路径会返回 404。
+- 内嵌资源占用了 `/assets/` 前缀,托管目录下如果存在同名目录会被遮蔽。
+- `/` 固定由控制台占用,上一版「根目录有 `index.html` 就直接当单站点展示」的行为已取消;
+ 托管目录里的项目统一走 `/<目录名>/`。
+- 项目扫描只检查一级子目录,不递归查找更深层项目。
+- 文件事件经过约 150ms 防抖,新项目出现在控制台可能有短暂延迟。
+- 没有身份认证,默认只建议绑定 `127.0.0.1`;使用 `0.0.0.0` 暴露到局域网前请确认目录内容适合被其他设备访问。
diff --git a/stash-web/index.html b/stash-web/index.html
index 25e12d7..88c1559 100644
--- a/stash-web/index.html
+++ b/stash-web/index.html
@@ -5,6 +5,12 @@
+
+
Stash · 本地应用工作区
diff --git a/stash-web/src/App.css b/stash-web/src/App.css
index e5b31e2..ad0c908 100644
--- a/stash-web/src/App.css
+++ b/stash-web/src/App.css
@@ -1,4 +1,35 @@
-/* ============ 基础 ============ */
+/* ============================================================
+ 页面背景:复刻 Alright 的 .site-background
+ ============================================================ */
+
+.site-background {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 65vh;
+ background-image: url('./assets/site-bg.jpg');
+ background-size: cover;
+ background-position: top center;
+ background-repeat: no-repeat;
+ pointer-events: none;
+ z-index: 0;
+}
+
+/* 底部 80% 高度做一层「底色 → 透明」的渐变,让图片自然融进页面背景 */
+.site-background::after {
+ content: '';
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 80%;
+ background-image: linear-gradient(0deg, var(--bg-color) 0%, rgba(0, 0, 0, 0) 100%);
+}
+
+/* ============================================================
+ 全局容器
+ ============================================================ */
.app {
height: 100%;
@@ -6,6 +37,14 @@
flex-direction: column;
}
+/* 版心:顶栏与内容区共用,保证左右边界一?*/
+.layout-container {
+ width: 100%;
+ max-width: var(--layout-max-width);
+ margin: 0 auto;
+ padding: 0 20px;
+}
+
.card-box {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
@@ -26,9 +65,11 @@
border: 0;
}
-/* ============ 顶栏 ============ */
+/* ============================================================
+ 顶部栏:品牌 + 运行信息 + 操作图标
+ ============================================================ */
-.topbar {
+.site-header {
position: fixed;
top: 0;
left: 0;
@@ -39,36 +80,26 @@
z-index: 200;
}
-/* 顶栏内容与下方 .layout 使用同一套宽度与左右内边距,
- 这样 logo、右侧图标才能和侧栏、内容区严格对齐 */
-.topbar-inner {
- width: 100%;
- max-width: var(--layout-max-width);
+.site-header-inner {
height: 100%;
- margin: 0 auto;
- padding: 0 24px 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
-.brand {
+.site-brand {
display: flex;
align-items: center;
gap: 10px;
- width: 180px;
flex-shrink: 0;
- font-size: 18px;
- font-weight: 800;
- color: #0f172a;
}
-.brand-mark {
+.site-brand-mark {
width: 32px;
height: 32px;
border-radius: 50%;
- background-color: #0f172a;
+ background-color: var(--text-main);
color: #fff;
display: flex;
align-items: center;
@@ -77,85 +108,60 @@
font-weight: 700;
}
-.brand-name {
+.site-brand-name {
+ font-size: 18px;
+ font-weight: 800;
letter-spacing: 0.04em;
+ color: var(--text-main);
}
-/* 跑马灯 */
+/* 中间运行信息 */
-.ticker {
- flex: 1;
- max-width: 560px;
- height: 36px;
- margin: 0 20px;
- overflow: hidden;
- position: relative;
+.header-info {
display: flex;
align-items: center;
- -webkit-mask-image: linear-gradient(
- to right,
- transparent,
- #000 10%,
- #000 90%,
- transparent
- );
- mask-image: linear-gradient(
- to right,
- transparent,
- #000 10%,
- #000 90%,
- transparent
- );
-}
-
-.ticker-track {
- display: flex;
- align-items: center;
- gap: 40px;
- white-space: nowrap;
- animation: ticker 26s linear infinite;
-}
-
-.ticker:hover .ticker-track {
- animation-play-state: paused;
-}
-
-.ticker-item {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 13.5px;
+ gap: 20px;
+ min-width: 0;
+ font-size: 14px;
color: var(--text-sub);
}
-.ticker-tag {
- padding: 2px 6px;
+.header-info-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.header-info-tag {
+ padding: 2px 8px;
border-radius: 4px;
background-color: var(--primary-soft);
color: var(--primary-color);
- font-size: 11px;
- font-weight: 600;
+ font-size: 12px;
}
-@keyframes ticker {
- from {
- transform: translateX(0);
- }
- to {
- transform: translateX(-50%);
- }
+.header-info-tag.is-green {
+ background-color: rgba(74, 189, 134, 0.12);
+ color: var(--green);
}
-/* 顶栏操作 */
+.header-info-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
-.top-actions {
+/* 右侧图标 */
+
+.header-actions {
display: flex;
align-items: center;
- gap: 12px;
+ gap: 16px;
flex-shrink: 0;
+ color: var(--text-sub);
}
-.icon-button {
+.header-icon-button {
width: 34px;
height: 34px;
display: flex;
@@ -163,41 +169,25 @@
justify-content: center;
padding: 6px;
border-radius: 50%;
- color: #475569;
cursor: pointer;
transition:
background-color 0.2s,
color 0.2s;
}
-.icon-button:hover {
- background-color: #f1f5f9;
- color: var(--primary-color);
+.header-icon-button:hover {
+ background-color: var(--border-strong);
+ color: var(--text-main);
}
-.icon-button svg {
- width: 19px;
- height: 19px;
- stroke: currentColor;
- fill: none;
- stroke-width: 2;
- stroke-linecap: round;
- stroke-linejoin: round;
+.header-icon-button .iconfont {
+ font-size: 19px;
}
-.icon-button[data-spinning='true'] svg,
-.service-action[data-spinning='true'] svg {
- animation: spin 0.9s linear infinite;
-}
-
-@keyframes spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-/* ============ 搜索框 ============ */
+/* ============================================================
+ 搜索框:固定悬浮在顶栏下方居? ============================================================ */
+/* 固定悬浮、相对整页居中,与设计稿一?*/
.search-row {
position: fixed;
top: 76px;
@@ -218,13 +208,8 @@
.search-icon {
flex-shrink: 0;
- width: 16px;
- height: 16px;
+ font-size: 16px;
color: var(--text-sub);
- stroke: currentColor;
- fill: none;
- stroke-width: 2;
- stroke-linecap: round;
}
.search-input {
@@ -234,7 +219,7 @@
border: none;
outline: none;
background: transparent;
- font-size: 13.5px;
+ font-size: 14px;
color: var(--text-main);
}
@@ -258,45 +243,42 @@
}
.search-clear:hover {
- background-color: #f1f5f9;
+ background-color: var(--border-strong);
color: var(--text-main);
}
-.search-clear svg {
- width: 13px;
- height: 13px;
- stroke: currentColor;
- fill: none;
- stroke-width: 2.4;
- stroke-linecap: round;
+.search-clear .iconfont {
+ font-size: 14px;
}
-/* ============ 双列布局 ============ */
+/* ============================================================
+ 双列布局:左侧栏 + 右侧内容
+ ============================================================ */
.layout {
+ position: relative;
+ z-index: 1;
width: 100%;
max-width: var(--layout-max-width);
- /* 宽屏时整体居中,避免内容全部贴左 */
margin: var(--top-nav-height) auto 0;
height: calc(100vh - var(--top-nav-height));
- padding: 36px 24px 0 20px;
+ padding: 36px 20px 0;
display: grid;
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
gap: 20px;
align-items: stretch;
}
-/* 左侧栏 */
-
.sidebar {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
- margin-top: 68px;
+ /* 与右侧首个分区卡片对?*/
+ margin-top: var(--content-top-offset);
}
-/* 用户资料模块(数据占位,见 profile.ts) */
+/* ---------- 用户资料?---------- */
.profile-card {
display: flex;
@@ -316,17 +298,12 @@
border-radius: 50%;
display: grid;
place-items: center;
- background: linear-gradient(135deg, #dbeafe, #bfdbfe);
- color: #1d4ed8;
+ background: linear-gradient(135deg, #dfe4ff, #c9d0ff);
+ color: var(--primary-color);
}
-.profile-avatar svg {
- width: 34px;
- height: 34px;
- stroke: currentColor;
- fill: none;
- stroke-width: 1.8;
- stroke-linecap: round;
+.profile-avatar .iconfont {
+ font-size: 34px;
}
.profile-badge {
@@ -337,7 +314,7 @@
height: 20px;
border-radius: 50%;
border: 2px solid var(--card-bg);
- background-color: #f59e0b;
+ background-color: var(--orange);
color: #fff;
display: grid;
place-items: center;
@@ -356,132 +333,24 @@
padding: 1px 8px;
border: 1px solid var(--border-color);
border-radius: 999px;
- font-size: 11px;
+ font-size: 12px;
color: var(--text-sub);
}
.profile-bio {
margin: 2px 0 0;
- font-size: 11.5px;
+ font-size: 12px;
line-height: 1.55;
color: var(--text-sub);
}
.profile-location {
margin-top: 2px;
- font-size: 11px;
+ font-size: 12px;
color: var(--text-muted);
}
-.nav-card {
- padding: 8px;
- flex-shrink: 0;
-}
-
-.nav-group {
- list-style: none;
-}
-
-.nav-item {
- margin-bottom: 4px;
-}
-
-.nav-item:last-child {
- margin-bottom: 0;
-}
-
-.nav-link {
- width: 100%;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- padding: 9px 12px;
- border-radius: 8px;
- font-size: 13.5px;
- font-weight: 500;
- color: var(--text-sub);
- cursor: pointer;
- text-align: left;
- transition:
- background-color 0.2s,
- color 0.2s;
-}
-
-.nav-link:hover,
-.nav-link.active {
- background-color: var(--primary-soft);
- color: var(--primary-color);
-}
-
-.nav-count {
- padding: 1px 6px;
- border-radius: 10px;
- background-color: #f1f5f9;
- color: var(--text-muted);
- font-size: 11px;
- font-weight: 600;
-}
-
-.nav-trailing {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-shrink: 0;
-}
-
-.nav-link.active .nav-count,
-.nav-link:hover .nav-count {
- background-color: #fff;
- color: var(--primary-color);
-}
-
-.nav-arrow {
- color: var(--text-muted);
- font-weight: 700;
- line-height: 1;
- transition: transform 0.3s;
-}
-
-.nav-item.open > .nav-link .nav-arrow {
- transform: rotate(90deg);
-}
-
-.submenu {
- list-style: none;
- padding-left: 12px;
- max-height: 0;
- overflow: hidden;
- transition: max-height 0.3s cubic-bezier(0, 1, 0, 1);
-}
-
-.nav-item.open > .submenu {
- max-height: 320px;
- overflow-y: auto;
- transition: max-height 0.3s ease-in-out;
- scrollbar-width: thin;
-}
-
-.submenu .nav-link {
- padding: 7px 12px;
- font-size: 12.5px;
-}
-
-.submenu .nav-link > span:first-child {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.nav-empty {
- color: var(--text-muted);
- cursor: default;
- font-size: 12.5px;
- padding: 7px 12px;
- display: block;
-}
-
-/* 本地服务面板:独立模块,不与导航项混排 */
+/* ---------- 本地服务面板 ---------- */
.service-panel {
padding: 12px 14px;
@@ -496,7 +365,7 @@
}
.service-title {
- font-size: 13px;
+ font-size: 14px;
font-weight: 600;
color: var(--text-main);
}
@@ -507,14 +376,14 @@
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
- background-color: #f1f5f9;
- font-size: 11px;
+ background-color: var(--border-strong);
+ font-size: 12px;
color: var(--text-sub);
}
.service-pill.is-on {
background-color: rgba(34, 197, 94, 0.12);
- color: #15803d;
+ color: var(--green);
}
.service-stats {
@@ -540,14 +409,13 @@
margin: 0;
overflow: hidden;
font-family: var(--font-mono);
- font-size: 11.5px;
+ font-size: 12px;
font-weight: 600;
color: var(--text-main);
text-overflow: ellipsis;
white-space: nowrap;
}
-/* 面板底部操作:重新扫描 / 查看接口 */
.service-actions {
display: flex;
gap: 8px;
@@ -585,22 +453,15 @@
cursor: default;
}
-.service-action svg {
- width: 13px;
- height: 13px;
- flex-shrink: 0;
- stroke: currentColor;
- fill: none;
- stroke-width: 2;
- stroke-linecap: round;
- stroke-linejoin: round;
+.service-action .iconfont {
+ font-size: 14px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
- background-color: #cbd5e1;
+ background-color: var(--border-strong);
flex-shrink: 0;
transition:
background-color 0.2s,
@@ -608,35 +469,144 @@
}
.status-dot.is-on {
- background-color: #22c55e;
+ background-color: var(--green);
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.16);
}
-/* 右侧内容 */
+/* ---------- 分类导航 ---------- */
+
+.nav-card {
+ padding: 8px;
+ flex-shrink: 0;
+}
+
+.nav-group {
+ list-style: none;
+}
+
+.nav-item {
+ margin-bottom: 4px;
+}
+
+.nav-item:last-child {
+ margin-bottom: 0;
+}
+
+.nav-link {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 9px 12px;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-sub);
+ cursor: pointer;
+ text-align: left;
+ transition:
+ background-color 0.2s,
+ color 0.2s;
+}
+
+.nav-link:hover,
+.nav-link.active {
+ background-color: var(--primary-soft);
+ color: var(--primary-color);
+}
+
+/* 折叠箭头:复刻主题的 6×6 边框 V 形,靠右对齐 */
+.nav-link.has-children::after {
+ content: '';
+ flex-shrink: 0;
+ width: 6px;
+ height: 6px;
+ border-left: 1px solid currentColor;
+ border-bottom: 1px solid currentColor;
+ opacity: 0.4;
+ transform: rotate(315deg);
+ transition:
+ transform 0.3s,
+ opacity 0.3s;
+}
+
+.nav-link.has-children:hover::after {
+ opacity: 0.7;
+}
+
+.nav-item.open > .nav-link.has-children::after {
+ opacity: 0.7;
+ transform: rotate(135deg);
+}
+
+/* 子菜单展开:用 grid-template-rows 0fr → 1fr 做高度动画,
+ 高度精确贴合内容,全程 overflow: hidden,不会出现滚动条 */
+.submenu-wrap {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 0.28s ease;
+}
+
+.submenu-wrap.is-open {
+ grid-template-rows: 1fr;
+}
+
+.submenu {
+ list-style: none;
+ min-height: 0;
+ padding-left: 12px;
+ overflow: hidden;
+}
+
+.submenu .nav-link {
+ padding: 7px 12px;
+ font-size: 12px;
+}
+
+.submenu .nav-link > span:first-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* ============================================================
+ 右侧内容? ============================================================ */
.content {
+ position: relative;
display: flex;
flex-direction: column;
min-height: 0;
- padding-top: 52px;
+ /* 让出悬浮的搜索框;侧栏与遮罩层都按同一个变量对?*/
+ padding-top: var(--content-top-offset);
+}
+
+/* 滚动后才淡入的顶部渐隐层? 卡片滚到容器顶边时被硬切会很难看,用一层背景色渐变把它柔化?*/
+.content-veil {
+ position: absolute;
+ top: var(--content-top-offset);
+ right: 0;
+ left: 0;
+ height: 32px;
+ pointer-events: none;
+ opacity: 0;
+ background: linear-gradient(to bottom, var(--bg-color) 0%, transparent 100%);
+ transition: opacity 0.2s ease;
+ z-index: 5;
+}
+
+.content-veil.is-visible {
+ opacity: 1;
}
.content-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
- /* 顶部留出渐隐区,避免第一个分区标题被 mask 吃掉 */
- padding-top: 24px;
padding-right: 4px;
padding-bottom: 40px;
scroll-behavior: smooth;
- -webkit-mask-image: linear-gradient(
- to bottom,
- transparent 0,
- #000 22px,
- #000 100%
- );
- mask-image: linear-gradient(to bottom, transparent 0, #000 22px, #000 100%);
-ms-overflow-style: none;
scrollbar-width: none;
}
@@ -646,103 +616,829 @@
width: 0;
}
-.section-heading {
+/* ---------- 首页:分区卡?---------- */
+
+.home-view {
display: flex;
- align-items: baseline;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.section-card {
+ background-color: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ padding: 20px;
+ box-shadow: var(--shadow-card);
+}
+
+.section-header {
+ display: flex;
+ align-items: center;
justify-content: space-between;
- gap: 12px;
margin-bottom: 16px;
}
-.section-title {
+.section-title-wrapper {
display: flex;
align-items: center;
- font-size: 15px;
- font-weight: 600;
- color: var(--text-main);
- line-height: 1;
}
-.section-title::before {
- content: '';
+.section-bar {
width: 4px;
- height: 15px;
+ height: 16px;
margin-right: 8px;
border-radius: 2px;
background-color: var(--primary-color);
}
-/* 右上角数量角标:粉色渐变胶囊 */
-.section-badge {
- flex-shrink: 0;
+/* 标题容器设为 relative,角标才能贴在标题右上角 */
+.section-title-box {
+ position: relative;
display: inline-flex;
align-items: center;
+}
+
+.section-title {
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 1.2;
+ color: var(--text-main);
+}
+
+.count-badge {
+ position: absolute;
+ top: -8px;
+ right: -18px;
+ display: flex;
+ align-items: center;
justify-content: center;
- min-width: 26px;
- height: 20px;
- padding: 0 9px;
- border-radius: 999px;
- background-image: linear-gradient(90deg, #ff8a8a 0%, #f42a63 100%);
- box-shadow: 0 2px 6px rgba(244, 42, 99, 0.28);
+ height: 16px;
+ min-width: 16px;
+ padding: 0 4px;
+ border-radius: 8px;
+ background-color: var(--danger);
+ box-shadow: 0 1px 3px rgba(255, 77, 79, 0.3);
color: #fff;
font-size: 12px;
font-weight: 700;
line-height: 1;
- letter-spacing: 0.02em;
}
-.section-group + .section-group {
- margin-top: 28px;
+.section-more {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 6px;
+ border-radius: 4px;
+ color: var(--text-muted);
+ font-size: 16px;
+ line-height: 1;
+ cursor: pointer;
+ transition:
+ background-color 0.2s,
+ color 0.2s;
}
-.grid {
+.section-more:hover {
+ background-color: var(--border-color);
+ color: var(--text-sub);
+}
+
+.section-empty {
+ padding: 8px 0;
+ font-size: 14px;
+ color: var(--text-sub);
+}
+
+/* ---------- 应用网格 ---------- */
+
+.app-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.app-grid--detail {
gap: 16px;
}
-.card {
+.app-card {
display: flex;
align-items: center;
- padding: 12px 14px;
- border: 1px solid var(--border-color);
- border-radius: 10px;
- background-color: var(--card-bg);
- box-shadow: var(--shadow-card);
- transition:
- transform 0.2s,
- box-shadow 0.2s,
- border-color 0.2s;
- animation: card-in 0.28s ease backwards;
+ gap: 15px;
+ padding: 6px 0;
+ /* 和主题的作者条目一样:没有底色、描边、阴影,就是一行内容 */
+ background: none;
+ border: 0;
+ color: inherit;
}
-.card:hover {
- transform: translateY(-2px);
- border-color: rgba(59, 130, 246, 0.35);
- box-shadow: var(--shadow-hover);
+.app-card:hover .app-card-name {
+ color: var(--primary-color);
}
-.card-icon {
- width: 36px;
- height: 36px;
- margin-right: 12px;
+/* 48px 圆形头像 + 右下角认证角标,对应主题 .flex-avatar / .avatar-badge.badge_5 */
+.app-card-avatar {
+ position: relative;
+ width: 48px;
+ height: 48px;
+ flex-shrink: 0;
+ border-radius: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--primary-soft);
+ color: var(--primary-color);
+ font-size: 18px;
+ font-weight: 700;
+}
+
+/* 有封面图时直接铺满圆形头像 */
+.app-card-avatar img {
+ width: 100%;
+ height: 100%;
+ border-radius: inherit;
+ object-fit: cover;
+ display: block;
+}
+
+.app-card-verify {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ width: 10px;
+ height: 10px;
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNiIgaGVpZ2h0PSIzNiIgZmlsbD0ibm9uZSI+PGNpcmNsZSBjeD0iMTgiIGN5PSIxOCIgcj0iMTgiIGZpbGw9InVybCgjYSkiLz48cGF0aCBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yOC4yNSAxMS44NDRhMi4yMzIgMi4yMzIgMCAwIDEgMCAzLjE1NkwxNy41ODggMjUuNjYyYTIuMzggMi4zOCAwIDAgMS0zLjM2NiAwbC01LjY0NC01LjY0NGEyLjIzMiAyLjIzMiAwIDAgMSAzLjE1Ni0zLjE1NWw0LjE3IDQuMTcgOS4xOS05LjE4OWEyLjIzMiAyLjIzMiAwIDAgMSAzLjE1NiAwWiIgY2xpcC1ydWxlPSJldmVub2RkIi8+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJhIiB4MT0iMzEuNzE0IiB4Mj0iLTMuNzMxIiB5MT0iLjg1NyIgeTI9IjExLjgzOSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNGRkI3NEEiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGRDkwMEYiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4=");
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: contain;
+}
+
+.app-card-info {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ min-width: 0;
+ flex: 1 1 auto;
+ overflow: hidden;
+}
+
+.app-card-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+.app-card-name {
+ font-size: 16px;
+ font-weight: 500;
+ line-height: 24px;
+ color: var(--text-main);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* 名字后的 HOT 徽标:主题里是 24×16 的图形,不是文字 */
+.app-card-badge {
+ position: relative;
+ top: 2px;
+ display: inline-block;
+ flex-shrink: 0;
+ width: 24px;
+ height: 16px;
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSIyMSIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0idXJsKCNhKSIgZD0iTTAgNGE0IDQgMCAwIDEgNC00aDQyYTQgNCAwIDAgMSA0IDR2MTNhNCA0IDAgMCAxLTQgNEg0YTQgNCAwIDAgMS00LTRWNFoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNi4yODYgOS4zNDVoOC4wM1Y0LjE3NmgyLjI4NnYxMi42NDhoLTIuMjg3di01LjE2OUg2LjI4NnY1LjE2OUg0VjQuMTc2aDIuMjg2djUuMTY5Wk0zMi41MTkgMTEuNzA4YzAgLjc3LS4xMzIgMS40NzktLjM5NiAyLjEyNmE1LjAzOSA1LjAzOSAwIDAgMS0xLjEgMS42NzUgNS4wMiA1LjAyIDAgMCAxLTEuNjcgMS4xMDNBNS41MjYgNS41MjYgMCAwIDEgMjcuMjYgMTdoLTMuMDk1Yy0uNzU3IDAtMS40NTctLjEzLTIuMTAyLS4zODhhNS4wMiA1LjAyIDAgMCAxLTEuNjcxLTEuMTAyIDUuMTUyIDUuMTUyIDAgMCAxLTEuMTA4LTEuNjc2IDUuNTc2IDUuNTc2IDAgMCAxLS4zOTYtMi4xMjZWOS4yOTJjMC0uNzY1LjEzMi0xLjQ3LjM5Ni0yLjExN2E1LjAzIDUuMDMgMCAwIDEgMS4xMDgtMS42NzYgNS4wMjIgNS4wMjIgMCAwIDEgMS42Ny0xLjEwMkE1LjQ4NCA1LjQ4NCAwIDAgMSAyNC4xNjYgNGgzLjA5NWMuNzU2IDAgMS40NTQuMTMyIDIuMDkzLjM5N2E1LjAyMiA1LjAyMiAwIDAgMSAxLjY3IDEuMTAyIDQuOTIyIDQuOTIyIDAgMCAxIDEuMSAxLjY3NmMuMjY0LjY0Ny4zOTYgMS4zNTIuMzk2IDIuMTE3djIuNDE2Wm0tMi4yODctMi40MTZjMC0uNDUzLS4wNzMtLjg2Mi0uMjItMS4yMjZhMi41ODkgMi41ODkgMCAwIDAtLjYwNi0uOTQ0IDIuNTggMi41OCAwIDAgMC0uOTQxLS42MDggMy4xODYgMy4xODYgMCAwIDAtMS4yMDUtLjIyaC0zLjA5NWMtLjQ0NiAwLS44NTMuMDczLTEuMjIzLjIyYTIuNjgxIDIuNjgxIDAgMCAwLS45NC42MDhjLS4yNjUuMjU5LS40Ny41NzMtLjYxNi45NDQtLjE0MS4zNjQtLjIxMS43NzMtLjIxMSAxLjIyNnYyLjQxNmMwIC40NTMuMDcuODY1LjIxIDEuMjM1LjE0Ny4zNjUuMzUyLjY4LjYxNi45NDQuMjY0LjI1OC41NzguNDYxLjk0MS42MDhhMy40IDMuNCAwIDAgMCAxLjIyMy4yMTJoMy4wNzhjLjQ0NSAwIC44NS0uMDcgMS4yMTMtLjIxMi4zNy0uMTQ3LjY4Ni0uMzUuOTUtLjYwOC4yNjQtLjI2NS40NjYtLjU4LjYwNy0uOTQ0LjE0Ni0uMzcuMjItLjc4Mi4yMi0xLjIzNVY5LjI5MlpNNDYgNi40N2gtNS4wMzl2MTAuMzU0aC0yLjI4NlY2LjQ2OWgtNS4wNDhWNC4xNzZINDZWNi40N1oiLz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSI0NC4wNDgiIHgyPSI5LjEwNyIgeTE9Ii41IiB5Mj0iMjYuMjc2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agc3RvcC1jb2xvcj0iI0ZGN0I1MSIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI0ZGMEUwRSIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjwvc3ZnPg==");
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: contain;
+}
+
+.app-card-desc {
+ font-size: 12px;
+ line-height: 16px;
+ color: var(--text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* ---------- 海报网格 ---------- */
+
+.poster-grid {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.poster-grid--detail {
+ gap: 14px;
+}
+
+.poster-card {
+ position: relative;
+ display: block;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ border: none;
+ aspect-ratio: 2 / 3;
border-radius: 8px;
- background-color: #f1f5f9;
+ overflow: hidden;
+ background-color: var(--border-strong);
+ text-align: left;
+ font: inherit;
+ cursor: pointer;
+ transition: transform 0.2s ease;
+}
+
+.poster-card:hover {
+ transform: translateY(-2px);
+}
+
+.poster-cover {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.poster-caption {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 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: #fff;
+}
+
+.poster-title {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ overflow: hidden;
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1.3;
+}
+
+/* ---------- 首页图文网格 ---------- */
+
+.article-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.article-card {
+ position: relative;
+ display: block;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ border: none;
+ aspect-ratio: 4 / 3;
+ border-radius: 6px;
+ overflow: hidden;
+ background-color: var(--border-strong);
+ text-align: left;
+ font: inherit;
+ cursor: pointer;
+ transition: transform 0.2s ease;
+}
+
+.article-card:hover {
+ transform: translateY(-2px);
+}
+
+.article-cover {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.article-caption {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 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: #fff;
+}
+
+.article-title {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ overflow: hidden;
+ font-size: 12px;
+ line-height: 1.3;
+}
+
+/* ---------- 明细?---------- */
+
+.detail-view {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.detail-nav {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 20px;
+}
+
+.detail-back {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--primary-color);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.detail-back:hover {
+ text-decoration: underline;
+}
+
+.detail-breadcrumb {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.detail-hero {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 12px 0 4px;
+ text-align: center;
+}
+
+.detail-hero-icon {
+ width: 44px;
+ height: 44px;
+ margin-bottom: 8px;
+ border-radius: 50%;
+ background-color: var(--primary-soft);
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
+ font-size: 24px;
+}
+
+.detail-hero-title {
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--text-main);
+}
+
+.detail-hero-count {
+ margin-top: 4px;
+ font-size: 14px;
+ color: var(--text-muted);
+}
+
+/* 分组块:小标?+ 该组网格 */
+.group-block {
+ margin-bottom: 12px;
+}
+
+.group-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+ font-size: 16px;
font-weight: 600;
+ color: var(--text-main);
+}
+
+.group-title::before {
+ content: '';
+ width: 3px;
+ height: 14px;
+ border-radius: 2px;
+ background-color: var(--primary-color);
+}
+
+/* 明细页图文白?*/
+.ces-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.ces-card {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ padding: 0;
+ overflow: hidden;
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ background-color: var(--card-bg);
+ box-shadow: var(--shadow-card);
+ text-align: left;
+ font: inherit;
+ cursor: pointer;
+ transition:
+ transform 0.2s,
+ box-shadow 0.2s;
+}
+
+.ces-card:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-hover);
+}
+
+.ces-cover {
+ width: 100%;
+ aspect-ratio: 16 / 10;
+ object-fit: cover;
+ display: block;
+ background-color: var(--border-color);
+}
+
+.ces-body {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 12px;
+}
+
+.ces-title {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ overflow: hidden;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.4;
+ color: var(--text-main);
+}
+
+.ces-desc {
+ display: block;
+ margin-top: 2px;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+/* ============================================================
+ 文章详情页(点击图文卡片进入? ============================================================ */
+
+.article-head {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ padding: 4px 0;
+ text-align: center;
+}
+
+.article-heading {
+ max-width: 760px;
+ font-size: 24px;
+ font-weight: 700;
+ line-height: 1.4;
+ color: var(--text-main);
+}
+
+.article-meta {
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.article-meta-item {
+ display: inline-flex;
+ align-items: center;
+}
+
+.article-meta-dot {
+ width: 3px;
+ height: 3px;
+ margin: 0 8px;
+ border-radius: 50%;
+ background-color: var(--border-strong);
+}
+
+.article-body {
+ padding: 32px 40px 40px;
+}
+
+.article-body-text {
+ margin-bottom: 16px;
+ font-size: 14px;
+ line-height: 1.9;
+ color: var(--text-sub);
+}
+
+.article-body-heading {
+ margin: 28px 0 16px;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-main);
+}
+
+.article-body-image {
+ display: block;
+ width: 100%;
+ margin: 20px 0;
+ border-radius: 4px;
+ background-color: var(--border-color);
+}
+
+/* ============================================================
+ 条目详情页(点击应用卡片进入? ============================================================ */
+
+/* 明细 / 条目详情共用同一套打开动画,和点击 ••?的表现保持一?*/
+.detail-view {
+ animation: view-in 0.22s ease both;
+}
+
+@keyframes view-in {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+.item-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 24px 28px;
+}
+
+.item-title {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ font-size: 24px;
+ font-weight: 600;
+ color: var(--text-main);
+}
+
+.item-edit {
+ font-size: 14px;
+ font-weight: 400;
+ color: var(--text-muted);
+ cursor: default;
+}
+
+/* 主图:应用是方形头像,电影是竖版海报 */
+.item-cover {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 18px 0 10px;
+ overflow: hidden;
+ background-color: var(--primary-soft);
+ color: var(--primary-color);
+}
+
+.item-cover--square {
+ width: 96px;
+ height: 96px;
+ border-radius: 12px;
+}
+
+.item-cover--portrait {
+ width: 179px;
+ aspect-ratio: 2 / 3;
+ border-radius: 8px;
+}
+
+.item-cover img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.item-cover-letter {
+ font-size: 38px;
+ font-weight: 700;
+}
+
+.item-tags {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 8px;
+}
+
+.item-tag {
+ padding: 2px 10px;
+ border-radius: 999px;
+ background-color: var(--primary-soft);
+ color: var(--primary-color);
+ font-size: 12px;
+}
+
+.item-quote {
+ display: flex;
+ gap: 6px;
+ width: 100%;
+ margin-top: 18px;
+ text-align: left;
+}
+
+.item-quote-mark {
+ flex-shrink: 0;
+ font-size: 32px;
+ font-weight: 700;
+ line-height: 1;
+ color: rgba(255, 62, 62, 0.35);
+}
+
+.item-quote-text {
+ font-size: 14px;
+ line-height: 1.7;
+ color: var(--text-sub);
+}
+
+.item-desc {
+ width: 100%;
+ margin-top: 10px;
+ font-size: 14px;
+ line-height: 1.7;
+ color: var(--text-sub);
+ text-align: left;
+}
+
+.item-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ margin-top: 18px;
+}
+
+.item-stat {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ min-width: 56px;
+ height: 36px;
+ padding: 0 12px;
+ border-radius: 8px;
+ background-color: var(--bg-color);
+ color: var(--text-sub);
+ font-size: 12px;
+}
+
+.item-stat .iconfont {
+ font-size: 16px;
+}
+
+.item-stat em {
+ font-style: normal;
+ font-weight: 600;
+ color: var(--text-main);
+}
+
+.item-stat--icon {
+ cursor: pointer;
+}
+
+.item-stat--icon:hover {
+ background-color: var(--border-color);
+}
+
+.item-jump {
+ flex: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ height: 36px;
+ border-radius: 8px;
+ background-color: var(--primary-color);
+ color: #fff;
+ font-size: 14px;
+ transition: background-color 0.2s;
+}
+
+.item-jump:hover {
+ background-color: #2c47d8;
+}
+
+.item-jump:disabled {
+ opacity: 0.55;
+ cursor: default;
+}
+
+.item-jump .iconfont {
+ font-size: 14px;
+}
+
+.item-banner {
+ width: 100%;
+ margin-top: 18px;
+ aspect-ratio: 3 / 1;
+ border-radius: 10px;
+ object-fit: cover;
+ background-color: var(--border-color);
+}
+
+/* 详情页「推荐阅读?*/
+
+.related-card {
+ padding: 20px;
+}
+
+.related-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 16px;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-main);
+}
+
+.related-title-icon {
+ font-size: 16px;
+}
+
+.related-grid {
+ list-style: none;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px 24px;
+}
+
+.related-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 4px;
+ border-radius: 8px;
+ text-align: left;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.related-item:hover {
+ background-color: var(--bg-color);
+}
+
+.related-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--primary-soft);
+ color: var(--primary-color);
+ font-size: 14px;
+ font-weight: 700;
flex-shrink: 0;
}
-.card-body {
+.related-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.related-info {
display: flex;
flex-direction: column;
min-width: 0;
}
-.card-title {
+.related-name {
font-size: 14px;
font-weight: 600;
color: var(--text-main);
@@ -751,43 +1447,36 @@
text-overflow: ellipsis;
}
-.card-desc {
+.related-desc {
margin-top: 2px;
- font-size: 11.5px;
- color: var(--text-sub);
+ font-size: 12px;
+ color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
-/* 路径类描述用等宽字体,普通文案保持无衬线 */
-.card-desc.is-mono {
- font-family: var(--font-mono);
-}
-
-@keyframes card-in {
- from {
- opacity: 0;
- transform: translateY(6px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
+@media (max-width: 900px) {
+ .related-grid {
+ grid-template-columns: minmax(0, 1fr);
}
}
-/* 空状态 / 错误 */
+/* ============================================================
+ 提示?/ 回到顶部
+ ============================================================ */
.notice {
display: flex;
flex-direction: column;
gap: 6px;
- padding: 36px 24px;
+ margin-bottom: 20px;
+ padding: 24px;
border: 1px dashed var(--border-color);
- border-radius: var(--radius-main);
- background-color: rgba(255, 255, 255, 0.6);
+ border-radius: 12px;
+ background-color: rgba(255, 255, 255, 0.7);
color: var(--text-sub);
- font-size: 13px;
+ font-size: 14px;
}
.notice strong {
@@ -796,23 +1485,14 @@
color: var(--text-main);
}
-.notice code {
- padding: 1px 5px;
- border-radius: 5px;
- background-color: #eef2f7;
- font-family: var(--font-mono);
- font-size: 12px;
- color: var(--text-main);
-}
-
.notice-error {
border-style: solid;
- border-color: #f3c6c6;
- background-color: #fff7f7;
+ border-color: rgba(255, 62, 62, 0.25);
+ background-color: #fff5f5;
}
.notice-error strong {
- color: #b42318;
+ color: var(--danger);
}
.notice-action {
@@ -822,11 +1502,8 @@
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: var(--card-bg);
- font-size: 13px;
+ font-size: 14px;
cursor: pointer;
- transition:
- border-color 0.2s,
- color 0.2s;
}
.notice-action:hover {
@@ -834,49 +1511,78 @@
color: var(--primary-color);
}
-/* 回到顶部 */
-
+/* 返回顶部:复刻 Alright 的 .totop-button(42px 圆形浅灰底),
+ 默认隐藏,内容区滚动后才淡入 */
.back-to-top {
position: fixed;
+ top: 50%;
right: 30px;
- bottom: 30px;
- width: 40px;
- height: 40px;
+ width: 42px;
+ height: 42px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
- background-color: #e2e8f0;
- color: #1e293b;
- box-shadow: var(--shadow-card);
+ border: 1px solid var(--border-color);
+ background-color: var(--border-color);
+ color: var(--text-sub);
cursor: pointer;
- z-index: 90;
+ z-index: 99;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-50%) translateY(15px);
transition:
+ opacity 0.3s ease,
+ visibility 0.3s ease,
+ transform 0.3s ease,
background-color 0.2s,
- transform 0.2s;
+ color 0.2s;
+}
+
+.back-to-top.is-visible {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(-50%);
}
.back-to-top:hover {
- background-color: #cbd5e1;
- transform: translateY(-2px);
+ background-color: var(--border-strong);
+ border-color: var(--border-strong);
+ color: var(--text-main);
}
-.back-to-top svg {
- width: 18px;
- height: 18px;
- stroke: currentColor;
- fill: none;
- stroke-width: 2.5;
- stroke-linecap: round;
- stroke-linejoin: round;
+.back-to-top .iconfont {
+ font-size: 20px;
}
-/* ============ 响应式 ============ */
+/* 刷新按钮转圈 */
+.service-action[data-spinning='true'] .iconfont {
+ animation: spin 0.9s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* ============================================================
+ 响应? ============================================================ */
@media (max-width: 1100px) {
:root {
--sidebar-width: 200px;
}
+
+ .app-grid,
+ .article-grid,
+ .ces-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .poster-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
}
@media (max-width: 900px) {
@@ -885,26 +1591,22 @@
min-height: 100vh;
}
- .topbar {
- position: sticky;
- }
-
- .topbar-inner {
+ .layout-container {
padding: 0 16px;
}
- .brand {
- width: auto;
+ .site-header {
+ position: sticky;
}
- .ticker {
+ .header-info {
display: none;
}
.search-row {
position: static;
- top: auto;
left: auto;
+ top: auto;
transform: none;
width: auto;
height: 44px;
@@ -926,14 +1628,25 @@
padding-top: 0;
}
+ .content-veil {
+ display: none;
+ }
+
.content-scroll {
overflow: visible;
-webkit-mask-image: none;
mask-image: none;
}
+ /* 窄屏沿用主题的右下侧定位,隐藏/显示的位移单独处理 */
.back-to-top {
- right: 20px;
- bottom: 20px;
+ top: auto;
+ right: 15px;
+ bottom: 30%;
+ transform: translateY(15px);
+ }
+
+ .back-to-top.is-visible {
+ transform: translateY(0);
}
}
diff --git a/stash-web/src/App.tsx b/stash-web/src/App.tsx
index 377589f..c8c6297 100644
--- a/stash-web/src/App.tsx
+++ b/stash-web/src/App.tsx
@@ -1,119 +1,178 @@
-import { useMemo, useRef, useState } from 'react'
-import { BackToTop } from './components/BackToTop'
-import { SearchBar } from './components/SearchBar'
-import { Sidebar } from './components/Sidebar'
-import { SiteCard } from './components/SiteCard'
-import { TopBar } from './components/TopBar'
-import { MOCK_SECTIONS, type CatalogCard, type CatalogSection } from './catalog'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { BackToTop } from './components/common/BackToTop'
+import { ArticleDetailView } from './components/content/ArticleDetailView'
+import { DetailView } from './components/content/DetailView'
+import { HomeView } from './components/content/HomeView'
+import { ItemDetailView } from './components/content/ItemDetailView'
+import { SiteHeader } from './components/header/SiteHeader'
+import { SearchBar } from './components/search/SearchBar'
+import { Sidebar } from './components/sidebar/Sidebar'
+import { buildCategories } from './data/catalog'
+import {
+ appDetailModel,
+ posterDetailModel,
+ posterToRelatedItem,
+ toRelatedItem,
+} from './data/detail'
+import { filterCategories } from './data/filter'
+import type { NavTarget } from './data/nav'
+import {
+ findAppItem,
+ findArticleItem,
+ findPosterItem,
+ relatedAppItems,
+ relatedPosterItems,
+} from './data/types'
import { useProjects } from './useProjects'
import './App.css'
-function initialOf(name: string): string {
- const [first] = Array.from(name.trim())
- return first ? first.toUpperCase() : '#'
-}
+/**
+ * 当前展示的视图:
+ * - home:首页分区卡片流
+ * - detail:某个分类(可指定分组)的明细
+ * - item:单个条目(应用或电影)的详情,returnTo 记录返回目标
+ */
+type View =
+ | { type: 'home' }
+ | { type: 'detail'; categoryId: string; groupId?: string }
+ | { type: 'item'; categoryId: string; itemId: string; returnTo: View }
export default function App() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
const [keyword, setKeyword] = useState('')
+ const [view, setView] = useState({ type: 'home' })
+ const [scrolled, setScrolled] = useState(false)
const searchRef = useRef(null)
+ const scrollRef = useRef(null)
- // 真实项目放最前面,占位分类跟在后面
- const sections = useMemo(() => {
- const localCards: CatalogCard[] = projects.map((project) => ({
- id: project.route_path,
- title: project.name,
- desc: project.route_path,
- href: project.route_path,
- initial: initialOf(project.name),
- mono: true,
- }))
+ const categories = useMemo(() => buildCategories(projects), [projects])
+ const visibleCategories = useMemo(
+ () => filterCategories(categories, keyword),
+ [categories, keyword],
+ )
- return [{ id: 'local', title: '本地服务', cards: localCards }, ...MOCK_SECTIONS]
- }, [projects])
+ const detailView = view.type === 'detail' ? view : undefined
+ const itemView = view.type === 'item' ? view : undefined
- const query = keyword.trim().toLowerCase()
- const searching = query !== ''
+ const detailCategory =
+ detailView !== undefined
+ ? categories.find((category) => category.id === detailView.categoryId)
+ : undefined
- // 搜索时过滤卡片,并丢掉没有命中的分区
- const sectionsToRender = useMemo(() => {
- if (!searching) return sections
+ const itemCategory =
+ itemView !== undefined
+ ? categories.find((category) => category.id === itemView.categoryId)
+ : undefined
- return sections
- .map((section) => ({
- ...section,
- cards: section.cards.filter(
- (card) =>
- card.title.toLowerCase().includes(query) ||
- card.desc.toLowerCase().includes(query),
- ),
- }))
- .filter((section) => section.cards.length > 0)
- }, [sections, searching, query])
+ // 应用与电影共用一个详情模型,这里按分类类型做映射
+ const detailModel = useMemo(() => {
+ if (itemView === undefined || itemCategory === undefined) return undefined
- const noMatch = searching && sectionsToRender.length === 0
+ if (itemCategory.kind === 'apps') {
+ const item = findAppItem(itemCategory, itemView.itemId)
+ return item !== undefined ? appDetailModel(item) : undefined
+ }
+
+ if (itemCategory.kind === 'posters') {
+ const poster = findPosterItem(itemCategory, itemView.itemId)
+ return poster !== undefined ? posterDetailModel(poster) : undefined
+ }
+
+ return undefined
+ }, [itemView, itemCategory])
+
+ const relatedItems = useMemo(() => {
+ if (itemView === undefined || itemCategory === undefined) return []
+
+ if (itemCategory.kind === 'apps') {
+ return relatedAppItems(itemCategory, itemView.itemId, 4).map(toRelatedItem)
+ }
+
+ if (itemCategory.kind === 'posters') {
+ return relatedPosterItems(itemCategory, itemView.itemId, 4).map(posterToRelatedItem)
+ }
+
+ return []
+ }, [itemView, itemCategory])
+
+ // 切换视图时把内容区滚回顶部
+ useEffect(() => {
+ scrollRef.current?.scrollTo({ top: 0 })
+ }, [view])
+
+ // 桌面端滚动作在内容容器里,窄屏滚的是窗口:
+ // 两处都要驱动「已滚动」状态(顶部渐隐层 + 返回顶部按钮共用)
+ useEffect(() => {
+ const sync = () => {
+ const inner = scrollRef.current?.scrollTop ?? 0
+ const next = Math.max(inner, window.scrollY) > 4
+ setScrolled((current) => (current === next ? current : next))
+ }
+
+ window.addEventListener('scroll', sync, { passive: true })
+ return () => window.removeEventListener('scroll', sync)
+ }, [])
+
+ const openDetail = (categoryId: string) => setView({ type: 'detail', categoryId })
+
+ const openItem = (categoryId: string, itemId: string, returnTo: View) =>
+ setView({ type: 'item', categoryId, itemId, returnTo })
+
+ const navigate = (target: NavTarget) =>
+ setView({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
+
+ const goHome = () => setView({ type: 'home' })
+
+ const searching = keyword.trim() !== ''
+
+ /**
+ * 条目详情按分类类型分流:
+ * 图文走长文版式,应用 / 电影走通用的条目版式。
+ */
+ function renderItemView() {
+ if (itemView === undefined) return null
+ if (itemCategory === undefined) {
+ return 分类不存在
+ }
+
+ if (itemCategory.kind === 'articles') {
+ const article = findArticleItem(itemCategory, itemView.itemId)
+ if (article === undefined) {
+ return 条目不存在
+ }
- function renderLocalPlaceholder() {
- if (status === 'error') {
return (
-
-
连不上 Stash 服务
-
{error}
-
- 请确认后端已启动(默认 127.0.0.1:8080),开发时 Vite 会把{' '}
- /api 代理到后端。
-
-
-
+ setView(itemView.returnTo)}
+ />
)
}
- if (status === 'loading') {
- return (
-
-
正在读取项目列表…
-
首次加载会扫描一次托管目录。
-
- )
+ if (detailModel === undefined) {
+ return 条目不存在
}
return (
-
-
这里还没有项目
-
- 在托管根目录下创建子目录并放入 index.html,刷新后即可在这里看到它。
-
-
- )
- }
-
- function renderSection(section: CatalogSection) {
- return (
-
-
-
{section.title}
- {section.cards.length}
-
-
- {section.cards.length > 0 ? (
-
- {section.cards.map((card, index) => (
-
- ))}
-
- ) : (
- renderLocalPlaceholder()
- )}
-
+ setView(itemView.returnTo)}
+ onOpenItem={(itemId) => setView({ ...itemView, itemId })}
+ />
)
}
return (
-
+
+
searchRef.current?.focus()}
/>
@@ -128,25 +187,74 @@ export default function App() {
live={live}
refreshing={refreshing}
onRefresh={refresh}
+ activeCategoryId={detailView?.categoryId ?? itemView?.categoryId}
+ activeGroupId={detailView?.groupId}
+ onNavigate={navigate}
/>
-
+ {/* 滚动后才淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
+
+
+
{
+ const next = event.currentTarget.scrollTop > 4
+ setScrolled((current) => (current === next ? current : next))
+ }}
+ >
Stash 本地应用工作区
- {sectionsToRender.map(renderSection)}
-
- {noMatch && (
-
-
没有匹配「{keyword.trim()}」的内容
-
换个关键字试试,或清空搜索查看全部。
+ {status === 'error' && (
+
+
连不上 Stash 服务
+
{error}
+
)}
+
+ {detailView === undefined && itemView === undefined && (
+ <>
+
+ openItem(categoryId, itemId, { type: 'home' })
+ }
+ />
+
+ {searching && visibleCategories.length === 0 && (
+
+
没有匹配「{keyword.trim()}」的内容
+
换个关键字试试,或清空搜索查看全部。
+
+ )}
+ >
+ )}
+
+ {detailView !== undefined && detailCategory !== undefined && (
+ openItem(detailCategory.id, itemId, detailView)}
+ />
+ )}
+
+ {detailView !== undefined && detailCategory === undefined && (
+ 分类不存在
+ )}
+
+ {itemView !== undefined && renderItemView()}
-
+
)
}
diff --git a/stash-web/src/assets/iconfont.woff2 b/stash-web/src/assets/iconfont.woff2
new file mode 100644
index 0000000..4ca9e6f
Binary files /dev/null and b/stash-web/src/assets/iconfont.woff2 differ
diff --git a/stash-web/src/assets/site-bg.jpg b/stash-web/src/assets/site-bg.jpg
new file mode 100644
index 0000000..44f1755
Binary files /dev/null and b/stash-web/src/assets/site-bg.jpg differ
diff --git a/stash-web/src/catalog.ts b/stash-web/src/catalog.ts
deleted file mode 100644
index 599966f..0000000
--- a/stash-web/src/catalog.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-/**
- * 占位数据:沿用 index9 设计稿里的分类与卡片。
- *
- * 后端目前只提供扁平的项目列表,没有分类概念,
- * 所以这里的内容仅用于撑起界面结构,等接口支持分类后再替换。
- */
-
-export type CatalogCard = {
- id: string
- title: string
- desc: string
- href: string
- /** 卡片左侧的字符标记 */
- initial: string
- /** desc 是路径时用等宽字体 */
- mono?: boolean
-}
-
-export type CatalogSection = {
- id: string
- title: string
- cards: CatalogCard[]
-}
-
-export type NavLink = {
- id: string
- label: string
-}
-
-export type NavGroup = {
- id: string
- label: string
- links: NavLink[]
-}
-
-/** 侧栏顶层链接 */
-export const MOCK_NAV_PRIMARY: NavLink[] = [{ id: 'common', label: '常用推荐' }]
-
-/** 侧栏可折叠分组 */
-export const MOCK_NAV_GROUPS: NavGroup[] = [
- {
- id: 'assets',
- label: '素材资源',
- links: [
- { id: 'design', label: 'UI 设计资源' },
- { id: 'icon', label: '矢量图标' },
- { id: 'photo', label: '免费壁纸' },
- ],
- },
- {
- id: 'tools',
- label: '开发工具',
- links: [
- { id: 'dev', label: '前端调试工具' },
- { id: 'code', label: '在线编辑器' },
- ],
- },
-]
-
-/** 内容区占位分区,顺序与设计稿一致 */
-export const MOCK_SECTIONS: CatalogSection[] = [
- {
- id: 'common',
- title: '常用推荐',
- cards: [
- {
- id: 'github',
- title: 'GitHub',
- desc: '代码托管与开源社区',
- href: '#',
- initial: 'G',
- },
- { id: 'zhihu', title: '知乎', desc: '深度内容问答社区', href: '#', initial: 'Z' },
- {
- id: 'bilibili',
- title: '哔哩哔哩',
- desc: '弹幕视频分享网站',
- href: '#',
- initial: 'B',
- },
- {
- id: 'v2ex',
- title: 'V2EX',
- desc: '创意工作者讨论社区',
- href: '#',
- initial: 'V',
- },
- ],
- },
- {
- id: 'dev',
- title: '前端调试工具',
- cards: [
- {
- id: 'stackoverflow',
- title: 'Stack Overflow',
- desc: '程序员技术问答',
- href: '#',
- initial: 'S',
- },
- { id: 'juejin', title: '掘金', desc: '开发者技术社区', href: '#', initial: 'J' },
- ],
- },
- {
- id: 'code',
- title: '在线编辑器',
- cards: [
- {
- id: 'codepen',
- title: 'CodePen',
- desc: '前端在线代码演练场',
- href: '#',
- initial: 'C',
- },
- ],
- },
- {
- id: 'design',
- title: 'UI 设计资源',
- cards: [
- {
- id: 'dribbble',
- title: 'Dribbble',
- desc: '全球设计作品分享社区',
- href: '#',
- initial: 'D',
- },
- ],
- },
-]
diff --git a/stash-web/src/components/BackToTop.tsx b/stash-web/src/components/BackToTop.tsx
deleted file mode 100644
index e7b4d8f..0000000
--- a/stash-web/src/components/BackToTop.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-export function BackToTop() {
- const handleClick = () => {
- // 桌面端滚动的是内容容器,窄屏滚动的是窗口
- document.getElementById('content-scroll')?.scrollTo({ top: 0, behavior: 'smooth' })
- window.scrollTo({ top: 0, behavior: 'smooth' })
- }
-
- return (
-
- )
-}
diff --git a/stash-web/src/components/Sidebar.tsx b/stash-web/src/components/Sidebar.tsx
deleted file mode 100644
index 09dead0..0000000
--- a/stash-web/src/components/Sidebar.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { useState } from 'react'
-import type { Project } from '../api'
-import { MOCK_NAV_GROUPS, MOCK_NAV_PRIMARY } from '../catalog'
-import { ProfileCard } from './ProfileCard'
-import { ServicePanel } from './ServicePanel'
-
-type SidebarProps = {
- projects: Project[]
- live: boolean
- refreshing: boolean
- onRefresh: () => void
-}
-
-export function Sidebar({ projects, live, refreshing, onRefresh }: SidebarProps) {
- const [openGroups, setOpenGroups] = useState([])
- const [activeId, setActiveId] = useState('common')
-
- const toggle = (id: string) => {
- setOpenGroups((previous) =>
- previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id],
- )
- }
-
- const isOpen = (id: string) => openGroups.includes(id)
-
- return (
-
- )
-}
diff --git a/stash-web/src/components/SiteCard.tsx b/stash-web/src/components/SiteCard.tsx
deleted file mode 100644
index f7db7c0..0000000
--- a/stash-web/src/components/SiteCard.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { CatalogCard } from '../catalog'
-
-type SiteCardProps = {
- card: CatalogCard
- index: number
-}
-
-export function SiteCard({ card, index }: SiteCardProps) {
- return (
-
-
- {card.initial}
-
-
- {card.title}
- {card.desc}
-
-
- )
-}
diff --git a/stash-web/src/components/TopBar.tsx b/stash-web/src/components/TopBar.tsx
deleted file mode 100644
index d430298..0000000
--- a/stash-web/src/components/TopBar.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { useMemo } from 'react'
-import type { Project } from '../api'
-import { LoginIcon, SearchIcon } from './icons'
-
-type TopBarProps = {
- projects: Project[]
- live: boolean
- onFocusSearch: () => void
-}
-
-type TickerItem = {
- tag: string
- text: string
-}
-
-export function TopBar({ projects, live, onFocusSearch }: TopBarProps) {
- return (
-
- )
-}
-
-/** 顶栏跑马灯:展示连接状态、项目数量与前几个项目名。 */
-function Ticker({ projects, live }: { projects: Project[]; live: boolean }) {
- const items = useMemo(() => buildTickerItems(projects, live), [projects, live])
-
- // 复制一份实现无缝循环,动画位移为 -50%
- const loop = [...items, ...items]
-
- return (
-
-
- {loop.map((item, index) => (
-
- {item.tag}
- {item.text}
-
- ))}
-
-
- )
-}
-
-function buildTickerItems(projects: Project[], live: boolean): TickerItem[] {
- const items: TickerItem[] = [
- { tag: '状态', text: live ? '实时同步已连接' : '等待服务连接' },
- { tag: '项目', text: `共 ${projects.length} 个本地应用` },
- ]
-
- if (projects.length === 0) {
- items.push({
- tag: '提示',
- text: '把静态站点放进托管目录,并保留 index.html,就会出现在这里',
- })
- return items
- }
-
- for (const project of projects.slice(0, 6)) {
- items.push({ tag: '本地', text: project.name })
- }
-
- return items
-}
diff --git a/stash-web/src/components/common/BackToTop.tsx b/stash-web/src/components/common/BackToTop.tsx
new file mode 100644
index 0000000..49d7f8f
--- /dev/null
+++ b/stash-web/src/components/common/BackToTop.tsx
@@ -0,0 +1,33 @@
+import { ArrowUpIcon } from './Icon'
+
+/**
+ * 回到顶部:复刻 Alright 主题的 .scroll-toolbar .totop-button。
+ *
+ * - 42px 圆形、浅灰底,固定在右侧(bottom: 50%)
+ * - 默认隐藏,只有内容区滚动后才淡入,回到顶部后再淡出
+ */
+type BackToTopProps = {
+ /** 内容区是否已滚动(由 App 传入) */
+ visible: boolean
+}
+
+export function BackToTop({ visible }: BackToTopProps) {
+ const handleClick = () => {
+ // 桌面端滚动的是内容容器,窄屏滚动的是窗口,两处都滚一遍
+ document.getElementById('content-scroll')?.scrollTo({ top: 0, behavior: 'smooth' })
+ window.scrollTo({ top: 0, behavior: 'smooth' })
+ }
+
+ return (
+
+ )
+}
diff --git a/stash-web/src/components/common/CountBadge.tsx b/stash-web/src/components/common/CountBadge.tsx
new file mode 100644
index 0000000..8248eaf
--- /dev/null
+++ b/stash-web/src/components/common/CountBadge.tsx
@@ -0,0 +1,11 @@
+/**
+ * 数量角标:贴在分区标题右上角的小红标。
+ * 纯展示组件,只接收数字,宽度由内容撑开(两位数不会变形)。
+ */
+type CountBadgeProps = {
+ count: number
+}
+
+export function CountBadge({ count }: CountBadgeProps) {
+ return {count}
+}
diff --git a/stash-web/src/components/common/Icon.tsx b/stash-web/src/components/common/Icon.tsx
new file mode 100644
index 0000000..4753d76
--- /dev/null
+++ b/stash-web/src/components/common/Icon.tsx
@@ -0,0 +1,67 @@
+/**
+ * 通用图标组件。
+ *
+ * 全部使用 Alright 主题的 iconfont 线性图标(见 src/iconfont.css),
+ * 尺寸与颜色由使用处的 CSS 控制(font-size / color),
+ * 因此同一套图标可以复用在按钮、统计条、导航等不同位置。
+ */
+
+type IconProps = {
+ /** 附加类名,便于局部覆盖字号或颜色 */
+ className?: string
+}
+
+const base = (name: string, className?: string) =>
+ `iconfont icon-${name}${className !== undefined ? ` ${className}` : ''}`
+
+export function SearchIcon({ className }: IconProps) {
+ return
+}
+
+export function UserIcon({ className }: IconProps) {
+ return
+}
+
+export function RefreshIcon({ className }: IconProps) {
+ return
+}
+
+export function LinkIcon({ className }: IconProps) {
+ return
+}
+
+export function ExternalIcon({ className }: IconProps) {
+ return
+}
+
+export function EyeIcon({ className }: IconProps) {
+ return
+}
+
+export function ThumbUpIcon({ className }: IconProps) {
+ return
+}
+
+export function ShareIcon({ className }: IconProps) {
+ return
+}
+
+export function CloseIcon({ className }: IconProps) {
+ return
+}
+
+export function MoreIcon({ className }: IconProps) {
+ return
+}
+
+export function ArrowUpIcon({ className }: IconProps) {
+ return
+}
+
+export function ArrowLeftIcon({ className }: IconProps) {
+ return
+}
+
+export function ArrowDownIcon({ className }: IconProps) {
+ return
+}
diff --git a/stash-web/src/components/content/AppCard.tsx b/stash-web/src/components/content/AppCard.tsx
new file mode 100644
index 0000000..c78aa43
--- /dev/null
+++ b/stash-web/src/components/content/AppCard.tsx
@@ -0,0 +1,49 @@
+import type { AppItem } from '../../data/types'
+
+/**
+ * 应用卡片:逐项复刻 Alright 主题「推荐作者」条目的结构。
+ *
+ * 对应主题的:
+ * .flex-avatar > img.avatar-48 + span.avatar-badge.badge_5
+ * .item-content > h3.font-title.text-md(名字 + span.item-badge.hot)
+ * + div.text-xs.text-muted.h-1x(单行描述)
+ *
+ * 热门条目才会同时出现头像角标和 HOT 徽标,普通条目两者都没有。
+ */
+type AppCardProps = {
+ item: AppItem
+ onOpen?: (itemId: string) => void
+}
+
+export function AppCard({ item, onOpen }: AppCardProps) {
+ return (
+ {
+ if (onOpen === undefined) return
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
+ event.preventDefault()
+ onOpen(item.id)
+ }}
+ >
+
+ {item.cover !== undefined ? (
+
+ ) : (
+ {item.icon}
+ )}
+ {item.hot === true && }
+
+
+
+
+ {item.name}
+ {item.hot === true && }
+
+ {item.desc}
+
+
+ )
+}
diff --git a/stash-web/src/components/content/AppGrid.tsx b/stash-web/src/components/content/AppGrid.tsx
new file mode 100644
index 0000000..991a593
--- /dev/null
+++ b/stash-web/src/components/content/AppGrid.tsx
@@ -0,0 +1,24 @@
+import type { AppItem } from '../../data/types'
+import { AppCard } from './AppCard'
+
+/**
+ * 应用网格:默认 4 列,窄屏退化为 2 列。
+ * 首页预览与明细页共用,靠 modifier 调整间距。
+ */
+type AppGridProps = {
+ items: AppItem[]
+ /** 明细页使用更宽松的间距 */
+ variant?: 'home' | 'detail'
+ /** 点击卡片打开条目详情 */
+ onOpen?: (itemId: string) => void
+}
+
+export function AppGrid({ items, variant = 'home', onOpen }: AppGridProps) {
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ )
+}
diff --git a/stash-web/src/components/content/ArticleCard.tsx b/stash-web/src/components/content/ArticleCard.tsx
new file mode 100644
index 0000000..be972ec
--- /dev/null
+++ b/stash-web/src/components/content/ArticleCard.tsx
@@ -0,0 +1,36 @@
+import type { ArticleItem } from '../../data/types'
+
+/**
+ * 首页图文卡:4:3 封面 + 底部渐变条上的标题。
+ * 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
+ */
+type ArticleCardProps = {
+ item: ArticleItem
+ onOpen?: (itemId: string) => void
+}
+
+export function ArticleCard({ item, onOpen }: ArticleCardProps) {
+ const content = (
+ <>
+
+
+ {item.title}
+
+ >
+ )
+
+ if (onOpen === undefined) {
+ return {content}
+ }
+
+ return (
+
+ )
+}
diff --git a/stash-web/src/components/content/ArticleDetailView.tsx b/stash-web/src/components/content/ArticleDetailView.tsx
new file mode 100644
index 0000000..9e53bae
--- /dev/null
+++ b/stash-web/src/components/content/ArticleDetailView.tsx
@@ -0,0 +1,79 @@
+import type { ArticleItem } from '../../data/types'
+import { ArrowLeftIcon } from '../common/Icon'
+
+/**
+ * 文章详情页(对应模板图二):
+ * 大标题 + [编辑] + 元信息行 + 白色正文卡(段落 / 小标题 / 配图混排)。
+ *
+ * 正文是 block 数组,后端以后可以直接把富文本结构映射过来,
+ * 不用再改渲染层。
+ */
+type ArticleDetailViewProps = {
+ article: ArticleItem
+ categoryTitle: string
+ onBack: () => void
+}
+
+export function ArticleDetailView({ article, categoryTitle, onBack }: ArticleDetailViewProps) {
+ const meta = article.meta ?? ['2 年前', categoryTitle]
+
+ return (
+
+
+
+
{categoryTitle} / 文章
+
+
+
+
+
+ {(article.blocks ?? []).map((block, index) => {
+ if (block.type === 'heading') {
+ return (
+
+ {block.text}
+
+ )
+ }
+
+ if (block.type === 'image') {
+ return (
+
+ )
+ }
+
+ return (
+
+ {block.text}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/stash-web/src/components/content/ArticleGrid.tsx b/stash-web/src/components/content/ArticleGrid.tsx
new file mode 100644
index 0000000..2d9c154
--- /dev/null
+++ b/stash-web/src/components/content/ArticleGrid.tsx
@@ -0,0 +1,19 @@
+import type { ArticleItem } from '../../data/types'
+import { ArticleCard } from './ArticleCard'
+
+/** 图文网格:首页 4 列,窄屏 2 列 */
+type ArticleGridProps = {
+ items: ArticleItem[]
+ /** 点击卡片打开文章详情 */
+ onOpen?: (itemId: string) => void
+}
+
+export function ArticleGrid({ items, onOpen }: ArticleGridProps) {
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ )
+}
diff --git a/stash-web/src/components/content/CesCard.tsx b/stash-web/src/components/content/CesCard.tsx
new file mode 100644
index 0000000..318ec79
--- /dev/null
+++ b/stash-web/src/components/content/CesCard.tsx
@@ -0,0 +1,37 @@
+import type { ArticleItem } from '../../data/types'
+
+/**
+ * 明细页图文卡:上图下文的白卡。
+ * 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
+ */
+type CesCardProps = {
+ item: ArticleItem
+ onOpen?: (itemId: string) => void
+}
+
+export function CesCard({ item, onOpen }: CesCardProps) {
+ const content = (
+ <>
+
+
+ {item.title}
+ {item.desc}
+
+ >
+ )
+
+ if (onOpen === undefined) {
+ return {content}
+ }
+
+ return (
+
+ )
+}
diff --git a/stash-web/src/components/content/CesCardGrid.tsx b/stash-web/src/components/content/CesCardGrid.tsx
new file mode 100644
index 0000000..62eac4e
--- /dev/null
+++ b/stash-web/src/components/content/CesCardGrid.tsx
@@ -0,0 +1,19 @@
+import type { ArticleItem } from '../../data/types'
+import { CesCard } from './CesCard'
+
+/** 明细页图文网格:4 列白卡,窄屏 2 列 */
+type CesCardGridProps = {
+ items: ArticleItem[]
+ /** 点击卡片打开文章详情 */
+ onOpen?: (itemId: string) => void
+}
+
+export function CesCardGrid({ items, onOpen }: CesCardGridProps) {
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ )
+}
diff --git a/stash-web/src/components/content/DetailView.tsx b/stash-web/src/components/content/DetailView.tsx
new file mode 100644
index 0000000..69d9845
--- /dev/null
+++ b/stash-web/src/components/content/DetailView.tsx
@@ -0,0 +1,88 @@
+import { categoryCount, type Category } from '../../data/types'
+import { ArrowLeftIcon } from '../common/Icon'
+import { AppGrid } from './AppGrid'
+import { CesCardGrid } from './CesCardGrid'
+import { GroupBlock } from './GroupBlock'
+import { PosterGrid } from './PosterGrid'
+
+/**
+ * 分类明细视图:返回条 + 面包屑 + hero(图标 / 标题 / 数量)+ 内容区。
+ *
+ * 内容区按分类类型切换:
+ * - apps:指定分组时只渲染该组,否则单组扁平渲染、多组带小标题分组渲染;
+ * - posters:6 列海报;
+ * - articles:上图下文白卡网格。
+ */
+type DetailViewProps = {
+ category: Category
+ /** 从侧栏子菜单进入时指定的分组 */
+ groupId?: string
+ onBack: () => void
+ /** 点击卡片 / 海报进入条目详情 */
+ onOpenItem: (itemId: string) => void
+}
+
+export function DetailView({ category, groupId, onBack, onOpenItem }: DetailViewProps) {
+ const groups = category.kind === 'apps' ? category.groups : []
+ const activeGroup =
+ groupId !== undefined ? groups.find((group) => group.id === groupId) : undefined
+
+ const title = activeGroup?.name ?? category.title
+ const count = activeGroup !== undefined ? activeGroup.items.length : categoryCount(category)
+ const breadcrumb =
+ activeGroup !== undefined
+ ? `首页 / ${category.title} / ${activeGroup.name}`
+ : `首页 / ${category.title}`
+
+ function renderBody() {
+ if (category.kind === 'posters') {
+ return
+ }
+
+ if (category.kind === 'articles') {
+ return
+ }
+
+ if (activeGroup !== undefined) {
+ return
+ }
+
+ if (category.groups.length === 1) {
+ return
+ }
+
+ if (category.groups.length === 0) {
+ return {category.emptyText ?? '暂无内容'}
+ }
+
+ return (
+ <>
+ {category.groups.map((group) => (
+
+ ))}
+ >
+ )
+ }
+
+ return (
+
+
+
+
{breadcrumb}
+
+
+
+
+ {category.icon}
+
+
{title}
+ [{count}]
+
+
+ {renderBody()}
+
+ )
+}
diff --git a/stash-web/src/components/content/GroupBlock.tsx b/stash-web/src/components/content/GroupBlock.tsx
new file mode 100644
index 0000000..7a4c13f
--- /dev/null
+++ b/stash-web/src/components/content/GroupBlock.tsx
@@ -0,0 +1,20 @@
+import type { AppGroup } from '../../data/types'
+import { AppGrid } from './AppGrid'
+
+/**
+ * 明细页的分组块:小标题 + 该组的应用网格。
+ * 分类被拆成多个分组时重复使用,单个分组时明细页不渲染标题。
+ */
+type GroupBlockProps = {
+ group: AppGroup
+ onOpen?: (itemId: string) => void
+}
+
+export function GroupBlock({ group, onOpen }: GroupBlockProps) {
+ return (
+
+ )
+}
diff --git a/stash-web/src/components/content/HomeView.tsx b/stash-web/src/components/content/HomeView.tsx
new file mode 100644
index 0000000..b89fedf
--- /dev/null
+++ b/stash-web/src/components/content/HomeView.tsx
@@ -0,0 +1,67 @@
+import { categoryCount, flattenAppItems, type Category } from '../../data/types'
+import { AppGrid } from './AppGrid'
+import { ArticleGrid } from './ArticleGrid'
+import { PosterGrid } from './PosterGrid'
+import { SectionCard } from './SectionCard'
+
+/**
+ * 首页:把每个分类渲染成一张分区卡片。
+ *
+ * 每个分区只展示前若干条(由分类的 previewLimit 决定),
+ * 右上角 ••• 进入该分类明细,点击卡片 / 海报则进入该条目的详情。
+ */
+type HomeViewProps = {
+ categories: Category[]
+ onOpenDetail: (categoryId: string) => void
+ onOpenItem: (categoryId: string, itemId: string) => void
+}
+
+export function HomeView({ categories, onOpenDetail, onOpenItem }: HomeViewProps) {
+ return (
+
+ {categories.map((category) => (
+ onOpenDetail(category.id)}
+ >
+ {renderPreview(category, onOpenItem)}
+
+ ))}
+
+ )
+}
+
+/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
+function renderPreview(
+ category: Category,
+ onOpenItem: (categoryId: string, itemId: string) => void,
+) {
+ if (category.kind === 'apps') {
+ const items = flattenAppItems(category).slice(0, category.previewLimit ?? 4)
+
+ if (items.length === 0) {
+ return {category.emptyText ?? '暂无内容'}
+ }
+
+ return onOpenItem(category.id, itemId)} />
+ }
+
+ if (category.kind === 'posters') {
+ return (
+ onOpenItem(category.id, itemId)}
+ />
+ )
+ }
+
+ return (
+ onOpenItem(category.id, itemId)}
+ />
+ )
+}
diff --git a/stash-web/src/components/content/ItemDetailView.tsx b/stash-web/src/components/content/ItemDetailView.tsx
new file mode 100644
index 0000000..5c59f54
--- /dev/null
+++ b/stash-web/src/components/content/ItemDetailView.tsx
@@ -0,0 +1,122 @@
+import type { ItemDetailModel } from '../../data/detail'
+import type { RelatedItem } from '../../data/types'
+import { ExternalIcon, EyeIcon, ShareIcon, ThumbUpIcon } from '../common/Icon'
+import { ArrowLeftIcon } from '../common/Icon'
+import { RelatedList } from './RelatedList'
+
+/**
+ * 条目详情页:应用与电影共用。
+ *
+ * 版式:标题 + 主图(方形头像 / 竖版海报)+ 标签行 + 一句话评价 + 正文
+ * + 操作条(浏览 / 点赞 / 分享 / 跳转)+ 可选横幅 + 「推荐阅读」。
+ * 具体形态由 ItemDetailModel 决定,这里不判断条目类型。
+ */
+type ItemDetailViewProps = {
+ breadcrumb: string
+ model: ItemDetailModel
+ related: RelatedItem[]
+ onBack: () => void
+ onOpenItem: (itemId: string) => void
+}
+
+export function ItemDetailView({
+ breadcrumb,
+ model,
+ related,
+ onBack,
+ onOpenItem,
+}: ItemDetailViewProps) {
+ return (
+
+
+
+
{breadcrumb}
+
+
+
+
+ {model.title}
+
+
+
+
+ {model.cover !== undefined ? (
+

+ ) : (
+
{model.fallbackIcon ?? '•'}
+ )}
+
+
+ {model.tags.length > 0 && (
+
+ {model.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ {model.comment !== undefined && (
+
+
+ “
+
+
{model.comment}
+
+ )}
+
+ {model.paragraphs.map((paragraph, index) => (
+
+ {paragraph}
+
+ ))}
+
+
+
+
+ {model.views}
+
+
+
+
+ {model.likes}
+
+
+
+
+ {/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}
+ {model.href === '#' ? (
+
+ ) : (
+
+ 跳转到
+
+
+ )}
+
+
+ {model.banner !== undefined && (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/stash-web/src/components/content/PosterCard.tsx b/stash-web/src/components/content/PosterCard.tsx
new file mode 100644
index 0000000..c75febe
--- /dev/null
+++ b/stash-web/src/components/content/PosterCard.tsx
@@ -0,0 +1,36 @@
+import type { PosterItem } from '../../data/types'
+
+/**
+ * 竖版海报卡:2:3 封面 + 底部渐变遮罩上的标题。
+ * 传入 onOpen 时整张卡变成按钮,点击进入该电影的详情页。
+ */
+type PosterCardProps = {
+ item: PosterItem
+ onOpen?: (itemId: string) => void
+}
+
+export function PosterCard({ item, onOpen }: PosterCardProps) {
+ const content = (
+ <>
+
+
+ {item.title}
+
+ >
+ )
+
+ if (onOpen === undefined) {
+ return {content}
+ }
+
+ return (
+
+ )
+}
diff --git a/stash-web/src/components/content/PosterGrid.tsx b/stash-web/src/components/content/PosterGrid.tsx
new file mode 100644
index 0000000..f7486c5
--- /dev/null
+++ b/stash-web/src/components/content/PosterGrid.tsx
@@ -0,0 +1,20 @@
+import type { PosterItem } from '../../data/types'
+import { PosterCard } from './PosterCard'
+
+/** 海报网格:首页 6 列,明细页同样 6 列但间距更大 */
+type PosterGridProps = {
+ items: PosterItem[]
+ variant?: 'home' | 'detail'
+ /** 点击海报打开条目详情 */
+ onOpen?: (itemId: string) => void
+}
+
+export function PosterGrid({ items, variant = 'home', onOpen }: PosterGridProps) {
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ )
+}
diff --git a/stash-web/src/components/content/RelatedList.tsx b/stash-web/src/components/content/RelatedList.tsx
new file mode 100644
index 0000000..07aadd1
--- /dev/null
+++ b/stash-web/src/components/content/RelatedList.tsx
@@ -0,0 +1,45 @@
+import type { RelatedItem } from '../../data/types'
+
+/**
+ * 详情页的「推荐阅读」:两列小卡片,头像 + 名称 + 截断描述。
+ * 点击任意一条会在同一视图内切换到对应条目的详情。
+ */
+type RelatedListProps = {
+ items: RelatedItem[]
+ onOpen: (itemId: string) => void
+}
+
+export function RelatedList({ items, onOpen }: RelatedListProps) {
+ if (items.length === 0) return null
+
+ return (
+
+
+
+ 📚
+
+ 推荐阅读
+
+
+
+ {items.map((item) => (
+ -
+
+
+ ))}
+
+
+ )
+}
diff --git a/stash-web/src/components/content/SectionCard.tsx b/stash-web/src/components/content/SectionCard.tsx
new file mode 100644
index 0000000..b25bc86
--- /dev/null
+++ b/stash-web/src/components/content/SectionCard.tsx
@@ -0,0 +1,47 @@
+import type { ReactNode } from 'react'
+import { CountBadge } from '../common/CountBadge'
+import { MoreIcon } from '../common/Icon'
+
+/**
+ * 首页分区卡片:白底圆角容器,组成「色条 + 标题 + 角标 + 更多按钮 + 内容」。
+ * 内容由调用方传入,因此同一容器可以承载应用网格、海报网格或图文网格。
+ */
+type SectionCardProps = {
+ title: string
+ count: number
+ /** 标题左侧色条颜色,不同分类用不同颜色区分 */
+ barColor?: string
+ /** 点击右上角 ••• 进入该分类的明细视图 */
+ onMore?: () => void
+ children: ReactNode
+}
+
+export function SectionCard({ title, count, barColor, onMore, children }: SectionCardProps) {
+ return (
+
+
+
+
+
+ {title}
+
+
+
+
+ {onMore !== undefined && (
+
+ )}
+
+
+ {children}
+
+ )
+}
diff --git a/stash-web/src/components/header/HeaderInfoItem.tsx b/stash-web/src/components/header/HeaderInfoItem.tsx
new file mode 100644
index 0000000..e9d9410
--- /dev/null
+++ b/stash-web/src/components/header/HeaderInfoItem.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react'
+
+/**
+ * 顶栏中间的一条信息,形如「[标签] 文本」。
+ * 标签带浅色底,用于区分「本地 / 状态」这类类型前缀。
+ */
+type HeaderInfoItemProps = {
+ /** 可选的前缀标签 */
+ tag?: string
+ /** 标签配色:默认蓝色,状态类用绿色 */
+ tone?: 'blue' | 'green'
+ children: ReactNode
+}
+
+export function HeaderInfoItem({ tag, tone = 'blue', children }: HeaderInfoItemProps) {
+ return (
+
+ {tag !== undefined && (
+ {tag}
+ )}
+ {children}
+
+ )
+}
diff --git a/stash-web/src/components/header/SiteHeader.tsx b/stash-web/src/components/header/SiteHeader.tsx
new file mode 100644
index 0000000..8ed96a6
--- /dev/null
+++ b/stash-web/src/components/header/SiteHeader.tsx
@@ -0,0 +1,58 @@
+import { SearchIcon, UserIcon } from '../common/Icon'
+import { HeaderInfoItem } from './HeaderInfoItem'
+
+/**
+ * 顶部栏:左品牌、中间运行信息、右侧操作图标。
+ * 信息区展示「共 N 个本地应用 / 第一个项目 / 连接状态」,
+ * 对应模板里的 header-center-info。
+ */
+type SiteHeaderProps = {
+ projectCount: number
+ /** 用于展示的第一个项目名,没有项目时留空 */
+ sampleProject?: string
+ live: boolean
+ onFocusSearch: () => void
+}
+
+export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }: SiteHeaderProps) {
+ return (
+
+ )
+}
diff --git a/stash-web/src/components/icons.tsx b/stash-web/src/components/icons.tsx
deleted file mode 100644
index b2c4aea..0000000
--- a/stash-web/src/components/icons.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-/** 统一的小图标集合:只负责路径,尺寸与颜色由使用处的 CSS 决定。 */
-
-export function RefreshIcon() {
- return (
-
- )
-}
-
-export function SearchIcon() {
- return (
-
- )
-}
-
-export function LinkIcon() {
- return (
-
- )
-}
-
-export function LoginIcon() {
- return (
-
- )
-}
diff --git a/stash-web/src/components/SearchBar.tsx b/stash-web/src/components/search/SearchBar.tsx
similarity index 81%
rename from stash-web/src/components/SearchBar.tsx
rename to stash-web/src/components/search/SearchBar.tsx
index 7a5fa8b..1ed085c 100644
--- a/stash-web/src/components/SearchBar.tsx
+++ b/stash-web/src/components/search/SearchBar.tsx
@@ -1,5 +1,10 @@
import { useEffect, type RefObject } from 'react'
+import { CloseIcon, SearchIcon } from '../common/Icon'
+/**
+ * 全局搜索框:固定悬浮在顶栏下方居中。
+ * 支持 Ctrl/Cmd+K 聚焦、Esc 清空,输入内容由父组件接管过滤逻辑。
+ */
type SearchBarProps = {
value: string
onChange: (value: string) => void
@@ -22,10 +27,7 @@ export function SearchBar({ value, onChange, inputRef }: SearchBarProps) {
return (
-
+
onChange('')}
>
-
+
)}
diff --git a/stash-web/src/components/sidebar/CategoryNav.tsx b/stash-web/src/components/sidebar/CategoryNav.tsx
new file mode 100644
index 0000000..9f27439
--- /dev/null
+++ b/stash-web/src/components/sidebar/CategoryNav.tsx
@@ -0,0 +1,79 @@
+import { useState } from 'react'
+import { NAV_GROUPS, NAV_TOP_LEVEL, type NavTarget } from '../../data/nav'
+
+/**
+ * 分类导航树:顶层直达链接 + 可折叠分组。
+ * 点击叶子节点不跳转页面,而是把目标(分类 + 可选分组)交给父组件的 onNavigate,
+ * 由父组件切换右侧的明细视图。
+ */
+type CategoryNavProps = {
+ /** 当前明细视图的分类 / 分组,用于高亮 */
+ activeCategoryId?: string
+ activeGroupId?: string
+ onNavigate: (target: NavTarget) => void
+}
+
+export function CategoryNav({ activeCategoryId, activeGroupId, onNavigate }: CategoryNavProps) {
+ const [openGroups, setOpenGroups] = useState([])
+
+ const toggle = (id: string) => {
+ setOpenGroups((previous) =>
+ previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id],
+ )
+ }
+
+ const isOpen = (id: string) => openGroups.includes(id)
+
+ return (
+
+ )
+}
diff --git a/stash-web/src/components/ProfileCard.tsx b/stash-web/src/components/sidebar/ProfileCard.tsx
similarity index 65%
rename from stash-web/src/components/ProfileCard.tsx
rename to stash-web/src/components/sidebar/ProfileCard.tsx
index 593401f..3d22302 100644
--- a/stash-web/src/components/ProfileCard.tsx
+++ b/stash-web/src/components/sidebar/ProfileCard.tsx
@@ -1,16 +1,17 @@
-import { PLACEHOLDER_PROFILE } from '../profile'
+import { PLACEHOLDER_PROFILE } from '../../profile'
+import { UserIcon } from '../common/Icon'
-/** 登录后展示的用户资料卡(数据为占位,见 profile.ts)。 */
+/**
+ * 用户资料卡:头像、认证徽标、昵称、角色、简介、IP 属地。
+ * 数据来自 profile.ts 的占位对象,后端接入鉴权后替换。
+ */
export function ProfileCard() {
const { name, role, bio, location } = PLACEHOLDER_PROFILE
return (
diff --git a/stash-web/src/components/ServicePanel.tsx b/stash-web/src/components/sidebar/ServicePanel.tsx
similarity index 86%
rename from stash-web/src/components/ServicePanel.tsx
rename to stash-web/src/components/sidebar/ServicePanel.tsx
index 824f399..8fba9c6 100644
--- a/stash-web/src/components/ServicePanel.tsx
+++ b/stash-web/src/components/sidebar/ServicePanel.tsx
@@ -1,6 +1,10 @@
-import type { Project } from '../api'
-import { LinkIcon, RefreshIcon } from './icons'
+import type { Project } from '../../api'
+import { LinkIcon, RefreshIcon } from '../common/Icon'
+/**
+ * 本地服务面板:连接状态、项目数、访问地址,以及两个操作入口。
+ * 只读展示 + 触发刷新,不负责导航。
+ */
type ServicePanelProps = {
projects: Project[]
live: boolean
@@ -8,10 +12,6 @@ type ServicePanelProps = {
onRefresh: () => void
}
-/**
- * 本地服务面板:状态概览 + 操作入口。
- * 项目列表本身在右侧内容区以卡片呈现,这里不再展开。
- */
export function ServicePanel({ projects, live, refreshing, onRefresh }: ServicePanelProps) {
const host = typeof window === 'undefined' ? '-' : window.location.host
diff --git a/stash-web/src/components/sidebar/Sidebar.tsx b/stash-web/src/components/sidebar/Sidebar.tsx
new file mode 100644
index 0000000..9e99b81
--- /dev/null
+++ b/stash-web/src/components/sidebar/Sidebar.tsx
@@ -0,0 +1,48 @@
+import type { Project } from '../../api'
+import type { NavTarget } from '../../data/nav'
+import { CategoryNav } from './CategoryNav'
+import { ProfileCard } from './ProfileCard'
+import { ServicePanel } from './ServicePanel'
+
+/**
+ * 左侧栏容器:用户资料 + 本地服务面板 + 分类导航。
+ * 只负责布局与数据透传,具体内容由三个子组件分别渲染。
+ */
+type SidebarProps = {
+ projects: Project[]
+ live: boolean
+ refreshing: boolean
+ onRefresh: () => void
+ activeCategoryId?: string
+ activeGroupId?: string
+ onNavigate: (target: NavTarget) => void
+}
+
+export function Sidebar({
+ projects,
+ live,
+ refreshing,
+ onRefresh,
+ activeCategoryId,
+ activeGroupId,
+ onNavigate,
+}: SidebarProps) {
+ return (
+
+ )
+}
diff --git a/stash-web/src/data/catalog.ts b/stash-web/src/data/catalog.ts
new file mode 100644
index 0000000..83ea554
--- /dev/null
+++ b/stash-web/src/data/catalog.ts
@@ -0,0 +1,267 @@
+import type {
+ AppCategory,
+ AppGroup,
+ AppItem,
+ ArticleBlock,
+ ArticleCategory,
+ Category,
+ PosterCategory,
+} from './types'
+
+/**
+ * 占位内容:沿用 index4.html 模板里的示例数据。
+ *
+ * 除「本地服务」由后端项目列表动态生成外,其余分类都是静态占位,
+ * 等后端提供分类 / 收藏接口后再替换。
+ */
+
+/**
+ * 详情页占位统计:按 id 生成稳定的数值,
+ * 保证同一站点每次打开看到的是同一组数字,而不是随机跳变。
+ */
+function stat(seed: string, min: number, max: number): number {
+ let hash = 0
+ for (const char of seed) {
+ hash = (hash * 31 + char.charCodeAt(0)) % 100_000
+ }
+ return min + (hash % (max - min + 1))
+}
+
+/** 只有少数条目带 HOT 徽标 —— 主题里也不是每个作者都带 */
+const HOT_IDS = new Set(['github', 'bilibili', 'wallhaven'])
+
+/** 生成一条占位站点,详情页需要的头像 / 横幅 / 评价也一并补齐 */
+const app = (id: string, name: string, desc: string, icon: string, href = '#'): AppItem => ({
+ id,
+ name,
+ desc,
+ icon,
+ href,
+ cover: `https://picsum.photos/seed/${id}/200/200`,
+ banner: `https://picsum.photos/seed/${id}-banner/800/300`,
+ comment: '收录在本地导航里的常用站点,点开就能直达。',
+ badge: '推荐好站',
+ hot: HOT_IDS.has(id),
+ views: stat(id, 60, 980),
+ likes: stat(`${id}-like`, 3, 88),
+})
+
+/** 常用推荐:第一个分组是首页预览展示的四项 */
+const RECOMMEND_GROUPS: AppGroup[] = [
+ {
+ id: 'common',
+ name: '常用站点',
+ items: [
+ app('github', 'GitHub', '代码托管与开源社区', 'G'),
+ app('zhihu', '知乎', '深度内容问答社区', 'Z'),
+ app('bilibili', '哔哩哔哩', '弹幕视频分享网站', 'B'),
+ app('v2ex', 'V2EX', '创意工作者讨论社区', 'V'),
+ ],
+ },
+ {
+ id: 'ui',
+ name: 'UI 设计资源',
+ items: [
+ app('wallhaven', 'Wallhaven', 'WallBase 创办人失踪后推出的精选壁纸站', 'W'),
+ app('500px', '500px', '有着世界上最好的照片分享社区', '5'),
+ app('chuangyiren', '创意人', '去掉了形式主义描述的原创社区', 'C'),
+ app('topys', '顶尖文案', '全球顶尖创意分享平台 OPEN YOUR EYES', 'T'),
+ app('uichina', 'UI中国', '前身为 iconfans,国内专业的 UI 设计平台', 'U'),
+ ],
+ },
+ {
+ id: 'vector',
+ name: '矢量图标',
+ items: [
+ app('iconfont', 'Iconfont', '阿里妈妈矢量图标库', 'I'),
+ app('feather', 'Feather Icons', '简单漂亮的开源图标库', 'F'),
+ app('fontawesome', 'FontAwesome', '图标字体与 CSS 框架', 'A'),
+ ],
+ },
+ {
+ id: 'wallpaper',
+ name: '免费壁纸',
+ items: [
+ app('unsplash', 'Unsplash', '免费高清无版权摄影图片', 'U'),
+ app('pexels', 'Pexels', '才华横溢的摄影师分享的素材', 'P'),
+ app('pixabay', 'Pixabay', '超过 2.7 百万张优质图片和视频', 'X'),
+ ],
+ },
+ {
+ id: 'dev',
+ name: '开发工具',
+ items: [
+ app('codepen', 'CodePen', '前端在线代码演示和练习场', 'C'),
+ app('stackoverflow', 'Stack Overflow', '全球程序员技术问答社区', 'S'),
+ app('github-dev', 'GitHub', '代码托管与开源协作社区', 'G'),
+ ],
+ },
+]
+
+const RECOMMEND: AppCategory = {
+ id: 'recommend',
+ kind: 'apps',
+ title: '常用推荐',
+ barColor: '#3858f6',
+ icon: '🔖',
+ groups: RECOMMEND_GROUPS,
+}
+
+/** 电影占位数据:标题 + 一句简介,简介同时用作详情页正文 */
+const MOVIE_SEEDS: [string, string][] = [
+ [
+ '千与千寻 千と千尋の神隠し (2001)',
+ '少女千寻误入神灵世界,为救回变成猪的父母,在汤屋打工并逐渐学会担当。',
+ ],
+ [
+ '美丽人生 La vita è bella (1997)',
+ '二战集中营里,父亲用一场「游戏」保护儿子,让他相信世界仍然美好。',
+ ],
+ [
+ '这个杀手不太冷 Léon (1994)',
+ '孤独的职业杀手莱昂收留了邻居少女玛蒂尔达,两个边缘人相互依靠。',
+ ],
+ [
+ '阿甘正传 Forrest Gump (1994)',
+ '智商不高的阿甘凭着一股单纯的执着,意外见证并参与了几十年美国历史。',
+ ],
+ ['霸王别姬 (1993)', '两位京剧伶人半个世纪的悲欢离合,与时代洪流交织在一起。'],
+ [
+ '肖申克的救赎 The Shawshank Redemption',
+ '蒙冤入狱的银行家安迪,在漫长牢狱中守住希望并完成自救。',
+ ],
+ [
+ '泰坦尼克号 Titanic (1997)',
+ '沉船之夜,来自不同阶层的杰克与露丝相爱,也被人性的选择所考验。',
+ ],
+ ['盗梦空间 Inception (2010)', '盗梦团队潜入层层梦境,试图在目标的潜意识里植入一个念头。'],
+ [
+ '星际穿越 Interstellar (2014)',
+ '地球濒临枯竭,父亲穿越虫洞寻找新家园,与女儿跨越时间重逢。',
+ ],
+ [
+ '楚门的世界 The Truman Show (1998)',
+ '楚门渐渐发现,自己的整个人生其实是一档被全世界围观的真人秀。',
+ ],
+ ['疯狂动物城 Zootopia (2016)', '兔子警官与狐狸搭档,在动物城里侦破一桩失踪案,也打破偏见。'],
+ [
+ '大话西游之大圣娶亲 (1995)',
+ '至尊宝在爱情与宿命之间反复抉择,最终戴上金箍踏上取经路。',
+ ],
+]
+
+const MOVIES: PosterCategory = {
+ id: 'movie',
+ kind: 'posters',
+ title: '推荐电影',
+ barColor: '#ff6034',
+ icon: '🎬',
+ items: MOVIE_SEEDS.map(([title, summary], index) => {
+ const id = `movie-${index + 1}`
+ return {
+ id,
+ title,
+ cover: `https://picsum.photos/seed/m${index + 1}/300/450`,
+ tags: ['推荐电影', '豆瓣电影 Top250'],
+ comment: '收藏在本地片单里,闲下来就重看一遍。',
+ summary,
+ href: '#',
+ views: stat(id, 300, 1500),
+ likes: stat(`${id}-like`, 3, 60),
+ }
+ }),
+}
+
+const ARTICLE_SEEDS = [
+ ['可塑性极强的玻璃,在设计师手里有了更多可能', '前沿科技'],
+ ['废旧共享单车如何变成微型共享书屋?', '环保设计'],
+ ['荧光灯管与混凝土块的组合,可以玩出多少花样?', '材料探索'],
+ ['混凝土做的模块化收纳框,还能改成搁架用', '家居模块'],
+ ['这个用拉伸材料做的头盔,还能帮你呼叫救护车', '智能穿戴'],
+ ['你如何选择字体,简单易读还是好看有趣?', '视觉体验'],
+ ['家里空间小?你需要几把可以悬挂在墙上的椅子', '空间收纳'],
+ ['宜家厨房家具又被改造了,色彩是亮点', '色彩灵感'],
+ ['这款诞生 50 年的扶手椅,推出了特别版配色', '经典产品'],
+ ['让家和办公室界限变得模糊的一组家具', '办公家具'],
+ ['这个酒店设计奖,挖掘过去一年体验最好的酒店', '空间建筑'],
+ ['一款可移动办公桌,挂墙上当作白板就能写写画画', '移动办公'],
+]
+
+/** 生成演示用正文;接入真实数据后由后端提供 blocks */
+function buildArticleBlocks(title: string, desc: string, cover: string): ArticleBlock[] {
+ return [
+ {
+ type: 'paragraph',
+ text: `开放与明亮的氛围,在室内空间中尤为注重。这条以「${desc}」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。`,
+ },
+ { type: 'heading', text: '设计细节' },
+ {
+ type: 'paragraph',
+ text: `《${title}》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。`,
+ },
+ {
+ type: 'paragraph',
+ text: '在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。',
+ },
+ { type: 'image', src: cover },
+ {
+ type: 'paragraph',
+ text: '文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。',
+ },
+ ]
+}
+
+const CES: ArticleCategory = {
+ id: 'ces',
+ kind: 'articles',
+ title: 'CES 现场报道',
+ barColor: '#ffba3a',
+ icon: '🏷️',
+ items: ARTICLE_SEEDS.map(([title, desc], index) => {
+ const cover = `https://picsum.photos/seed/a${index + 1}/400/250`
+
+ return {
+ id: `ces-${index + 1}`,
+ title: `${title} | 这个设计了不起`,
+ desc,
+ cover,
+ meta: ['2 年前', 'CES 现场报道'],
+ blocks: buildArticleBlocks(title, desc, cover),
+ }
+ }),
+}
+
+/**
+ * 把后端项目列表转成「本地服务」分类。
+ * href 用真实路由,详情页的「跳转」按钮直接打开对应静态站点。
+ */
+function buildLocalCategory(items: AppItem[]): AppCategory {
+ 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 buildCategories(projects: { name: string; route_path: string }[]): Category[] {
+ const localItems: AppItem[] = projects.map((project) => ({
+ id: project.route_path,
+ 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,
+ }))
+
+ return [buildLocalCategory(localItems), RECOMMEND, MOVIES, CES]
+}
diff --git a/stash-web/src/data/detail.ts b/stash-web/src/data/detail.ts
new file mode 100644
index 0000000..f3e216a
--- /dev/null
+++ b/stash-web/src/data/detail.ts
@@ -0,0 +1,75 @@
+import type { AppItem, PosterItem, RelatedItem } from './types'
+
+/**
+ * 条目详情页的通用视图模型。
+ *
+ * 应用(方形头像 + 横幅)和电影(竖版海报 + 多标签)共用同一套版式,
+ * 差异通过 shape / tags / banner 这几个字段表达,避免维护两份几乎相同的页面。
+ */
+export type ItemDetailModel = {
+ title: string
+ /** 主图;缺失时用 fallbackIcon 显示字符块 */
+ cover?: string
+ /** 主图形态:应用用方形,电影用竖版海报 */
+ shape: 'square' | 'portrait'
+ /** 主图缺省时的字符 */
+ fallbackIcon?: string
+ /** 标签行(应用 1 个,电影 2 个) */
+ tags: string[]
+ /** 一句话评价 */
+ comment?: string
+ /** 正文段落 */
+ paragraphs: string[]
+ views: number
+ likes: number
+ href: string
+ /** 应用详情页的横幅图 */
+ banner?: string
+}
+
+/** 把应用条目转成详情模型 */
+export function appDetailModel(item: AppItem): ItemDetailModel {
+ return {
+ title: item.name,
+ cover: item.cover,
+ shape: 'square',
+ fallbackIcon: item.icon,
+ tags: item.badge !== undefined ? [item.badge] : [],
+ comment: item.comment,
+ paragraphs: [item.desc],
+ views: item.views ?? 0,
+ likes: item.likes ?? 0,
+ href: item.href,
+ banner: item.banner,
+ }
+}
+
+/** 把电影海报转成详情模型 */
+export function posterDetailModel(item: PosterItem): ItemDetailModel {
+ return {
+ title: item.title,
+ cover: item.cover,
+ shape: 'portrait',
+ tags: item.tags ?? [],
+ comment: item.comment,
+ paragraphs: item.summary !== undefined ? [item.summary] : [],
+ views: item.views ?? 0,
+ likes: item.likes ?? 0,
+ href: item.href ?? '#',
+ }
+}
+
+/** 应用条目 → 推荐阅读条目 */
+export function toRelatedItem(item: AppItem): RelatedItem {
+ return { id: item.id, name: item.name, desc: item.desc, cover: item.cover, icon: item.icon }
+}
+
+/** 电影海报 → 推荐阅读条目 */
+export function posterToRelatedItem(item: PosterItem): RelatedItem {
+ return {
+ id: item.id,
+ name: item.title,
+ desc: item.tags?.join(' · ') ?? '推荐电影',
+ cover: item.cover,
+ }
+}
diff --git a/stash-web/src/data/filter.ts b/stash-web/src/data/filter.ts
new file mode 100644
index 0000000..029fc6e
--- /dev/null
+++ b/stash-web/src/data/filter.ts
@@ -0,0 +1,49 @@
+import type { Category } from './types'
+
+/**
+ * 按关键字过滤分类内容。
+ *
+ * 应用类分类会逐组过滤并丢掉空组,海报 / 图文类按标题或描述匹配;
+ * 空关键字直接原样返回,避免无谓的对象重建。
+ */
+export function filterCategories(categories: Category[], keyword: string): Category[] {
+ const query = keyword.trim().toLowerCase()
+ if (query === '') return categories
+
+ const result: Category[] = []
+
+ for (const category of categories) {
+ if (category.kind === 'apps') {
+ const groups = category.groups
+ .map((group) => ({
+ ...group,
+ items: group.items.filter((item) => matches(query, item.name, item.desc)),
+ }))
+ .filter((group) => group.items.length > 0)
+
+ if (groups.length > 0) {
+ result.push({ ...category, groups })
+ }
+ continue
+ }
+
+ if (category.kind === 'posters') {
+ const items = category.items.filter((item) => matches(query, item.title))
+ if (items.length > 0) {
+ result.push({ ...category, items })
+ }
+ continue
+ }
+
+ const items = category.items.filter((item) => matches(query, item.title, item.desc))
+ if (items.length > 0) {
+ result.push({ ...category, items })
+ }
+ }
+
+ return result
+}
+
+function matches(query: string, ...fields: string[]): boolean {
+ return fields.some((field) => field.toLowerCase().includes(query))
+}
diff --git a/stash-web/src/data/nav.ts b/stash-web/src/data/nav.ts
new file mode 100644
index 0000000..41be8c2
--- /dev/null
+++ b/stash-web/src/data/nav.ts
@@ -0,0 +1,54 @@
+/**
+ * 侧栏分类导航的数据定义。
+ *
+ * 叶子节点通过 target 指向「分类 + 可选分组」,点击后右侧切换到对应明细。
+ * 标签保持与现有侧栏一致,避免改动已定稿的侧栏。
+ */
+
+export type NavTarget = {
+ categoryId: string
+ /** 指定后明细页只展示该分组 */
+ groupId?: string
+}
+
+export type NavLeaf = {
+ id: string
+ label: string
+ target: NavTarget
+}
+
+export type NavGroupNode = {
+ id: string
+ label: string
+ leaves: NavLeaf[]
+}
+
+/** 顶层直达链接 */
+export const NAV_TOP_LEVEL: NavLeaf[] = [
+ { id: 'recommend', label: '常用推荐', target: { categoryId: 'recommend' } },
+]
+
+/** 可折叠分组 */
+export const NAV_GROUPS: NavGroupNode[] = [
+ {
+ id: 'assets',
+ label: '素材资源',
+ leaves: [
+ { id: 'ui', label: 'UI 设计资源', target: { categoryId: 'recommend', groupId: 'ui' } },
+ { id: 'vector', label: '矢量图标', target: { categoryId: 'recommend', groupId: 'vector' } },
+ {
+ id: 'wallpaper',
+ label: '免费壁纸',
+ target: { categoryId: 'recommend', groupId: 'wallpaper' },
+ },
+ ],
+ },
+ {
+ id: 'tools',
+ label: '开发工具',
+ leaves: [
+ { id: 'dev', label: '前端调试工具', target: { categoryId: 'recommend', groupId: 'dev' } },
+ { id: 'code', label: '在线编辑器', target: { categoryId: 'recommend', groupId: 'dev' } },
+ ],
+ },
+]
diff --git a/stash-web/src/data/types.ts b/stash-web/src/data/types.ts
new file mode 100644
index 0000000..e615a0e
--- /dev/null
+++ b/stash-web/src/data/types.ts
@@ -0,0 +1,190 @@
+/**
+ * 内容数据模型。
+ *
+ * 首页把每个「分类」渲染成一张卡片,明细页按分类的 kind 渲染不同网格,
+ * 因此这里用可辨识联合把三种展示形态分开。
+ */
+
+/** 一个可点击的应用 / 站点条目 */
+export type AppItem = {
+ id: string
+ name: string
+ desc: string
+ /** 卡片左侧的字符图标 */
+ icon: string
+ /** 跳转地址;本地项目为 /<目录名>/ */
+ href: string
+ /** 详情页头像 / 封面,缺省时退化为字符图标 */
+ cover?: string
+ /** 是否为热门条目:卡片名字后显示主题同款 HOT 徽标 */
+ hot?: boolean
+ /** 详情页的一句话评价 */
+ comment?: string
+ /** 详情页横幅图 */
+ banner?: string
+ /** 详情页头像下方的标签 */
+ badge?: string
+ /** 详情页浏览量(占位数据) */
+ views?: number
+ /** 详情页点赞数(占位数据) */
+ likes?: number
+}
+
+/** 应用分组:明细页按组展示,首页取所有分组的前几项做预览 */
+export type AppGroup = {
+ id: string
+ name: string
+ items: AppItem[]
+}
+
+/** 竖版海报条目(电影) */
+export type PosterItem = {
+ id: string
+ title: string
+ cover: string
+ /** 详情页标签行,如「推荐电影」「豆瓣电影 Top250」 */
+ tags?: string[]
+ /** 详情页一句话评价 */
+ comment?: string
+ /** 详情页正文段落 */
+ summary?: string
+ /** 跳转地址(占位数据暂无真实链接) */
+ href?: string
+ views?: number
+ likes?: number
+}
+
+/** 「推荐阅读」用的轻量条目,应用与电影都能映射过来 */
+export type RelatedItem = {
+ id: string
+ name: string
+ desc: string
+ cover?: string
+ icon?: string
+}
+
+/** 文章正文块:段落 / 小标题 / 配图 */
+export type ArticleBlock =
+ | { type: 'paragraph'; text: string }
+ | { type: 'heading'; text: string }
+ | { type: 'image'; src: string }
+
+/** 图文条目(报道) */
+export type ArticleItem = {
+ id: string
+ title: string
+ desc: string
+ cover: string
+ /** 详情页标题下的元信息,如 ['2 年前', 'CES 现场报道'] */
+ meta?: string[]
+ /** 详情页正文 */
+ blocks?: ArticleBlock[]
+}
+
+/** 应用类分类:内部按分组组织 */
+export type AppCategory = {
+ id: string
+ kind: 'apps'
+ title: string
+ /** 标题左侧色条颜色,各分类用不同颜色区分 */
+ barColor: string
+ /** 明细页 hero 区的字符图标 */
+ icon: string
+ groups: AppGroup[]
+ /** 首页预览条数,默认 4 */
+ previewLimit?: number
+ /** 首页预览为空时的提示文案 */
+ emptyText?: string
+}
+
+/** 海报类分类:竖版海报网格 */
+export type PosterCategory = {
+ id: string
+ kind: 'posters'
+ title: string
+ barColor: string
+ icon: string
+ items: PosterItem[]
+ /** 首页预览条数,默认 6 */
+ previewLimit?: number
+}
+
+/** 图文类分类:上图下文卡片网格 */
+export type ArticleCategory = {
+ id: string
+ kind: 'articles'
+ title: string
+ barColor: string
+ icon: string
+ items: ArticleItem[]
+ /** 首页预览条数,默认 4 */
+ previewLimit?: number
+}
+
+export type Category = AppCategory | PosterCategory | ArticleCategory
+
+/** 分类内的条目总数,用于角标与明细页计数 */
+export function categoryCount(category: Category): number {
+ if (category.kind === 'apps') {
+ return category.groups.reduce((total, group) => total + group.items.length, 0)
+ }
+ return category.items.length
+}
+
+/** 把应用类分类的分组拍平,首页预览用 */
+export function flattenAppItems(category: AppCategory): AppItem[] {
+ return category.groups.flatMap((group) => group.items)
+}
+
+/** 按 id 在分类里查找条目;详情页用 */
+export function findAppItem(category: Category, itemId: string): AppItem | undefined {
+ if (category.kind !== 'apps') return undefined
+
+ for (const group of category.groups) {
+ const found = group.items.find((item) => item.id === itemId)
+ if (found !== undefined) return found
+ }
+ return undefined
+}
+
+/** 取同分类下的其他条目,用于详情页的「推荐阅读」 */
+export function relatedAppItems(
+ category: Category,
+ excludeId: string | undefined,
+ limit = 4,
+): AppItem[] {
+ if (category.kind !== 'apps') return []
+
+ return flattenAppItems(category)
+ .filter((item) => item.id !== excludeId)
+ .slice(0, limit)
+}
+
+/** 按 id 查找海报条目 */
+export function findPosterItem(
+ category: Category,
+ itemId: string,
+): PosterItem | undefined {
+ if (category.kind !== 'posters') return undefined
+ return category.items.find((item) => item.id === itemId)
+}
+
+/** 取同分类下的其他海报,用于详情页的「推荐阅读」 */
+export function relatedPosterItems(
+ category: Category,
+ excludeId: string | undefined,
+ limit = 4,
+): PosterItem[] {
+ if (category.kind !== 'posters') return []
+
+ return category.items.filter((item) => item.id !== excludeId).slice(0, limit)
+}
+
+/** 按 id 查找图文条目 */
+export function findArticleItem(
+ category: Category,
+ itemId: string,
+): ArticleItem | undefined {
+ if (category.kind !== 'articles') return undefined
+ return category.items.find((item) => item.id === itemId)
+}
diff --git a/stash-web/src/iconfont.css b/stash-web/src/iconfont.css
new file mode 100644
index 0000000..a55507e
--- /dev/null
+++ b/stash-web/src/iconfont.css
@@ -0,0 +1,74 @@
+/**
+ * 图标字体:取自 Alright 主题自带的 iconfont(Project id 4809463)。
+ * 字体文件放在 public/fonts/iconfont.woff2,这里只保留项目用到的字形,
+ * 避免把整份 500 多个图标的样式表打进包里。
+ */
+@font-face {
+ font-family: 'iconfont';
+ src: url('./assets/iconfont.woff2') format('woff2');
+ font-display: swap;
+}
+
+.iconfont {
+ display: inline-block;
+ font-family: 'iconfont' !important;
+ font-style: normal;
+ font-weight: normal;
+ line-height: 1;
+ text-rendering: auto;
+ speak: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-search::before {
+ content: '\e942';
+}
+
+.icon-user::before {
+ content: '\e966';
+}
+
+.icon-refresh::before {
+ content: '\e93d';
+}
+
+.icon-link::before {
+ content: '\ea37';
+}
+
+.icon-external::before {
+ content: '\e8be';
+}
+
+.icon-eye::before {
+ content: '\e8fd';
+}
+
+.icon-thumb-up::before {
+ content: '\e957';
+}
+
+.icon-share::before {
+ content: '\e946';
+}
+
+.icon-close::before {
+ content: '\e8e4';
+}
+
+.icon-more::before {
+ content: '\e91f';
+}
+
+.icon-arrow-up::before {
+ content: '\e8c1';
+}
+
+.icon-arrow-left::before {
+ content: '\e8b5';
+}
+
+.icon-arrow-down::before {
+ content: '\e8b7';
+}
diff --git a/stash-web/src/index.css b/stash-web/src/index.css
index a7de93e..ab5a4cb 100644
--- a/stash-web/src/index.css
+++ b/stash-web/src/index.css
@@ -1,24 +1,37 @@
:root {
- --bg-color: #f7f9fb;
+ /* ---------- 颜色:取自 Alright 主题的设计变量 ---------- */
+ --bg-color: #f7f8fa;
--card-bg: #ffffff;
- --primary-color: #3b82f6;
- --primary-soft: #eff6ff;
- --text-main: #1e293b;
- --text-sub: #64748b;
- --text-muted: #94a3b8;
- --border-color: #e2e8f0;
+ --primary-color: #3858f6;
+ --primary-soft: rgba(56, 88, 246, 0.1);
+ --text-main: #1d2129;
+ --text-sub: #4e5969;
+ --text-muted: #86909c;
+ --border-color: #f6f6f6;
+ --border-strong: #eeeeee;
+ --danger: #ff3e3e;
+ --orange: #ffba3a;
+ --green: #4abd86;
+
+ --radius-sm: 6px;
--radius-main: 12px;
- --shadow-card: 0 4px 12px rgba(0, 0, 0, 0.03), 0 1px 3px rgba(0, 0, 0, 0.02);
- --shadow-hover: 0 10px 20px rgba(0, 0, 0, 0.06), 0 4px 6px rgba(0, 0, 0, 0.03);
+ --radius-lg: 24px;
+
+ /* 主题的阴影很轻:0 6px 16px rgba(27,26,49,2%) */
+ --shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
+ --shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06);
--sidebar-width: 240px;
--top-nav-height: 60px;
/* 内容区最大宽度,宽屏时整体居中 */
- --layout-max-width: 1280px;
+ --layout-max-width: 1380px;
+ /* 内容列顶部让位高度(给悬浮搜索框),侧栏首卡与内容首卡都按它对齐 */
+ --content-top-offset: 52px;
+ /* 主题正文用霞鹜文楷(与演示站同一份 webfont),缺失时回退系统字体 */
--font-sans:
- -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
- 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
+ 'LXGW WenKai', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+ 'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans CN', sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace;
font-family: var(--font-sans);
diff --git a/stash-web/src/main.tsx b/stash-web/src/main.tsx
index bef5202..af55199 100644
--- a/stash-web/src/main.tsx
+++ b/stash-web/src/main.tsx
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
+import './iconfont.css'
import './index.css'
import App from './App.tsx'
diff --git a/templates/index2.html b/templates/index2.html
new file mode 100644
index 0000000..b117b3d
--- /dev/null
+++ b/templates/index2.html
@@ -0,0 +1,499 @@
+
+
+
+
+
+ 服务与推荐列表完整布局
+
+
+
+
+
+
+
+
+
+
+
+
+
G
+
+
git_helper
+
/git_helper/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+

+
+
美丽人生 La vita è bella (1997)
+
+
+
+

+
+
+
+

+
+
阿甘正传 Forrest Gump (1994)
+
+
+
+

+
+
+
+

+
+
肖申克的救赎 The Shawshank...
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
可塑性极强的玻璃,在设计师手里有了更多可能
+
+
+
+

+
+
+
+

+
+
荧光灯管与混凝土块的组合,可以玩出多少花样?
+
+
+
+

+
+
+
+

+
+
这个用拉伸材料做的头盔,还能帮你呼叫救护车
+
+
+
+

+
+
+
+
+
+

+
+
家里空间小?你需要几把可以悬挂在墙上的椅子
+
+
+
+

+
+
+
+

+
+
+
+

+
+
+
+

+
+
这个酒店设计奖,挖掘过去一年体验最好的酒店
+
+
+
+

+
+
一款可移动办公桌,挂墙上当作白板就能写写画画
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/index3.html b/templates/index3.html
new file mode 100644
index 0000000..3caf41b
--- /dev/null
+++ b/templates/index3.html
@@ -0,0 +1,920 @@
+
+
+
+
+
+ STASH - 完整联动与版心对齐布局
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
G
+
+
git_helper
+
/git_helper/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+

+
+
美丽人生 La vita è bella (1997)
+
+
+
+

+
+
+
+

+
+
阿甘正传 Forrest Gump (1994)
+
+
+
+

+
+
+
+

+
+
肖申克的救赎 The Shawshank...
+
+
+
+
+
+
+
+
+
+
+
+

+
+
可塑性极强的玻璃,在设计师手里有了更多可能
+
+
+
+

+
+
+
+

+
+
荧光灯管与混凝土块的组合,可以玩出多少花样?
+
+
+
+

+
+
+
+

+
+
这个用拉伸材料做的头盔,还能帮你呼叫救护车
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/index4.html b/templates/index4.html
new file mode 100644
index 0000000..0a21273
--- /dev/null
+++ b/templates/index4.html
@@ -0,0 +1,1378 @@
+
+
+
+
+
+ STASH - 完整联动 dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
G
+
+
git_helper
+
/git_helper/
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+

+
+
+
+

+
+
+
+

+
+
+
+

+
+
+
+

+
+
+
+
+
+
+
+
+
+
+

+
+
+
+

+
+
+
+

+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file