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 (
-
- + ) } 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.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 ( -
-
- - - STASH - - - - -
- - {/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */} - -
-
-
- ) -} - -/** 顶栏跑马灯:展示连接状态、项目数量与前几个项目名。 */ -function Ticker({ projects, live }: { projects: Project[]; live: boolean }) { - const items = useMemo(() => buildTickerItems(projects, live), [projects, live]) - - // 复制一份实现无缝循环,动画位移为 -50% - const loop = [...items, ...items] - - return ( - - ) -} - -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