feat: add AirShelf core implementation

This commit is contained in:
zyc
2026-06-05 10:21:40 +08:00
parent 2ba1058329
commit cfdcd84a30
252 changed files with 70828 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# Exact Design To React Conversion
> 日期:2026-05-29
> 目标:先把 `电商AI平台` / `public/exact` 设计稿完整纳入正式 React 路由,确保视觉还原优先于业务接口继续扩展。
## 当前实现
- `public/exact/*.html`:保留为设计对照基线和截图 diff 来源。
- `src/routes/exact-html.ts`:由脚本生成,内嵌 21 个 exact 页面文档。
- `src/routes/exact-document.tsx`:React 承载层,负责加载 exact 文档、隔离页面样式、接管页面间导航和登录注册提交。
- `src/routes/exact-pages/*.tsx`:每个页面对应一个正式 TSX 包装文件。
- `scripts/generate-exact-html.mjs`:从 `public/exact` 重新生成 React exact 文档与页面包装。
## 已覆盖页面
登录、注册、工作台、商品库、商品详情、商品创建入口、项目列表、新建项目、Pipeline、资产库、账户、团队、消息、图片工具、图片优化、模特图、模特图方案 A/B、平台套图、设置。
## 验收结果
验证命令:
```bash
npm run build
```
Playwright 真实浏览器截图对照输出:
```text
output/playwright/exact-react-final-20260529103829/
```
关键结果:
- 登录 / 注册:0 像素差。
- Dashboard / 商品库 / 项目列表 / Pipeline / 资产库 / 账户 / 团队 / 消息 / AI 工具页:与 exact 基线基本一致,差异为 0 到极低比例。
- `product-create-upload.html` 是原型里的跳转 stub,实际落到商品库 drawer,因此差异高于其他页面,但属于原型跳转页本身的动态行为差异。
## 后续规则
1. 修改页面前先改 `public/exact` 或确认设计基线,再运行生成脚本。
2. 业务 API 接入时,不直接破坏 exact 视觉层;先在承载层或组件层做数据替换。
3. 生视频真实测试仍放最后,不在页面还原阶段触发。
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="icon" href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="%23fa5d19"/><text x="16" y="21" text-anchor="middle" font-family="Arial" font-size="12" fill="white">AS</text></svg>' />
<title>AirShelf</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1721
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "airshelf-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@vitejs/plugin-react": "^5.1.1",
"vite": "^7.2.7",
"typescript": "^5.9.3",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"lucide-react": "^0.561.0"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+7
View File
@@ -0,0 +1,7 @@
Dockerfile
.dockerignore
.git
.gitignore
.DS_Store
*.md
node_modules
+10
View File
@@ -0,0 +1,10 @@
# ---- Runtime Stage (no build — pure static HTML/CSS/JS) ----
FROM docker.m.daocloud.io/nginx:alpine
RUN sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY . /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+53
View File
@@ -0,0 +1,53 @@
# 电商AI平台 · Airshelf
AI 短视频带货生成平台 · 静态设计稿(V2.1 Restraint)。
## 快速浏览
直接用浏览器打开任意 HTML,或本地起服务:
```bash
npx http-server . -p 8080
# 然后访问 http://localhost:8080/
```
入口页:[index.html](index.html)(工作台)
## 页面索引
| 页面 | 文件 | 说明 |
| --- | --- | --- |
| 工作台 | [index.html](index.html) | 进入后首屏 · 4 块 KPI + 最近项目 + 快捷入口 |
| 视频项目 | [projects.html](projects.html) | 项目列表 · 表格 + 状态 + 进度 |
| 新建项目 | [projects-new.html](projects-new.html) | 4 步向导 · 含实时预估面板 + 推荐气泡 |
| 商品库 | [products.html](products.html) | SKU 列表 |
| 创建商品 | [product-create.html](product-create.html) | 4 步向导 + 子流程(挑模特 → 生上身) |
| 流水线 | [pipeline.html](pipeline.html) | 5 阶段:脚本 → 资产 → 故事板 → 片段 → 拼接 |
| 资产库 | [library.html](library.html) | 人物 / 场景 / 视频片段管理 |
| 账户 | [account.html](account.html) | 余额 / 充值 / 消费明细 |
| 设计系统 | [design-system.html](design-system.html) | 完整 V2.1 token + 组件 + 状态 + Modal/Toast |
## 设计规范
详见 [DESIGN_SPEC_V2.md](DESIGN_SPEC_V2.md) · 当前 V2.1。
**核心视觉特征:**
- 配色:冷灰底 `#f9f9f9` + 单橙 accent `#FA5D19`(Firecrawl-aligned)
- 圆角:统一 8 px / pill 999 px / 微元素 4 px
- Icon:SVG line · stroke 1.5 · linecap round · `currentColor` 继承
- 签名元素:容器装订线(左右 1 px 边)+ 四角 22×21 px SVG 准星(圆弧内凹)
- 字体:Inter Tight + Inter + Alibaba PuHuiTi
- 主 CTA:橙底 + 4 层橙阴影(全场唯一允许阴影的组件)
## 目录结构
```
电商AI平台/
├── README.md ← 本文档
├── DESIGN_SPEC_V2.md ← 设计规范 source of truth
├── design-system.html ← 可交互组件参考(token + 状态 demo)
├── assets/
│ ├── restraint.css ← V2.1 共享样式 · 无 V1 legacy alias
│ └── shell.js ← sidebar/topbar/Toast/Modal 渲染器
└── *.html ← 8 个页面
```
+54
View File
@@ -0,0 +1,54 @@
# 已归档/保留的 HTML 文件说明
> 2026-05-28 · 主流程收口记录
下列早期版本或独立实验页**不在主流程上**,已归档到
[`_archive/deprecated-pages-20260528/`](_archive/deprecated-pages-20260528/)。
根目录半成品 Next.js scaffold 已归档到
[`../_archive/root-next-20260528/`](../_archive/root-next-20260528/)。
保留本文件用于说明历史路径,避免后续误改旧页面。
## ✅ 已归档(无主流程入口)
| 文件 | 原用途 | 现状 |
|------|--------|------|
| `_archive/deprecated-pages-20260528/product-create.legacy.html` | 旧版「新建商品」全屏页(3883 行 drawer) | 已被 `product-create.html` stub + drawer 模式替代 |
| `_archive/deprecated-pages-20260528/product-create-v2.html` | 备选「新建商品」实验版 | 仅供参考 |
| `_archive/deprecated-pages-20260528/studio.html` | 早期工作室页占位 | 已被 `pipeline.html` 替代 |
| `_archive/deprecated-pages-20260528/studio-v2.html` | 早期工作室 V2 实验 | 已被 `pipeline.html` 替代 |
| `_archive/deprecated-pages-20260528/product-studio.html` | 商品 + 工作室合并实验 | 主流程已拆为 `product-detail.html` + `pipeline.html` |
| `../_archive/root-next-20260528/` | 根目录 Next.js 半成品 | 静态 HTML 才是当前 source of truth |
## ⚠️ 仍保留在主流程/辅助流程
| 文件 | 决定 |
|------|------|
| `product-create.html` | **保留** · stub 重定向,处理外部书签/直接访问 → 弹 drawer |
| `product-create-upload.html` | **保留** · 商品图上传专用辅助页,通过 drawer 内部调用 |
| `model-photo.html` / `platform-cover.html` | **保留** · 「AI 生成素材」二级页(从商品库 gen-choice 跳入) |
| `asset-factory.html` | **保留** · sidebar 入口「图片生成」 |
| `design-system.html` | **保留** · 设计系统参考,非用户路径 |
## 📌 sidebar NAV 当前入口对照(assets/shell.js:10-43)
```
工作台 → index.html ✓
商品库 → products.html ✓
视频项目 → projects.html ✓
图片生成 → asset-factory.html ✓
资产库 → library.html ✓
团队 → team.html ✓
消费 → account.html ✓
设置 → settings.html ✓
```
未在 sidebar 的"白名单"页面(从其他页面 link 进入):
- login.html / register.html(auth flow · 顶级入口)
- projects-new.html(项目向导 · 工作台「+新建项目」入口)
- pipeline.html(流水线 · 项目列表「打开」入口)
- product-detail.html(商品详情 · 商品库卡片入口)
- product-create.html stub / product-create-upload.html(drawer 流程辅助)
- model-photo.html / platform-cover.html(AI 素材生成入口)
- design-system.html(设计参考 · 非用户路径)
@@ -0,0 +1,952 @@
# 流·Studio 设计规范 · V2.1
> **版本:** V2.1(2026-05-15)
> **风格代号:** Restraint(克制)· Firecrawl-aligned
> **适用范围:** 流·Studio 全产品(工作台 / 项目 / 商品库 / 流水线 / 资产库 / 编辑器 / 账户)
> **V2.1 更新:** 色彩系统全面对齐 Firecrawl 实测——**冷灰底色**(放弃米白)· **主橙 #FA5D19**(从 #E55B26 调亮)· **20 档 black-alpha**(从 11 档扩展)· **5 色 accent 多彩点**(新增)· accent-black 替代 ink。保留 V2 所有结构性决策(8 px 圆角、inside-border、装订线、mono 装饰)。
> **与 V2 关系:** V2.1 兼容 V2 的所有 token 命名(`--ink` `--bg` `--card` `--green` `--red` 等保留为 legacy 别名),代码层无需重写。
---
## 0. 变更速查
### V2 → V2.1(本次 · 色彩系统对齐 Firecrawl)
| # | 维度 | V2 | V2.1 |
| - | ---- | -- | ---- |
| C1 | 底色 | `#FAF9F5` 暖米白 | **`#f9f9f9` 冷灰**(`--background-base`) |
| C2 | 主橙 | `#E55B26` 砖红 | **`#FA5D19`** 更红更饱和 |
| C3 | 文字基色 | `--ink: #15140F` | **`--accent-black: #262626`**(更柔和)· `--ink` 作 legacy 别名 |
| C4 | Alpha 阶梯 | 11 档 `--ink-alpha-*` | **20 档 `--black-alpha-*`**(1/2/3/4/5/6/7/8/10/12/16/20/24/32/40/48/56/64/72/88) |
| C5 | Alpha base | 全部用 `rgba(0,0,0,...)` | **32% 起换 `rgba(38,38,38,...)`**(避免叠出"灰中带蓝") |
| C6 | 状态色 | `--green #3F6B3F` 深森林绿 / `--red #B33A2A` 暗砖红 | **`--accent-forest #42c366`** / **`--accent-crimson #eb3424`**(信号灯感) |
| C7 | Accent 多彩 | 仅橙+绿+红 | **新增 5 色** amethyst/bluetron/crimson/forest/honey(仅用于语义信号) |
| C8 | 边框 | `#EFEBE0` 暖色 | **`#ededed`** 冷灰(无色相) |
| C9 | Selection | 未定义 | **`background: var(--heat-20)` + `color: var(--heat)`**(Firecrawl 签名) |
### V1 → V2(上次 · 结构性变更,保留)
| # | 维度 | V1 | V2 | 决策类型 |
| - | ---- | -- | -- | -------- |
| Δ1 | 圆角 | 大容器 0 / 按钮 9 / pill 999 | **大容器 8 / 按钮 8 / pill 999 / dot 999** | **变更** |
| Δ2 | mono `[ STATUS ]` | 大量使用 | **保留**(品牌签名) | 保留 |
| Δ3 | 字体 | Inter | **Inter Tight + 字重 500 替代 450**(免费) | 变更(轻) |
| Δ4 | 边框 | 真 `border:1px` | **`::before` inside-border**(hover 不抖动) | 变更 |
| Δ5 | 主容器左右垂直边 | 无 | **加 `border-x` + 四角准星**(图纸装订线) | 新增 |
| Δ6 | 主 CTA 阴影 | 全场无 | **4 层橙色发光** | 变更(小幅) |
---
## 1. 设计哲学
**一句话:** 一台精密设备的工作面板。
**三条铁律:**
1. **克制大于装饰** — 留白 > 容器 > 内容,大量空气感。
2. **单色锚点** — 全场只有一种 accent(橙色),且只用于 CTA / 关键状态 / 强调单词。
3. **结构清晰可见** — 用 **1 px 边框 + 8 px 圆角 + 四角准星 + 容器纵向装订线 + mono `[ ]` 标签**暴露"图纸感",而非阴影/渐变隐藏结构。
**避免的"AI 味":**
- 渐变铺面 / 玻璃拟态 / 彩色阴影
- 多色 emoji 图标
- 圆角无差别(全部 8px / 16px 的 SaaS 模板感)
- 卡片浮在背景上的"贴纸感"
- 装饰盖过内容(场记板 / 霓虹 / 丝绒幕布)
**新增禁令:**
-**0 px 硬切角的卡片** —— V2 起所有结构性容器都用 8 px 圆角
-**变深 hue 的 hover 色** —— 橙色 hover 用 alpha 阶梯,不用更深的橙
---
## 2. 色彩系统(V2.1 · 对齐 Firecrawl)
### 2.1 表面 / 背景(冷灰 · 无色相)
| Token | Hex | 用途 |
| ---------------------- | --------- | --------------------------- |
| `--background-base` | `#f9f9f9` | 页面底色 · 冷灰 |
| `--background-lighter` | `#fbfbfb` | 容器底色 / hover 浅底 |
| `--surface` | `#ffffff` | 卡片 / 容器表面 |
| `--surface-raised` | `#ffffff` | 浮层 / Modal 表面 |
> **Legacy 别名(V2 → V2.1):** `--bg = var(--background-base)` / `--bg-soft = var(--background-lighter)` / `--card = var(--surface)`。组件 CSS 不用改。
### 2.2 边框(冷灰 · 3 档差距极小)
| Token | Hex | 用途 |
| ---------------- | --------- | --------------------- |
| `--border-faint` | `#ededed` | **默认 1 px 边框 ★ 80% 场景** |
| `--border-muted` | `#e8e8e8` | 略深 |
| `--border-loud` | `#e6e6e6` | 最深(强分隔) |
> **设计意图:** 3 档之间只差 1–2 个色阶,**肉眼几乎看不出**。**用语义(faint/muted/loud)选择,不用视觉对比选择。** 这是 Firecrawl 的细节哲学。
### 2.3 Heat · 主橙(单 hue + 8 档 alpha)
| Token | Value | 用途 |
| ----------- | ----------------------- | ----------------------------- |
| `--heat` | `#fa5d19` | **主橙 100% · CTA / 链接 ★** |
| `--heat-90` | `rgba(250,93,25,.90)` | hover(替代 V1 `#D04E1F`) |
| `--heat-40` | `rgba(250,93,25,.40)` | focus ring / 边框次级 |
| `--heat-20` | `rgba(250,93,25,.20)` | pill 边框 / **selection 底色 ★** |
| `--heat-16` | `rgba(250,93,25,.16)` | hover 软底 |
| `--heat-12` | `rgba(250,93,25,.12)` | tint 底(active nav / icon-box) |
| `--heat-8` | `rgba(250,93,25,.08)` | |
| `--heat-4` | `rgba(250,93,25,.04)` | 极弱底 |
> **从 V2 `#E55B26` 调亮到 V2.1 `#FA5D19`** —— 更红更饱和,与 Firecrawl 100% 实测一致。**hover 永远不换 hue,只换 alpha。**
### 2.4 Accent · 5 色信号(新增)
> **作用:** 仅用于**语义信号**——代码高亮、图标色、状态色源。**禁止做大面积背景或装饰**。全场仍只有 1 个 accent(橙)。
| Token | Hex | 用途 |
| ------------------ | --------- | ----------------------------- |
| `--accent-black` | `#262626` | **主前景**(替代 V2 `--ink #15140F`,更柔和) |
| `--accent-white` | `#ffffff` | 反色文字(在橙底 / 黑底上) |
| `--accent-amethyst`| `#9061ff` | 紫 · 代码 property |
| `--accent-bluetron`| `#2a6dfb` | 蓝 · info |
| `--accent-crimson` | `#eb3424` | 红 · **error / 失败 ★** |
| `--accent-forest` | `#42c366` | 绿 · **success / 成功 ★** |
| `--accent-honey` | `#ecb730` | 黄 · warning |
> **Legacy 别名:** `--ink = var(--accent-black)` / `--green = var(--accent-forest)` / `--red = var(--accent-crimson)`。
### 2.5 Black-Alpha 阶梯(20 档 · 核心工具尺)
> **替代 V2 的 11 档 ink-alpha**。这是日常用得最多的 token。024% 用 `rgba(0,0,0,...)`;**32% 起换 `rgba(38,38,38,...)`**(=`--accent-black` 作底),避免叠出"灰中带蓝"——Firecrawl 实测细节。
| Token | Light 值 | Dark 值 | 典型用途 |
| ------------------ | ---------------------- | ---------------------- | ----------------------- |
| `--black-alpha-1` | `rgba(0,0,0,.01)` | `rgba(255,255,255,.01)`| 极弱底 |
| `--black-alpha-2` | `rgba(0,0,0,.02)` | `rgba(255,255,255,.02)`| |
| `--black-alpha-3` | `rgba(0,0,0,.03)` | `rgba(255,255,255,.03)`| |
| `--black-alpha-4` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.04)`| **hover bg ★** |
| `--black-alpha-5` | `rgba(0,0,0,.05)` | `rgba(255,255,255,.05)`| tab 间分隔条 |
| `--black-alpha-6` | `rgba(0,0,0,.06)` | `rgba(255,255,255,.06)`| |
| `--black-alpha-7` | `rgba(0,0,0,.07)` | `rgba(255,255,255,.07)`| **active bg ★** |
| `--black-alpha-8` | `rgba(0,0,0,.08)` | `rgba(255,255,255,.08)`| |
| `--black-alpha-10` | `rgba(0,0,0,.10)` | `rgba(255,255,255,.10)`| |
| `--black-alpha-12` | `rgba(0,0,0,.12)` | `rgba(255,255,255,.12)`| **inside-border ★** |
| `--black-alpha-16` | `rgba(0,0,0,.16)` | `rgba(255,255,255,.16)`| |
| `--black-alpha-20` | `rgba(0,0,0,.20)` | `rgba(255,255,255,.20)`| |
| `--black-alpha-24` | `rgba(0,0,0,.24)` | `rgba(255,255,255,.24)`| input hover 边框 |
| `--black-alpha-32` | `rgba(38,38,38,.32)` | `rgba(255,255,255,.32)`| ← base 切换 → ↓ |
| `--black-alpha-40` | `rgba(38,38,38,.40)` | `rgba(255,255,255,.40)`| |
| `--black-alpha-48` | `rgba(38,38,38,.48)` | `rgba(255,255,255,.48)`| **占位字色 ★** |
| `--black-alpha-56` | `rgba(38,38,38,.56)` | `rgba(255,255,255,.56)`| **次级文字 / 未选中 Tab ★** |
| `--black-alpha-64` | `rgba(38,38,38,.64)` | `rgba(255,255,255,.64)`| 描述文字 |
| `--black-alpha-72` | `rgba(38,38,38,.72)` | `rgba(255,255,255,.72)`| 强次级 |
| `--black-alpha-88` | `rgba(38,38,38,.88)` | `rgba(255,255,255,.88)`| 近主前景 |
> **V2 → V2.1 兼容映射(legacy 别名,组件 CSS 不必改):**
> - `--ink-alpha-4` → `--black-alpha-4`
> - `--ink-alpha-7` → `--black-alpha-7`
> - `--ink-alpha-12` → `--black-alpha-12`
> - `--ink-alpha-24` → `--black-alpha-24`
> - `--ink-alpha-32` → `--black-alpha-32`
> - `--ink-alpha-48` → `--black-alpha-48`
> - `--ink-alpha-56` → `--black-alpha-56`
> - `--ink-alpha-64` → `--black-alpha-64`
> - `--ink-alpha-72` → `--black-alpha-72`
> - `--ink-alpha-88` → `--black-alpha-88`
### 2.6 状态色配套底/边
| 含义 | 主色 | 配套底色(8% alpha) | 配套边框(20% alpha) |
| ---- | -------------------------- | --------------------------- | --------------------------- |
| 成功 | `--green` (`#42c366`) | `rgba(66,195,102,.08)` | `rgba(66,195,102,.20)` |
| 失败 | `--red` (`#eb3424`) | `rgba(235,52,36,.08)` | `rgba(235,52,36,.20)` |
| 信息 | `--heat` (`#fa5d19`) | `--heat-12` | `--heat-20` |
| 警告 | `--accent-honey`(`#ecb730`)| `rgba(236,183,48,.08)` | `rgba(236,183,48,.20)` |
### 2.7 Selection · 文字选中色(Firecrawl 签名)
```css
::selection {
background: var(--heat-20);
color: var(--heat);
}
```
选中任何文字时,底色 20% 橙、文字 100% 橙。这是 Firecrawl 易被忽略但**整站感知到位**的细节。
---
## 3. 字体系统(V2.1 · 中英混排策略)
### 3.1 字体族 · Inter + 阿里巴巴普惠体
| 用途 | 字体声明 |
| ------------ | ------------------------------------------------------------------------------------------------- |
| **正文 / UI**| `'Inter', 'Alibaba PuHuiTi', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif` |
| **纯英文场景** | `'Inter', system-ui, sans-serif`(`--font-inter`)· 用于"Ctrl K"等强制纯英文徽标 |
| **Mono 装饰** | `'JetBrains Mono', 'Geist Mono', ui-monospace, monospace` |
**核心策略 · 浏览器字符级 fallthrough:**
```
Inter ────────→ 英文 / 数字 / 符号(命中)
↓ (CJK 不命中,继续找)
Alibaba PuHuiTi ──→ 中文(命中)
↓ (字体未加载到)
PingFang SC / Microsoft YaHei ──→ 系统中文兜底
```
浏览器对每个字符**逐个查找** font-family 链,Inter 不含 CJK 字形 → 中文字符自动跳到下一个候选。这是中英混排的标准做法,**不需要 JS、不会字重错位**。
**V2 → V2.1 → V2.1 变更轨迹:**
- V2: `Inter Tight` → 中文走系统 fallback → 字重错位
- V2.1 (前一版): 单一 `Alibaba PuHuiTi` → 英文用普惠体英文字形 → 英文略圆,缺乏 Inter 的"科技感"
- **V2.1 (本版)** : **Inter(英)+ Alibaba PuHuiTi(中)双字体协作** → 各自处理最擅长的语种
**为什么这个组合最优:**
- Inter:Vercel / Linear / Stripe 御用,**工程产品默认审美**。专门给屏幕 UI 优化,数字字形漂亮(同宽)。中文不擅长。
- Alibaba PuHuiTi:阿里出品,免费商用,**为中英混排专门设计的笔画粗细配比**(45/55/65/85 多档),中文笔画与 Inter 视觉重量贴近。专门给中文优化。
- 两者结合:**英文有 Inter 的锐利,中文有普惠体的清晰**,字重之间衔接自然。
**Inter 载入:**
```html
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
```
**Alibaba PuHuiTi 载入:**
```css
@font-face {
font-family: 'Alibaba PuHuiTi';
font-weight: 400; /* 55 Regular */
src: local('Alibaba PuHuiTi 3.0'),
local('AlibabaPuHuiTi-3-55-Regular'),
url('https://chinese-fonts-cdn.deno.dev/packages/alibaba_puhuiti/dist/AlibabaPuHuiTi-3-55-Regular/AlibabaPuHuiTi-3-55-Regular.woff2') format('woff2');
}
/* 同写法处理 500 Medium / 600 SemiBold / 700 Bold */
```
> **优先级:** 本地安装(设计师机器一般装了)→ CDN 加载 → 系统中文兜底。**永远不会出现假字或方框。**
**特殊场景 · 强制纯英文用 `--font-inter`:**
某些场景必须确保用 Inter(比如 "Ctrl K" 这种快捷键徽标想要 Inter Bold 的紧凑感),直接用专属变量:
```css
.search-wrap .k {
font-family: var(--font-inter); /* 跳过 fallback 链,锁定 Inter */
font-weight: 700;
font-size: 11.5px;
letter-spacing: 0.02em;
}
```
Mono 字体保留 `JetBrains Mono`——用作装饰元素 `[ 200 OK ]` `// 05.14` `/v2`,**不参与中文**。
### 3.2 字号 / 字重 / 行高(V2.1 整体放大半档,行高提升)
| 角色 | 字号 | 字重 | 字距 | 行高 | 用途 |
| ---------------- | ------- | ---- | ---------- | ---- | ---------------- |
| H1 / Hero | 36 px | 500 | -0.024em | 1.2 | 页面主标题(从 V2 的 32 上调) |
| 区块 H2 | 28 px | 500 | -0.02em | 1.25 | section-head 标题 |
| KPI 数值 | 32 px | 500 | -0.02em | 1.1 | 统计大数字 |
| 子区 H3 | 16 px | 500 | -0.01em | 1.4 | subsection 标题(从 15 上调) |
| 卡片标题 | 14 px | 500 | normal | 1.4 | 项目名 / 商品名(从 13.5 上调) |
| 正文 body | 14 px | 400 | normal | **1.65** | 默认正文(行高从 1.5 上调) |
| 区块描述 | 14 px | 400 | normal | 1.75 | section-head 描述 |
| 子区 lead | 13 px | 400 | normal | 1.8 | 子区下方说明 |
| Label(按钮/Tab)| 13 px | 500 | normal | 1.4 | 按钮文字 / Tab |
| Pill 文字 | 11.5 px | 500 | normal | 1.3 | 状态徽标 |
| Mono 标签 | 1111.5 px | 400/500 | 0.04em | 1.5 | `[ STATUS ]` |
| Mono 散点 | 8.5 px | 400 | 0.04em | 1 | 背景 ASCII 装饰 |
**字重档位仍仅 3 档:400 / 500 / 600**。普惠体 500(Medium 65)的中文笔画比 Inter 500 重一些,**整体不显单薄**,因此 600 几乎不需要。
**关键属性:**
- 数值类必须加 `font-variant-numeric: tabular-nums`
- 标题用 negative letter-spacing(-0.01 ~ -0.024em)
- 正文行高 **1.651.8**(V2 是 1.51.7),中文字间留呼吸
---
## 4. 圆角规则 · V2 统一 8 px
> **核心原则(V2 改写):统一 8 px / 状态徽标完全圆 / 极少数微元素降到 4–6 px**
| 元素类型 | 圆角值 | 例子 |
| ------------------------------ | ---------- | ------------------------------- |
| 所有结构性容器(大卡片 / 区块) | **8 px** | `.stats` `.list-card` `.shortcut` `.tip` `.modal` |
| 所有按钮 / 输入框 | **8 px** | `.btn` `.pill-btn` `.icon-btn` `.search` |
| nav 项 | **8 px** | `nav a`(V1 是 7,V2 统一) |
| 缩略图 / 画面占位 | **8 px** | `.thumb` `.ic` |
| 头像 / 小色块 | **6 px** | `.av` `.team .p`(可选 8) |
| Mono 标签 / badge / kbd | **4 px** | `.kbd` `.badge` |
| 进度条段位 | **2 px** | `.prog span` |
| Pill 状态徽标 / dot | **999 px** | `.pill` `.dot`(完全圆) |
**为什么改:** Firecrawl 实测全站统一 8 px,工程感来自"准星 + 装订线 + mono 装饰",**不是硬切角**。0 圆角容器在小尺寸下会显得"卡顿、廉价",8 px 是同时兼顾"图纸感"和"成品感"的最佳值。
---
## 5. 边框 / 阴影 / 描边
### 5.1 边框策略 · V2 改为 inside-border
> **V2 改进:** 默认边框用 `::before` 伪元素绘制,而非真 `border`。原因:hover 时让 `::before` 透明度 → 0 不会触发布局抖动。
**通用工具类:**
```css
.inside-border {
position: relative;
}
.inside-border::before {
content: '';
position: absolute;
inset: 0;
border: 1px solid var(--border-faint);
border-radius: inherit;
pointer-events: none;
transition: opacity .2s ease, border-color .2s ease;
}
.inside-border:hover::before { opacity: 0; }
```
**层级:**
- 默认:`var(--border-faint)`
- 略深:`var(--border-muted)`(主分隔线)
- 最深:`var(--border-loud)`(强分隔,少用)
- **禁止 2px / 3px 实线**
- **虚线**仅用于 `.tip` 提示框:`1px dashed var(--border-faint)`
### 5.2 阴影 · V2 引入主 CTA 专属橙色发光
**默认无阴影**(V1 规则保留)。
**新增例外 · 主 CTA 4 层橙色阴影**(替代 V1 的"全场无"):
```css
.btn-primary {
box-shadow:
inset 0 -4px 8px rgba(250, 93, 25, 0.20), /* 内阴影:底部暗一点 = 立体 */
0 1px 1px rgba(250, 93, 25, 0.12),
0 2px 4px rgba(250, 93, 25, 0.10),
0 0.5px 0.5px rgba(250, 93, 25, 0.16);
}
.btn-primary:hover {
box-shadow:
inset 0 -4px 8px rgba(250, 93, 25, 0.20),
0 1px 1px rgba(250, 93, 25, 0.16),
0 4px 8px rgba(250, 93, 25, 0.20), /* 更高更亮 */
0 0.5px 0.5px rgba(250, 93, 25, 0.16);
}
```
**Toast 阴影**(V1 保留):`0 4px 20px rgba(21,20,15,0.06)`,白色调,不属于橙色发光体系。
### 5.3 容器四角"+"准星(签名元素 · V2 升级为 SVG)
V1 用字符 `+`(font-family JetBrains Mono);V2 升级为 SVG 路径,带圆弧内凹,质感更工程化:
```html
<svg width="22" height="21" viewBox="0 0 22 21" fill="none" class="corner">
<path d="M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z" fill="var(--border-muted)"/>
</svg>
```
**位置:** 容器四角,中心精确落在边框交点上(用 `-translate-x-1/2 -translate-y-1/2`)。
**字符版本兼容:** 简单卡片(如 modal 内嵌)仍可用 `content: '+'` 字符版,但全屏主容器必须用 SVG。
---
## 6. 主内容容器 · 新增装订线规则(V2 新增章节)
> **核心签名:** 主工作区始终被两条 1 px 垂直边线包夹,配合四角准星,形成图纸装订线效果。
```html
<div class="workbench-container">
<svg class="corner top-left">...</svg>
<svg class="corner top-right">...</svg>
<svg class="corner bottom-left">...</svg>
<svg class="corner bottom-right">...</svg>
<!-- content -->
</div>
```
```css
.workbench-container {
max-width: 1480px;
margin: 0 auto;
border-left: 1px solid var(--border-faint);
border-right: 1px solid var(--border-faint);
position: relative;
/* 上下边线不要,只左右 */
}
.workbench-container .corner {
position: absolute;
width: 22px; height: 21px;
}
.workbench-container .corner.top-left { top: -10.5px; left: -11px; }
.workbench-container .corner.top-right { top: -10.5px; right: -11px; }
.workbench-container .corner.bottom-left { bottom: -10.5px; left: -11px; }
.workbench-container .corner.bottom-right { bottom: -10.5px; right: -11px; }
```
适用范围:工作台 / 项目列表页 / 商品库 / 流水线主区。**编辑器全屏画布、Modal 内不必加。**
---
## 7. 间距 / 栅格(V2.1 全局放大,Firecrawl-level 呼吸)
**基础栅格:** 4 px
**常用间距阶梯:** 4 / 8 / 12 / 16 / 20 / 24 / 28 / 32 / 40 / 48 / 64 / 80 / 104
> **V2 → V2.1 变更:** 全局垂直间距系统性放大 30%。原因:Alibaba PuHuiTi 中文笔画比 Inter 厚一档,**字体密度提升 → 必须用更大的留白补偿**,否则视觉拥挤。这就是为什么 Firecrawl 的间距看起来"奢侈"——它的字体也是出版级偏紧凑的 Suisse Intl,留白补偿到位。
**主区块布局:**
- 侧边栏宽度:`248 px`(无变)
- **主内容 padding:`72 px 80 px 120 px`**(从 V2 的 `48 56 56` 上调)
- 内容最大宽度:`1280 px`(从 1480 收窄,留更多自然边距)
- 区块间垂直:**`104 px`**(`section margin-bottom` · 从 80 上调)
- 子区间垂直:**`64 px`**(`subsection margin-bottom` · 从 48 上调)
- 子区标题底距:**`22 px`**(`h3 → 内容` · 从 16 上调)
- 卡片网格间距:`24 px`(主区) / `14 px`(子区)
- 卡片内 padding:
- 大卡片 / stats: **`28 px 30 px`**(从 22 24 上调)
- 列表行: **`20 px 24 px`**(从 14 18 上调)
- 快捷入口: **`18 px 20 px`**(从 14 上调)
- Modal 头/体: **`24 px 28 px`**(从 20 24 上调)
- Hero / section-head: **`52 px 56 px`** / `padding-bottom: 28 px`
**最重要的一条:** 别再吝啬空气。**当不确定 padding 是 16 还是 24 时,选 24。** 当不确定 margin 是 48 还是 64 时,选 64。
**间距/字号配对原则:**
- 标题旁边的描述/副标:`mb 1014 px`(紧凑组合)
- 标题下方的正文/列表:`mb 2228 px`(分组组合)
- section 顶部 mono-tag → h2:`mb 14 px`
- h2 → section description:`mb 12 px`
- section-head 整体下方边线:`pb 28 px / mb 44 px`
---
## 8. 背景:制图纸网格(V1 保留)
### 8.1 三层叠加
```
图层 1(最上):主交叉点 "+" 准星 SVG — 240×240 重复
图层 2(中间):子交叉点小圆点 — 60×60 重复
图层 3(最下):虚线网格 — 240×240 重复(stroke-dasharray: 1.5 4)
```
**配色:**
- "+" 准星:`#B8B3A4`(stroke 1 px)
- 小点:`#CFCABB`(r=0.9)
- 虚线:`#E2DED2`(stroke 1 px)
### 8.2 视觉聚焦遮罩
```css
mask-image: radial-gradient(ellipse 95% 80% at 50% 35%, #000 25%, transparent 95%);
```
### 8.3 装饰散点(Mono ASCII)
主区域 4 个固定位置撒 ASCII 散点。字号 8.5 px / 颜色 `--ink-alpha-12` / 透明度 0.8 / `pointer-events: none`
### 8.4 边角 Mono 标签(品牌签名 · 保留)
主区域 4 个角各放一个 Mono 标签:
```
左上 [ 200 OK ] 右上 [ /v2 ]
左下 [ .MP4 · 9:16 ] 右下 [ STUDIO ]
```
字号 10.5 px / 颜色 `--ink-alpha-48` / 字距 0.06em。
> **作用:** 让页面看起来像「在某个开发环境 / 调试视图里」,而不是普通官网。Firecrawl **没有**这个元素,这是流·Studio 的独特品牌资产。
---
## 9. Icon 系统(V2 强化章节,直接对应用户优化诉求 ①)
### 9.1 通用规则
- **格式:** 一律 SVG inline,**禁止** `<img>` 引图标
- **库选择:** 推荐 Lucide(line icon,1.52 px stroke)或 Phosphor Regular
- **stroke width:** 统一 **1.5 px**(替代 V1 提到的 1.8)
- **stroke linecap / linejoin:** `round`
- **填充:** **不填充**(纯 line icon)
- **颜色:** 通过 `stroke="currentColor"`,继承父元素 `color`
- **emoji 禁用:** 任何场景都不允许彩色 emoji
### 9.2 尺寸阶梯
| 场景 | 尺寸 | 用途 |
| ------------------- | ------- | ----------------------------- |
| Icon-S | 14 px | 内嵌 inline 文字旁 |
| Icon-M(默认) | 16 px | 按钮内 / Tab / list 行 |
| Icon-L | 20 px | 顶栏 / 快捷入口 / dropdown 触发器 |
| Icon-XL | 24 px | Modal 头部 / Toast / 空状态 |
| Icon-Hero | 36 px | 空状态插画 / 大占位 |
### 9.3 颜色规则
| 场景 | 颜色 |
| ------------ | --------------------- |
| 默认 | `--ink-alpha-56` |
| Hover | `--ink` |
| Active / 选中| `--heat` |
| Disabled | `--ink-alpha-12` |
| 在主 CTA 内 | `#FFFFFF` |
### 9.4 Icon-Box(快捷入口左侧的方块图标容器)
```css
.icon-box {
width: 32px;
height: 32px;
border-radius: 8px; /* V1 是 0,V2 改 8 */
background: var(--heat-12);
display: grid;
place-items: center;
}
.icon-box svg { width: 16px; stroke: var(--heat); }
```
---
## 10. 组件规范 · 含完整状态(V2 大幅扩充,对应用户诉求 ②③④)
### 10.1 按钮(3 种类型 × 5 种状态 × 3 种尺寸)
**类型:**
| 类型 | 背景 | 文字 | 边框/描边 |
| ----------------- | ---------------- | ------------------ | ---------------------------------- |
| `.btn` 默认 | `--card` 白 | `--ink` | inside-border `--ink-alpha-12` |
| `.btn-primary` 主 | `--heat` | `#FFFFFF` | 无,靠橙色阴影分层 |
| `.btn-ghost` 无框 | transparent | `--ink-alpha-56` | 无 |
**尺寸:**
| 尺寸 | 高度 | padding | 字号 | icon 尺寸 |
| ------ | ----- | ------------ | ------- | --------- |
| `-sm` | 28 px | `0 10 px` | 12 px | 14 px |
| 默认 | 32 px | `0 14 px` | 13 px | 16 px |
| `-lg` | 40 px | `0 18 px` | 14 px | 16 px |
**5 种状态(每种类型都要实现):**
| 状态 | `.btn` 默认 | `.btn-primary` 主 |
| -------- | ------------------------------------------ | ---------------------------------------------- |
| Default | 白底 / inside-border `--ink-alpha-12` | 橙底 + 4 层橙阴影 |
| Hover | 底色 `--ink-alpha-4` + 边框 opacity → 0 | 阴影第 3 层加亮(`0 4px 8px rgba(229,91,38,0.20)`) |
| Active(按下) | 底色 `--ink-alpha-7` + `scale(0.99)` | `scale(0.995)` + 阴影 inset 加深 |
| Focused | 外层 2 px `--heat-40` ring,offset 2 px | 同左 |
| Disabled | 底 `--bg-soft` + 文字 `--ink-alpha-12` + `cursor: not-allowed` + 无 hover | 底 `--heat-40` + 文字 `#FFFFFF` + 阴影消失 + `cursor: not-allowed` |
**过渡:** 全场统一 `transition: background-color 200ms, opacity 200ms, transform 100ms, box-shadow 100ms ease`
### 10.2 Pill(状态徽标)· 严格分级(对应用户诉求 ②)
> **V2 核心改进:Pill 分 3 级,同级别尺寸完全一致**
| 级别 | 高度 | padding | 字号 | 圆角 | dot 尺寸 | 用途 |
| ------------ | ----- | ------------ | ------- | ----- | -------- | --------------------- |
| **L1 大胶囊** | 28 px | `0 12 px` | 13 px | 999 px| 8 px | 项目状态 / 列表行 |
| **L2 中胶囊** | 22 px | `0 10 px` | 11.5 px | 999 px| 6 px | **默认 / 通用** |
| **L3 小胶囊** | 18 px | `0 8 px` | 10.5 px | 999 px| 5 px | KPI 卡角标 / Mono 标签 |
**色调(3 种,通用所有级别):**
| 状态 | 文字色 | 底色 | 边框(20% alpha)|
| ---- | --------------- | ------------- | ---------------- |
| info | `--heat` | `--heat-12` | `--heat-20` |
| ok | `--green` | `--green-bg`(`#3F6B3F14`) | `--green-bd`(`#3F6B3F33`) |
| err | `--red` | `--red-bg`(`#B33A2A14`) | `--red-bd`(`#B33A2A33`) |
每个 pill 前置圆点(`.dot`,直径同上表,颜色继承文字色)。
**HTML 写法:**
```html
<span class="pill pill-l2 pill-info">
<span class="dot"></span>
生成中
</span>
```
### 10.3 输入框 / 搜索(V2.1 · 含 Firecrawl 式快捷键提示)
**尺寸:****36 px**(从 V2 的 32 上调,中文留白更舒展)/ padding `0 14 px` / 字号 14 px / 圆角 8 px
**状态:**
| 状态 | 边框 | 底色 |
| -------- | --------------------------------- | --------------------- |
| Default | inside-border `--black-alpha-12` | `--card` |
| Hover | inside-border `--black-alpha-24` | `--card` |
| Focused | inside-border `--heat-40` + inset 1 px | `--card` |
| Error | inside-border `--red` | `--red-bg` |
| Disabled | inside-border `--black-alpha-12` | **`--black-alpha-5`**(从 `--bg-soft` 改 · 冷灰底太接近白,看不出禁用) |
占位字色 `--black-alpha-48`,disabled 占位 `--black-alpha-24`
**带图标 / 快捷键搜索框(参考 Firecrawl 实测):**
```
[🔍] [ 搜索任意内容... ] Ctrl K
│ │
│ 16px line icon, color: --black-alpha-56 │ mono · 11.5px · --black-alpha-48
│ 左 14 px,**z-index: 2**(关键) │ 右 14 px,**z-index: 2**
│ │ 平铺文本(无边框盒),不用 kbd 样式
```
**关键坑(已修):**
1. **搜索 icon 看不见** —— `<input>` 的白色 bg 会盖住同级的 SVG icon。**SVG 必须加 `z-index: 2`** 才能抬到 input 之上。同理 Ctrl K 提示也要 z-index。
2. **快捷键不用 `⌘`** —— JetBrains Mono webfont 不带 U+2318 `⌘` 字形,会显示成方框。**Windows 用户体系用 "Ctrl K" 纯文本**;Mac 端要显示 ⌘ 时用 SVG command 图标(见 §9.4)。
3. **快捷键提示不要用 kbd 边框盒** —— Firecrawl 是平铺灰色 mono 文本,无任何 border / bg / radius。**显得克制,符合"克制大于装饰"。**
### 10.4 KPI 统计行(`.stats`)
- 1 行 4 格,共用一个 inside-border 容器,圆角 8 px,**无 gap**
- 列与列之间用 `border-right: 1px solid var(--border-faint)` 分隔(最后一列去掉)
- 容器四角加 SVG "+" 准星
- 每格结构:label + L3 pill → 大数字(30 px) → delta / progress
### 10.5 列表行(`.list-row`)
```
[缩略图 54×70] [标题 + meta] [进度条 5 段] [L2 pill] [按钮 -sm]
```
- grid 5 列:`54px 1fr auto auto auto`
- 行高 padding `14 px 18 px`
- 行间 1 px 分隔线(`--border-faint`),最后行去掉
- **整行 hover** → `--ink-alpha-4` 底色
- **整行 active**(键盘选中)→ `--ink-alpha-7` 底色
- **整行 disabled** → opacity 0.5 + `cursor: not-allowed`
### 10.6 进度条段位(流水线 5 阶段专用)· **V2.1 语义色重写**
5 个 `18×5 px` 小段,3 px 间距,**每段颜色映射真实业务状态**(替代 V2 的"全灰已完成"——那样像项目失败):
| 状态 | 颜色 | 视觉处理 |
| -------- | --------------------- | ------------------------------ |
| 未开始 | `--black-alpha-8` | 极弱灰底 · 静态 |
| **已完成** | `--accent-forest`(`#42c366`)| **绿色 · 静态**(替代 V2 灰色) |
| **进行中** | `--heat`(`#fa5d19`) | **橙色 + 1.4s 脉动**(opacity 1↔0.55 + scaleY 1↔0.7)· 区别于失败 |
| 失败 | `--accent-crimson`(`#eb3424`)| 红色 · 静态 |
圆角:每段 2 px。
**关键设计:**
- **绿/橙/红/灰** 四色一眼可辨:已完成的绿色让"5/5 全绿"=完美交付,**不会再有"全灰像失败了"的误读**
- **橙色脉动动画** 是区分"进行中"与"失败"的关键——红色永远静态,只有橙色会呼吸,潜意识上"动 = 在运行,静 = 出错"
```css
.prog span.now {
background: var(--heat);
animation: prog-pulse 1.4s ease-in-out infinite;
}
@keyframes prog-pulse {
0%,100% { opacity: 1; transform: scaleY(1); }
50% { opacity: .55; transform: scaleY(.7); }
}
```
### 10.7 快捷入口卡(`.shortcut`)
- 白底 / inside-border / **8 px 圆角**(V1 是 0) / 14 px padding
- 左侧 32×32 橙色 tint 图标块(`--heat-12` 底 / 8 px 圆角 / 居中 16 px line icon)
- 右侧:标题(13 px 500)+ Mono 描述(11.5 px,`--ink-alpha-48`)
- **Default → Hover → Active 完整状态:**
- Hover:底色 `--ink-alpha-4` / 标题 underline-from-orange 1 px(可选)
- Active(点击瞬间):scale(0.99)
- Focused(键盘):2 px `--heat-40` ring
### 10.8 提示框(`.tip`)
- 白底 / **1 px 虚线**边框(`dashed`)/ **8 px 圆角**
- 加粗标题独立一行 + 正文
- 内联代码用 `.mono` 类:橙色文字 + `--heat-12` 底 + 4 px 圆角
### 10.9 Toast
- 右下角 24 px 偏移 / 白底 / inside-border / **8 px 圆角**
- 唯一允许的"白阴影":`0 4px 20px rgba(21,20,15,0.06)`
- 进入动画:`translateX(420px → 0)`,缓动 `cubic-bezier(0.34, 1.56, 0.64, 1)`,300 ms
- 自动消失:2400 ms
- 内容结构:左侧 24×24 橙色 tint 图标(`--heat-12` 底)+ 右侧标题 + Mono 副文本(`[ 200 OK ]`)
### 10.10 弹窗(Modal)
- 居中,460480 px 宽,白底,**8 px 圆角**(V1 是 0)
- 四角加 SVG "+" 准星
- 遮罩 `rgba(21,20,15,0.42)`,带 `backdrop-filter: blur(8px)`
- 进入动画:`scale(0.96 → 1)`,250 ms 弹性缓动
- 三段结构:
- **Header:** 36 px 橙色 tint 图标 + 标题 + Mono 副标
- **Body:** 13 px / `--ink-alpha-56` / 行高 1.7
- **Footer:** 右对齐两个按钮(取消 + 主 CTA)
- ESC 关闭 / 点击遮罩关闭
### 10.11 Tab(双层)
**主 Tab:**
- 高 36 px / padding `0 14 px` / 字号 13 px / 字重 500
- 未选中:`--ink-alpha-56` 文字
- 选中:`--ink` 文字 + 底部 2 px `--heat` 横线(整个 tab 宽度)
- Hover(未选中态):`--ink-alpha-7` 底色
**副 Tab(过滤型):**
- 高 28 px / padding `0 10 px` / 字号 12 px
- 未选中:`--ink-alpha-56` + 灰度 icon
- 选中:`--ink` + 多色 icon(grayscale: 0)
- Tab 之间用 1 px 高 12 px 的 `--ink-alpha-7` 竖条分隔(可选)
### 10.12 Dropdown / Select
- 触发器:同输入框样式(高 32 / 圆角 8 / inside-border)
- 右侧 16 px chevron-down icon,色 `--ink-alpha-48`,展开时旋转 180°
- 弹层:白底 / 8 px 圆角 / `0 4px 20px rgba(21,20,15,0.06)` 阴影 / **唯一允许阴影场景之二**
- 选项:高 32 / padding `0 12 / hover `--ink-alpha-4` / 选中 `--heat-12` + 右侧 14 px checkmark `--heat`
- 分组分隔:1 px `--border-faint`
### 10.13 Checkbox / Radio / Switch(V2.1 · 全部用 SVG icon)
**通用原则:** 所有 indicator 都用真 SVG(via background-image data URI 或 inline `<svg>`),**禁止用 border-width / transform: rotate(45deg) 凑对勾**——那种 CSS hack 在缩放/字体渲染下会变形。
**Checkbox:**
- **容器:** 16×16 / 4 px 圆角 / inside-border `--black-alpha-24`(hover 时 → `--black-alpha-48`)
- **Checked:** `--heat` 底 · 居中 12×12 白色 SVG checkmark(`<path d="M20 6 9 17l-5-5"/>` · stroke 3 · linecap round)
- **Indeterminate:** `--heat` 底 · 居中 12×12 白色 SVG 横线(`<path d="M5 12h14"/>` · stroke 3)
- **Disabled:** 底 `--black-alpha-5` + 边 `--black-alpha-12` + icon 不渲染或半透明
- **实现方式:** `background-image: url("data:image/svg+xml,...")` · `background-size: 12px 12px` · `background-position: center` · 这样不需要 ::after 凑出 icon,SVG 是真实的
**Radio:**
- 16×16 圆 / inside-border `--black-alpha-24`
- Checked:内嵌 8×8 `--heat` 实心圆(纯几何形状,不算 icon,用 ::after 即可)
- Disabled:底 `--black-alpha-5`
**Switch:**
- 容器 28×16 / 999 px 圆角
- Off:底 `--black-alpha-12` / 圆球 `#FFFFFF` 12×12 + 1 px subtle shadow
- On:底 `--heat` / 圆球右移到 `left: 14px`
- 过渡:`background 200 ms`,圆球位移 `left 200 ms`
> **为什么不用字符 `✓` `✗` 这种 Unicode:** 不同系统、不同字体对这些字符的字形支持差异巨大(`⌘` 在 JetBrains Mono Web 字体里就是缺字形 → 显示成方框)。**SVG 是唯一可靠的解。**
---
## 11. 微观细节(品牌签名 · V1 保留)
### 11.1 标签全部包方括号
`[ 200 OK ]` `[ .MP4 · 9:16 ]` `[ /v2 ]` `[ /sidebar collapse ]`
### 11.2 时间戳像代码注释
`// 05.14 · 周五`
### 11.3 数值后缀
`¥327` 主体大字 + `.40` 小字次级(`<small>`)
### 11.4 强调单词上色
正文里 `<b style="color: var(--ink)">3 个项目</b>` —— 让具体数值/名词比周围文字更深一档(不是变橙)。**橙色只留给 CTA。**
### 11.5 ASCII 字符做装饰
`↑ 本月 +3` `↓ -1.2%`
### 11.6 链接式"更多"按钮
`[ ALL · 12 ] →` —— Mono 标签 + 箭头,色 `--ink-alpha-48`,hover 转橙。
### 11.7 缩略图不放图,放比例
`9:16` Mono 字符占位。
---
## 12. Don't List(绝对禁止)
-**0 px 卡片**(V1 → V2 反转)
- ❌ 渐变背景(只有 hero 区可考虑,但首选纯色)
- ❌ 玻璃拟态(`backdrop-filter` 只用于 modal 遮罩)
- ❌ 彩色 emoji 图标(用 SVG line icon,1.5 px stroke)
- ❌ 多个 accent 色(全场只有橙色)
- ❌ 大圆角容器(>12 px 直接判错)
- ❌ 灰色阴影 / 文字阴影(只允许橙色主 CTA 阴影 + Toast/Dropdown 白阴影)
- ❌ 鲜艳的状态色(避免荧光绿、电光蓝、霓虹粉)
- ❌ 居中对齐大段正文(全部左对齐)
- ❌ 把装饰当主角(场记板、丝绒、霓虹灯)
- ❌ 无意义的微动效(hover 旋转、缩放、彩虹流光)
-**hover 时换深 hue 的橙**(用 alpha)
-**真 `border` + hover 边框消失**(用 inside-border ::before)
-**同一行混用直角和圆角**(用户原话:"不要有些是直角,胶囊又是圆角")
---
## 13. Sass / CSS Token 速查表(V2.1)
```css
:root {
/* ============================================================
Color system V2.1 · Firecrawl-aligned
============================================================ */
/* === 表面 / 背景(冷灰)=== */
--background-base: #f9f9f9;
--background-lighter: #fbfbfb;
--surface: #ffffff;
--surface-raised: #ffffff;
/* === 边框(冷灰 · 3 档)=== */
--border-faint: #ededed; /* 默认 1 px */
--border-muted: #e8e8e8;
--border-loud: #e6e6e6;
/* === Accent 多彩(5 色信号)=== */
--accent-black: #262626;
--accent-white: #ffffff;
--accent-amethyst: #9061ff;
--accent-bluetron: #2a6dfb;
--accent-crimson: #eb3424;
--accent-forest: #42c366;
--accent-honey: #ecb730;
/* === Heat · 单 hue + 8 档 alpha === */
--heat: #fa5d19;
--heat-90: rgba(250, 93, 25, .90);
--heat-40: rgba(250, 93, 25, .40);
--heat-20: rgba(250, 93, 25, .20);
--heat-16: rgba(250, 93, 25, .16);
--heat-12: rgba(250, 93, 25, .12);
--heat-8: rgba(250, 93, 25, .08);
--heat-4: rgba(250, 93, 25, .04);
/* === Black-Alpha 阶梯(20 档 · 024 用 #000,32+ 用 #262626)=== */
--black-alpha-1: rgba(0, 0, 0, .01);
--black-alpha-2: rgba(0, 0, 0, .02);
--black-alpha-3: rgba(0, 0, 0, .03);
--black-alpha-4: rgba(0, 0, 0, .04);
--black-alpha-5: rgba(0, 0, 0, .05);
--black-alpha-6: rgba(0, 0, 0, .06);
--black-alpha-7: rgba(0, 0, 0, .07);
--black-alpha-8: rgba(0, 0, 0, .08);
--black-alpha-10: rgba(0, 0, 0, .10);
--black-alpha-12: rgba(0, 0, 0, .12);
--black-alpha-16: rgba(0, 0, 0, .16);
--black-alpha-20: rgba(0, 0, 0, .20);
--black-alpha-24: rgba(0, 0, 0, .24);
--black-alpha-32: rgba(38, 38, 38, .32);
--black-alpha-40: rgba(38, 38, 38, .40);
--black-alpha-48: rgba(38, 38, 38, .48);
--black-alpha-56: rgba(38, 38, 38, .56);
--black-alpha-64: rgba(38, 38, 38, .64);
--black-alpha-72: rgba(38, 38, 38, .72);
--black-alpha-88: rgba(38, 38, 38, .88);
/* === Legacy aliases(V2 命名 → V2.1 token,组件 CSS 无需重写)=== */
--bg: var(--background-base);
--bg-soft: var(--background-lighter);
--card: var(--surface);
--ink: var(--accent-black);
--green: var(--accent-forest);
--red: var(--accent-crimson);
--green-bg: rgba(66, 195, 102, .08);
--green-bd: rgba(66, 195, 102, .20);
--red-bg: rgba(235, 52, 36, .08);
--red-bd: rgba(235, 52, 36, .20);
--ink-alpha-4: var(--black-alpha-4);
--ink-alpha-7: var(--black-alpha-7);
--ink-alpha-12: var(--black-alpha-12);
--ink-alpha-24: var(--black-alpha-24);
--ink-alpha-32: var(--black-alpha-32);
--ink-alpha-48: var(--black-alpha-48);
--ink-alpha-56: var(--black-alpha-56);
--ink-alpha-64: var(--black-alpha-64);
--ink-alpha-72: var(--black-alpha-72);
--ink-alpha-88: var(--black-alpha-88);
/* === 圆角 === */
--r-sm: 4px;
--r-md: 8px; /* 默认主圆角 */
--r-pill: 999px;
/* === 字体 === */
--font-sans: 'Inter Tight', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', 'Geist Mono', monospace;
/* === 容器宽度 === */
--container-max: 1480px;
--sidebar-w: 248px;
/* === 过渡 === */
--t-fast: 100ms ease;
--t-base: 200ms ease;
--t-slow: 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* === Selection · Firecrawl 签名细节 === */
::selection {
background: var(--heat-20);
color: var(--heat);
}
/* === Dark mode · 翻转底色 + black-alpha 改用 white-alpha === */
.dark {
--background-base: #0a0a0a;
--background-lighter: #141414;
--surface: #171717;
--surface-raised: #1f1f1f;
--border-faint: #2a2a2a;
--border-muted: #333333;
--border-loud: #404040;
--accent-black: #f5f5f5;
--black-alpha-1: rgba(255,255,255,.01);
--black-alpha-2: rgba(255,255,255,.02);
--black-alpha-3: rgba(255,255,255,.03);
--black-alpha-4: rgba(255,255,255,.04);
--black-alpha-5: rgba(255,255,255,.05);
--black-alpha-6: rgba(255,255,255,.06);
--black-alpha-7: rgba(255,255,255,.07);
--black-alpha-8: rgba(255,255,255,.08);
--black-alpha-10: rgba(255,255,255,.10);
--black-alpha-12: rgba(255,255,255,.12);
--black-alpha-16: rgba(255,255,255,.16);
--black-alpha-20: rgba(255,255,255,.20);
--black-alpha-24: rgba(255,255,255,.24);
--black-alpha-32: rgba(255,255,255,.32);
--black-alpha-40: rgba(255,255,255,.40);
--black-alpha-48: rgba(255,255,255,.48);
--black-alpha-56: rgba(255,255,255,.56);
--black-alpha-64: rgba(255,255,255,.64);
--black-alpha-72: rgba(255,255,255,.72);
--black-alpha-88: rgba(255,255,255,.88);
}
```
---
## 14. V1 → V2 迁移检查清单(给后续改代码用)
- [ ] 全局替换 `border-radius: 0``border-radius: 8px`(卡片 / stats / shortcut / modal / toast / thumb)
- [ ] 替换 V1 ink-2/3/4 token 为 ink-alpha-56/48/12
- [ ] 替换 `--orange-tint` `--orange-soft``--heat-12` `--heat-20`
- [ ] 主 CTA hover 移除 `#D04E1F`,改用 4 层橙阴影变化
- [ ] 所有 `.btn` `.input``inside-border`
- [ ] 主工作区容器加 `border-left + border-right` + 4 个 SVG 准星
- [ ] 字体 Inter → Inter Tight
- [ ] 所有 icon 转 SVG line icon,stroke 1.5
- [ ] Pill 按 L1/L2/L3 三级规范化高度/字号/圆点尺寸
- [ ] 每个交互组件补齐 hover / active / focused / disabled 状态
---
## 15. 参考与来源
- **视觉灵感(实测):** [Firecrawl Playground](https://www.firecrawl.dev/playground?endpoint=parse) · 详见 [firecrawl_playground_spec.md](_design_src/firecrawl_playground_spec.md)
- **结构灵感:** Linear / Stripe Dashboard
- **图纸感来源:** 印刷套版准星 + 老 Unix 终端
- **V1 文档:** [DESIGN_SPEC.md](DESIGN_SPEC.md)(保留作为历史)
@@ -0,0 +1,16 @@
# Deprecated Static Pages Archive · 2026-05-28
These pages were moved out of the active static app because the current
navigation and production prototype use `products.html`, `product-detail.html`,
`pipeline.html`, `asset-factory.html`, `model-photo.html`, and
`platform-cover.html` instead.
Archived pages:
- `product-create.legacy.html`
- `product-create-v2.html`
- `product-studio.html`
- `studio.html`
- `studio-v2.html`
Keep them only as reference material. They should not be linked from the active
Airshelf static flow.
@@ -0,0 +1,414 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>新建商品 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=2026052607">
<style>
/* ─── 主表单 ─── */
.form-grid {
display: grid; grid-template-columns: 1.05fr 1fr; gap: 24px;
margin-bottom: 24px;
}
.form-card {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 24px;
}
.form-card .card-h {
display: flex; align-items: center; gap: 10px;
margin-bottom: 16px;
}
.form-card .card-h h3 {
font-size: 14px; font-weight: 600; color: var(--accent-black);
}
.form-card .card-h .req-tag {
font-family: var(--font-mono); font-size: 10px;
padding: 2px 7px;
background: var(--crimson-bg); color: var(--accent-crimson);
border-radius: var(--r-sm); letter-spacing: .04em;
border: 1px solid var(--red-bd);
}
.form-card .card-h .opt-tag {
font-family: var(--font-mono); font-size: 10px;
padding: 2px 7px;
background: var(--background-lighter); color: var(--black-alpha-56);
border-radius: var(--r-sm); letter-spacing: .04em;
border: 1px solid var(--border-faint);
}
.form-card .card-sub {
font-family: var(--font-mono); font-size: 11.5px;
color: var(--black-alpha-48); margin: -10px 0 14px; letter-spacing: .02em;
}
/* 原图槽位 */
.photo-grid {
display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px;
margin-top: 8px;
}
.photo-slot {
aspect-ratio: 1;
border-radius: var(--r-md);
border: 1px dashed var(--border-faint);
background: var(--background-lighter);
display: grid; place-items: center;
color: var(--black-alpha-32);
cursor: pointer;
overflow: hidden;
position: relative;
font-size: 10px; font-family: var(--font-mono); letter-spacing: .04em;
transition: all var(--t-base);
}
.photo-slot:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-8); }
.photo-slot.filled {
border-style: solid;
background-size: cover; background-position: center;
cursor: default; color: transparent;
}
.photo-slot.filled:hover { border-color: var(--heat-40); }
.photo-slot .slot-label {
position: absolute; top: 5px; left: 5px;
font-family: var(--font-mono); font-size: 9.5px; font-weight: 600;
padding: 2px 6px;
background: rgba(255,255,255,.92); color: var(--black-alpha-72);
border-radius: var(--r-sm); letter-spacing: .04em;
}
.photo-slot .slot-main {
position: absolute; top: 5px; right: 5px;
font-family: var(--font-mono); font-size: 9.5px; font-weight: 600;
padding: 2px 6px; background: var(--heat); color: #fff;
border-radius: var(--r-sm); letter-spacing: .04em;
}
.photo-slot .slot-x {
position: absolute; top: 4px; right: 4px;
width: 18px; height: 18px;
border-radius: 999px;
background: rgba(21,20,15,.7); color: #fff;
display: none; place-items: center; cursor: pointer; border: 0;
}
.photo-slot.filled:hover .slot-x { display: grid; }
.photo-slot.filled:hover .slot-main { display: none; }
.photo-slot .slot-x svg { width: 9px; height: 9px; }
.photo-slot .plus { width: 22px; height: 22px; border: 1px solid currentColor; border-radius: var(--r-sm); display: grid; place-items: center; margin-bottom: 4px; }
.photo-slot .plus svg { width: 12px; height: 12px; }
.upload-tip {
display: flex; align-items: center; gap: 8px;
margin-top: 14px; padding: 10px 12px;
background: var(--heat-8); border: 1px dashed var(--heat-40);
border-radius: var(--r-md);
font-size: 12px; color: var(--accent-black); line-height: 1.5;
}
.upload-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; }
.upload-tip strong { color: var(--heat); font-weight: 600; }
/* AI 提示 banner(选填字段说明) */
.ai-tip {
margin-top: -6px; margin-bottom: 16px;
padding: 10px 12px;
background: var(--background-lighter);
border-radius: var(--r-md);
border: 1px dashed var(--border-faint);
display: flex; align-items: flex-start; gap: 8px;
font-size: 12px; color: var(--black-alpha-72); line-height: 1.55;
}
.ai-tip svg { width: 13px; height: 13px; color: var(--heat); flex-shrink: 0; margin-top: 2px; }
.ai-tip strong { color: var(--accent-black); font-weight: 600; }
/* 卖点 bullet 输入 */
.sell-list { list-style: none; margin: 0; padding: 0; }
.sell-list li {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
margin-bottom: 6px;
font-size: 13px;
}
.sell-list li.add { background: var(--surface); border-style: dashed; }
.sell-list .num {
width: 18px; height: 18px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-size: 10.5px; font-family: var(--font-mono);
color: var(--black-alpha-56);
display: grid; place-items: center; flex-shrink: 0;
}
.sell-list li.add .num { background: transparent; color: var(--heat); border-color: var(--heat-40); }
.sell-list .txt { flex: 1; min-width: 0; }
.sell-list .bl-input {
flex: 1; border: 0; background: transparent;
font-size: 13px; color: var(--accent-black); padding: 0;
font-family: inherit;
}
.sell-list .bl-input::placeholder { color: var(--black-alpha-48); }
.sell-list .bl-x {
width: 20px; height: 20px;
display: grid; place-items: center;
color: var(--black-alpha-48); cursor: pointer;
background: transparent; border: 0; opacity: 0;
transition: opacity var(--t-base);
}
.sell-list li:hover .bl-x { opacity: 1; }
.sell-list .bl-x:hover { color: var(--accent-crimson); }
.sell-list .bl-x svg { width: 11px; height: 11px; }
/* 底部操作行 */
.form-foot {
position: sticky; bottom: 0;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 14px 22px;
display: flex; align-items: center; gap: 14px;
margin-top: 8px;
}
.form-foot .req-info {
font-family: var(--font-mono); font-size: 11.5px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
.form-foot .req-info .ok { color: var(--accent-forest); }
.form-foot .req-info .miss { color: var(--accent-crimson); }
.form-foot .actions {
margin-left: auto;
display: flex; gap: 10px;
}
</style>
</head>
<body>
<div id="page">
<div class="page-head">
<div>
<h1>新建商品</h1>
<div class="sub"><span class="mono">// 上传原图 + 填写基本信息</span> · 保存后可在工作台逐步丰富素材</div>
</div>
</div>
<!-- ============ 表单 ============ -->
<div class="form-grid">
<!-- 左:原图 -->
<div class="form-card">
<div class="card-h">
<h3>商品原图</h3>
<span class="req-tag">必填</span>
</div>
<div class="card-sub">// 1-5 张 · 这是后续所有 AI 生成的源材料</div>
<input type="file" id="photo-input" accept="image/*" multiple hidden>
<div class="photo-grid" id="photo-grid"></div>
<div class="upload-tip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></svg>
<span>建议上传 <strong>正面 / 侧面 / 细节 / 包装</strong> 4 张,后续在工作台生成的<strong>白底三视图</strong>更准确。</span>
</div>
</div>
<!-- 右:基本信息 -->
<div class="form-card">
<div class="card-h">
<h3>基本信息</h3>
<span class="req-tag">必填</span>
</div>
<div class="field">
<label class="field-label">商品名称<span class="req">*</span></label>
<input class="input" id="p-name" placeholder="例: 透真玻尿酸补水面膜">
</div>
<div class="field">
<label class="field-label">品类<span class="req">*</span></label>
<select class="select" id="p-cat">
<option value="">— 选择品类 —</option>
<option>美妆个护</option>
<option>服饰内衣</option>
<option>食品饮料</option>
<option>家居家电</option>
<option>数码 3C</option>
<option>个护清洁</option>
<option>运动户外</option>
<option>母婴亲子</option>
</select>
</div>
<div class="field" style="margin-bottom:0;">
<label class="field-label">价格(元)</label>
<input class="input" id="p-price" type="number" placeholder="选填 · 仅用于素材生成参考">
</div>
</div>
</div>
<div class="form-card" style="margin-bottom: 24px;">
<div class="card-h">
<h3>卖点 & 人群</h3>
<span class="opt-tag">选填 · 推荐</span>
</div>
<div class="ai-tip">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l1.5 4.5L18 8l-4.5 1.5L12 14l-1.5-4.5L6 8l4.5-1.5L12 2z"/></svg>
<span>填上这两项,后续 AI 生脚本(<strong>痛点种草 / 剧情带货</strong> 等模板)质量明显更高 —— 系统会用卖点构造钩子,用人群定语气。现在不填也可以,做视频项目时仍可补。</span>
</div>
<div class="field">
<label class="field-label">核心卖点</label>
<div class="field-hint" style="margin: 4px 0 8px;">3-5 条要点,回车添加</div>
<ul class="sell-list" id="sell-list">
<li class="add"><span class="num">+</span><input class="bl-input" id="sell-input" placeholder="例: 玻尿酸双效保湿,4 小时持久水润"></li>
</ul>
</div>
<div class="field" style="margin-bottom:0;">
<label class="field-label">目标人群</label>
<input class="input" id="p-target" placeholder="例: 22-32 岁女性、敏感肌、办公室通勤">
</div>
</div>
<!-- ============ 底部操作 ============ -->
<div class="form-foot">
<span class="req-info" id="req-info">// 必填检查:<span class="miss">商品名 / 品类 / ≥1 张图</span> 未完成</span>
<div class="actions">
<a class="btn" href="products.html">取消</a>
<button class="btn btn-primary" id="save-btn" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>
保存并进入工作台
</button>
</div>
</div>
</div>
<script src="assets/icons.js?v=2026052608"></script>
<script src="assets/shell.js?v=2026052607"></script>
<script>
Shell.render({
active: 'products',
crumbs: [
{ label: '工作台', href: 'index.html' },
{ label: '商品库', href: 'products.html' },
{ label: '新建' }
]
});
const MAX = 5;
const photos = []; // { id, dataUrl }
const SLOT_LABELS = ['主图', '细节 02', '细节 03', '细节 04', '细节 05'];
const $ = id => document.getElementById(id);
// 渲染槽位
function renderPhotos() {
const grid = $('photo-grid');
grid.innerHTML = '';
for (let i = 0; i < MAX; i++) {
const slot = document.createElement('div');
const p = photos[i];
if (p) {
slot.className = 'photo-slot filled';
slot.style.backgroundImage = `url("${p.dataUrl}")`;
slot.innerHTML = `
<span class="slot-label">${SLOT_LABELS[i]}</span>
${i === 0 ? '<span class="slot-main">MAIN</span>' : ''}
<button class="slot-x" type="button" data-i="${i}" aria-label="移除">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
</button>
`;
} else if (i === photos.length) {
slot.className = 'photo-slot empty';
slot.innerHTML = `<div class="plus"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 5v14M5 12h14"/></svg></div><span>添加</span>`;
slot.addEventListener('click', () => $('photo-input').click());
} else {
slot.className = 'photo-slot';
slot.style.opacity = '.6';
}
grid.appendChild(slot);
}
// 绑定删除
grid.querySelectorAll('.slot-x').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const i = +b.dataset.i;
photos.splice(i, 1);
renderPhotos();
syncSave();
});
});
}
// 文件上传
$('photo-input').addEventListener('change', e => {
const files = [...e.target.files].filter(f => f.type.startsWith('image/'));
const remain = MAX - photos.length;
files.slice(0, remain).forEach(f => {
const reader = new FileReader();
reader.onload = ev => {
photos.push({
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 5),
dataUrl: ev.target.result,
});
renderPhotos();
syncSave();
};
reader.readAsDataURL(f);
});
e.target.value = '';
});
// 卖点
const sellList = $('sell-list');
const sellInput = $('sell-input');
sellInput.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
e.preventDefault();
const t = sellInput.value.trim();
if (!t) return;
const li = document.createElement('li');
li.innerHTML = `<span class="num"></span><span class="txt"></span><button class="bl-x" type="button" aria-label="删除"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>`;
li.querySelector('.txt').textContent = t;
sellList.querySelector('.add').before(li);
sellInput.value = '';
renumberSell();
li.querySelector('.bl-x').addEventListener('click', () => {
li.remove();
renumberSell();
});
});
function renumberSell() {
sellList.querySelectorAll('li:not(.add) .num').forEach((n, i) => n.textContent = i + 1);
}
// 必填检查
function syncSave() {
const hasName = $('p-name').value.trim().length > 0;
const hasCat = $('p-cat').value.length > 0;
const hasPhoto = photos.length > 0;
const ok = hasName && hasCat && hasPhoto;
$('save-btn').disabled = !ok;
const missing = [];
if (!hasName) missing.push('商品名');
if (!hasCat) missing.push('品类');
if (!hasPhoto) missing.push('≥1 张图');
const info = $('req-info');
if (ok) {
info.innerHTML = '// 必填检查:<span class="ok">已全部完成 ✓</span> · 可进入工作台';
} else {
info.innerHTML = `// 必填检查:<span class="miss">${missing.join(' / ')}</span> 未完成`;
}
}
$('p-name').addEventListener('input', syncSave);
$('p-cat').addEventListener('change', syncSave);
// 保存 → 跳工作台
$('save-btn').addEventListener('click', () => {
if ($('save-btn').disabled) return;
Shell.toast('商品已建档', `进入工作台`);
setTimeout(() => {
// 真实场景下会带商品 ID;demo 直接跳
location.href = 'product-studio.html?from=new&onboard=1';
}, 500);
});
renderPhotos();
syncSave();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+881
View File
@@ -0,0 +1,881 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>消费 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
<style>
/* ─── 顶部:左右布局(余额 banner + 快速充值)─── */
.top-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(480px, .85fr); gap: 16px; margin-bottom: 22px; align-items: stretch; }
@media (max-width: 1120px) { .top-grid { grid-template-columns: 1fr; } }
.balance-banner {
background: var(--accent-black);
color: var(--accent-white);
padding: 26px 28px;
position: relative;
border: 1px solid var(--accent-black);
border-radius: var(--r-md);
display: flex;
flex-direction: column;
gap: 24px;
min-width: 0;
min-height: 246px;
}
.balance-banner::before, .balance-banner::after,
.balance-banner > .corner-tr, .balance-banner > .corner-bl {
content: ''; position: absolute; width: 14px; height: 14px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center;
background-size: contain; pointer-events: none;
}
.balance-banner::before { top: -7px; left: -7px; }
.balance-banner::after { bottom: -7px; right: -7px; }
.balance-banner > .corner-tr { top: -7px; right: -7px; }
.balance-banner > .corner-bl { bottom: -7px; left: -7px; }
/* 主余额 · 突出展示 */
.balance-hero { display: flex; flex-direction: column; gap: 4px; }
.balance-hero .lbl { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.55); letter-spacing: .06em; text-transform: uppercase; }
.balance-hero .v { font-size: 38px; font-weight: 700; letter-spacing: -.02em; font-variant-numeric: tabular-nums; line-height: 1.1; }
.balance-hero .meta { font-size: 11.5px; color: rgba(255,255,255,.5); font-family: var(--font-mono); letter-spacing: .02em; margin-top: 4px; }
/* 子统计 · 月限额 / 已用 */
.balance-sub { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding: 16px 0 0; border-top: 1px solid rgba(255,255,255,.1); }
.balance-sub .col { min-width: 0; }
.balance-sub .lbl { font-family: var(--font-mono); font-size: 10px; color: rgba(255,255,255,.5); letter-spacing: .06em; text-transform: uppercase; }
.balance-sub .v { font-size: 18px; font-weight: 700; letter-spacing: -.01em; margin-top: 4px; font-variant-numeric: tabular-nums; }
.balance-sub .meta { font-size: 10.5px; color: rgba(255,255,255,.42); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
.balance-foot { margin-top: auto; padding-top: 2px; }
.balance-meter { height: 5px; background: rgba(255,255,255,.12); border-radius: var(--r-pill); overflow: hidden; }
.balance-meter > span { display: block; height: 100%; width: 5.4%; background: var(--heat); border-radius: inherit; }
.balance-foot-meta { display: flex; justify-content: space-between; gap: 14px; margin-top: 8px; color: rgba(255,255,255,.46); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }
/* ─── 快速充值 pane(右栏)─── */
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 20px; margin-bottom: 16px; }
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 6px; }
.pane .desc { font-size: 11.5px; color: var(--black-alpha-48); margin-bottom: 14px; font-family: var(--font-mono); letter-spacing: .02em; }
.topup-pane { display: flex; flex-direction: column; padding: 20px 22px; margin-bottom: 0; min-height: 246px; }
.topup-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
.topup-head h3 { margin-bottom: 5px; }
.topup-head .desc { margin-bottom: 0; }
.topup-selected { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); white-space: nowrap; padding-top: 2px; }
.recharge-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.recharge-card { min-height: 76px; border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 10px 8px; text-align: center; cursor: pointer; background: var(--surface); position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base), transform var(--t-fast); }
.recharge-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-24); }
.recharge-card:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--heat-40); }
.recharge-card.selected { border-color: var(--heat); background: var(--heat-12); box-shadow: inset 0 0 0 1px var(--heat); }
.recharge-card.selected::after { content: '✓'; position: absolute; top: 6px; right: 7px; width: 15px; height: 15px; border-radius: 50%; display: grid; place-items: center; background: var(--heat); color: var(--accent-white); font-size: 10px; line-height: 1; }
.recharge-card .amt { font-size: 17px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.15; }
.recharge-card .gift { font-size: 10px; color: var(--black-alpha-48); margin-top: 4px; font-family: var(--font-mono); white-space: nowrap; }
.recharge-card .gift.bonus { color: var(--accent-forest); font-weight: 600; }
.recharge-card .ribbon { position: absolute; top: 6px; left: 7px; font-family: var(--font-mono); font-size: 9px; padding: 1px 5px; background: var(--heat); color: var(--accent-white); letter-spacing: .03em; font-weight: 600; border-radius: var(--r-sm); }
.pay-row { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.pay-title { font-size: 12px; font-weight: 600; color: var(--accent-black); line-height: 1.2; }
.pay-row .input { width: 100%; box-sizing: border-box; height: 38px; }
.pay-btn-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.pay-method-btn { height: 38px; border-radius: var(--r-pill); display: inline-flex; justify-content: center; align-items: center; gap: 8px; background: var(--surface); color: var(--accent-black); border-color: var(--border-faint); font-weight: 500; }
.pay-method-btn:hover { background: var(--background-lighter); color: var(--accent-black); border-color: var(--black-alpha-24); }
.pay-method-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--black-alpha-16); }
.pay-logo { width: 18px; height: 18px; border-radius: 6px; display: inline-block; flex: 0 0 18px; overflow: hidden; }
.pay-logo img { width: 100%; height: 100%; display: block; object-fit: cover; border-radius: inherit; }
@media (max-width: 720px) {
.recharge-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* ─── Tab strip ─── */
.billing-tabs { display: flex; align-items: flex-end; gap: 4px; border-bottom: 1px solid var(--border-faint); margin: 24px 0 18px; padding: 0 2px; overflow-x: auto; scrollbar-width: none; }
.billing-tabs::-webkit-scrollbar { display: none; }
.billing-tabs .tab { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 6px; background: transparent; border: 0; border-bottom: 2px solid transparent; border-radius: var(--r-md) var(--r-md) 0 0; margin-bottom: -1px; padding: 10px 14px; font-size: 13px; font-weight: 500; color: var(--black-alpha-56); font-family: inherit; cursor: pointer; letter-spacing: 0; user-select: none; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.billing-tabs .tab:hover { color: var(--accent-black); background: var(--black-alpha-4); }
.billing-tabs .tab:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--heat-40); }
.billing-tabs .tab.active { color: var(--accent-black); border-bottom-color: var(--heat); font-weight: 600; }
.billing-tabs .tab .count { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); padding: 1px 7px; background: var(--black-alpha-4); border-radius: var(--r-sm); letter-spacing: .04em; }
.billing-tabs .tab.active .count { background: var(--heat-12); color: var(--heat); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ─── 总览 · 趋势 + 阶段分布 (两栏等高) ─── */
.overview-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 16px; align-items: stretch; }
.trend-pane { padding: 18px 20px 14px; display: flex; flex-direction: column; }
.trend-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 14px; }
.trend-head h3 { margin-bottom: 0; }
.trend-head .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.trend-head .spacer { flex: 1; }
.trend-head .chip { font-family: var(--font-mono); font-size: 10.5px; padding: 3px 8px; border: 1px solid var(--border-faint); border-radius: var(--r-pill); color: var(--black-alpha-56); cursor: pointer; }
.trend-head .chip.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }
.trend-chart { display: grid; grid-template-rows: 1fr auto; gap: 6px; min-height: 170px; flex: 1; padding: 6px 4px 2px; position: relative; }
.trend-chart .bars { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; align-items: end; height: 100%; }
.trend-chart .bar { background: var(--background-lighter); border-radius: 2px 2px 0 0; position: relative; transition: background var(--t-base); cursor: pointer; }
.trend-chart .bar > span { display: block; width: 100%; background: var(--heat); border-radius: 2px 2px 0 0; }
.trend-chart .bar:hover > span { background: var(--accent-black); }
.trend-chart .bar.peak > span { background: var(--accent-black); }
.trend-chart .x-axis { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-32); text-align: center; letter-spacing: .02em; }
.trend-foot { display: flex; gap: 14px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-faint); font-size: 12px; }
.trend-foot .item { display: flex; align-items: baseline; gap: 6px; }
.trend-foot .item .k { color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }
.trend-foot .item .v { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--accent-black); }
.trend-foot .item .v.warn { color: #B45309; }
/* ─── 阶段分布 ─── */
.stage-pane .usage-line { display: flex; justify-content: space-between; padding: 4px 0 4px; font-size: 12.5px; }
.stage-pane .usage-line .k { color: var(--accent-black); }
.stage-pane .usage-line .v { font-variant-numeric: tabular-nums; color: var(--accent-black); font-weight: 600; }
.stage-pane .usage-bar { height: 4px; background: var(--background-lighter); border-radius: 2px; margin: 4px 0 10px; overflow: hidden; }
.stage-pane .usage-bar > span { display: block; height: 100%; transition: width .3s ease; }
.stage-pane .total { display: flex; justify-content: space-between; padding-top: 10px; margin-top: 6px; border-top: 1px solid var(--border-faint); font-size: 13px; font-weight: 600; }
.stage-pane .total .v { font-variant-numeric: tabular-nums; }
/* ─── 扣费规则 + 四层预检 ─── */
.rule-pane .rule-list { font-size: 12.5px; color: var(--black-alpha-56); line-height: 1.7; }
.rule-pane .rule-list strong { color: var(--accent-black); font-weight: 600; }
.rule-pane .mono-acc { font-family: var(--font-mono); color: var(--heat); background: var(--heat-12); padding: 1px 5px; font-size: 11.5px; border-radius: var(--r-sm); }
.quota-rules { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-faint); }
.quota-rules .qr-head { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; margin-bottom: 10px; }
.quota-rules .step { display: grid; grid-template-columns: 22px 1fr; gap: 10px; align-items: baseline; margin-bottom: 6px; font-size: 12.5px; color: var(--accent-black); }
.quota-rules .step .num { width: 20px; height: 20px; border-radius: 50%; background: var(--heat-12); color: var(--heat); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; display: grid; place-items: center; }
.quota-rules .step .formula { font-family: var(--font-mono); font-size: 11.5px; color: var(--heat); background: var(--heat-12); padding: 0 4px; border-radius: var(--r-sm); }
/* ─── 表格通用 ─── */
.billing-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border-muted); border-radius: var(--r-md); overflow: hidden; }
.billing-table th, .billing-table td { padding: 11px 14px; text-align: left; font-size: 12.5px; border-bottom: 0; }
.billing-table thead th { background: var(--background-lighter); border-bottom: 1px solid var(--border-muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
.billing-table tbody tr:hover { background: var(--background-lighter); }
.billing-table .ts { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
.billing-table .neg { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-black); text-align: right; }
.billing-table .pos { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-forest); text-align: right; }
.billing-table .zero { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--black-alpha-32); text-align: right; }
.billing-table .muted { color: var(--black-alpha-56); font-size: 11.5px; }
.billing-table .ref { color: var(--black-alpha-48); font-size: 10.5px; font-family: var(--font-mono); }
.billing-table .who { display: inline-flex; align-items: center; gap: 8px; }
.billing-table .who .av { width: 24px; height: 24px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: inline-grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--accent-black); }
.billing-table .role-pill { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: var(--r-pill); font-size: 10.5px; font-weight: 500; }
.billing-table .role-pill .dot { width: 5px; height: 5px; border-radius: 50%; }
.billing-table .role-super { background: var(--heat-12); color: var(--heat); }
.billing-table .role-super .dot { background: var(--heat); }
.billing-table .role-admin { background: rgba(30,64,175,.1); color: #1E40AF; }
.billing-table .role-admin .dot { background: #1E40AF; }
.billing-table .role-member { background: var(--background-lighter); color: var(--black-alpha-56); }
.billing-table .role-member .dot { background: var(--black-alpha-56); }
.billing-table .status-tag { font-family: var(--font-mono); font-size: 10px; padding: 1px 6px; border-radius: var(--r-sm); letter-spacing: .04em; }
.billing-table .status-tag.ok { background: rgba(66,195,102,.12); color: var(--accent-forest); }
.billing-table .status-tag.wip { background: var(--heat-12); color: var(--heat); }
.billing-table .status-tag.fail { background: rgba(235,52,36,.10); color: var(--accent-crimson); }
.billing-table .progress-mini { width: 80px; height: 4px; background: var(--background-lighter); border-radius: 2px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 8px; }
.billing-table .progress-mini > span { display: block; height: 100%; background: var(--heat); }
/* ─── 流水筛选条 ─── */
.filter-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
.filter-bar select, .filter-bar input { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 6px 10px; font-size: 12.5px; font-family: inherit; color: var(--accent-black); }
.filter-bar select { padding-right: 24px; }
.filter-bar .spacer { flex: 1; }
.filter-bar .ct { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
/* 充值 modal */
.topup-modal { width: min(460px, 92vw); }
.topup-modal .topup-qr { aspect-ratio: 1; max-width: 220px; margin: 4px auto 12px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; position: relative; overflow: hidden; }
.topup-modal .topup-qr::before {
content: ''; position: absolute; inset: 18px;
background-image: linear-gradient(45deg, var(--accent-black) 25%, transparent 25%),
linear-gradient(-45deg, var(--accent-black) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--accent-black) 75%),
linear-gradient(-45deg, transparent 75%, var(--accent-black) 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
opacity: .14;
}
.topup-modal .topup-qr .center { position: relative; z-index: 1; background: var(--surface); padding: 10px 12px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-56); letter-spacing: .02em; text-align: center; }
.topup-modal .topup-info { text-align: center; font-size: 13px; color: var(--black-alpha-56); margin-bottom: 6px; }
.topup-modal .topup-amt { text-align: center; font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--heat); margin-bottom: 6px; }
.topup-modal .topup-note { text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; margin-bottom: 4px; }
</style>
</head>
<body>
<div id="page">
<div class="page-head">
<div>
<h1>消费</h1>
<div class="sub"><span class="mono">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div>
</div>
</div>
<!-- 顶部:余额 banner(左)+ 快速充值(右)左右布局 -->
<div class="top-grid">
<!-- 左:余额 banner -->
<div class="balance-banner">
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
<div class="balance-hero">
<div class="lbl">团队余额</div>
<div class="v">¥327.40</div>
<div class="meta">// 充值累加 · 不重置</div>
</div>
<div class="balance-sub">
<div class="col">
<div class="lbl">本月限额</div>
<div class="v">¥3,000.00</div>
<div class="meta">// 按自然月重置</div>
</div>
<div class="col">
<div class="lbl">当月已用</div>
<div class="v">¥162.60</div>
<div class="meta">// 占比 5.4% · 健康</div>
</div>
</div>
<div class="balance-foot">
<div class="balance-meter" aria-label="本月额度使用率 5.4%"><span></span></div>
<div class="balance-foot-meta">
<span>团队月剩余 ¥2,837.40</span>
<span>使用率 5.4%</span>
</div>
</div>
</div>
<!-- 右:快速充值 -->
<div class="pane topup-pane">
<div class="topup-head">
<div>
<h3>快速充值</h3>
<div class="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
</div>
<div class="topup-selected" id="topup-selected-label">已选 ¥500</div>
</div>
<div class="recharge-row">
<div class="recharge-card" data-amt="100" role="button" tabindex="0" aria-pressed="false"><div class="amt">¥100</div><div class="gift">无赠送</div></div>
<div class="recharge-card selected" data-amt="500" role="button" tabindex="0" aria-pressed="true"><span class="ribbon">推荐</span><div class="amt">¥500</div><div class="gift bonus">+ ¥30 赠送</div></div>
<div class="recharge-card" data-amt="1000" role="button" tabindex="0" aria-pressed="false"><div class="amt">¥1000</div><div class="gift bonus">+ ¥80 赠送</div></div>
<div class="recharge-card" data-amt="3000" role="button" tabindex="0" aria-pressed="false"><div class="amt">¥3000</div><div class="gift bonus">+ ¥300 赠送</div></div>
</div>
<div class="pay-row">
<div class="pay-title">自定义金额</div>
<input class="input" id="custom-amt" placeholder="最低 ¥50,可输入任意金额">
<div class="pay-btn-row">
<button class="btn pay-method-btn pay-wechat" onclick="openTopup('wechat')" aria-label="微信支付">
<span class="pay-logo" aria-hidden="true"><img src="assets/pay-wechat.png" alt=""></span>
微信支付
</button>
<button class="btn pay-method-btn pay-alipay" onclick="openTopup('alipay')" aria-label="支付宝">
<span class="pay-logo" aria-hidden="true"><img src="assets/pay-alipay.png" alt=""></span>
支付宝
</button>
</div>
</div>
</div>
</div>
<!-- Tab strip -->
<div class="billing-tabs" role="tablist">
<button class="tab active" type="button" data-tab="overview" role="tab" aria-selected="true" aria-controls="panel-overview">总览</button>
<button class="tab" type="button" data-tab="by-project" role="tab" aria-selected="false" aria-controls="panel-by-project">项目 <span class="count">8</span></button>
<button class="tab" type="button" data-tab="by-member" role="tab" aria-selected="false" aria-controls="panel-by-member">成员 <span class="count">5</span></button>
<button class="tab" type="button" data-tab="bills" role="tab" aria-selected="false" aria-controls="panel-bills">账单流水 <span class="count">30天</span></button>
</div>
<!-- ===== Tab 1: 总览 ===== -->
<div class="tab-panel active" id="panel-overview">
<div class="overview-grid">
<div class="pane trend-pane">
<div class="trend-head">
<h3>消费趋势</h3>
<span class="sub" id="trend-sub">// 近 14 天 · 单位 ¥</span>
<span class="spacer"></span>
<button class="chip active" data-grain="day"></button>
<button class="chip" data-grain="week"></button>
<button class="chip" data-grain="month"></button>
</div>
<div class="trend-chart">
<div class="bars" id="trend-bars">
<!-- JS 注入 14 根柱 -->
</div>
<div class="x-axis" id="trend-xaxis">
<!-- JS 注入日期 -->
</div>
</div>
<div class="trend-foot">
<div class="item"><span class="k">14 天合计</span><span class="v" id="trend-sum">¥0.00</span></div>
<div class="item"><span class="k">日均</span><span class="v" id="trend-avg">¥0.00</span></div>
<div class="item"><span class="k">峰值</span><span class="v warn" id="trend-peak">¥0.00</span></div>
</div>
</div>
<div class="pane stage-pane">
<h3>本月按阶段分布</h3>
<div class="desc">// PRD §5.3.5 扣费规则 · 仅确认后扣</div>
<div class="usage-line"><span class="k">视频片段(Seedance)</span><span class="v">¥98.40</span></div>
<div class="usage-bar"><span style="width:60%; background:var(--heat);"></span></div>
<div class="usage-line"><span class="k">故事板(image-2)</span><span class="v">¥36.00</span></div>
<div class="usage-bar"><span style="width:22%; background:var(--accent-forest);"></span></div>
<div class="usage-line"><span class="k">基础资产</span><span class="v">¥21.00</span></div>
<div class="usage-bar"><span style="width:13%; background:var(--black-alpha-56);"></span></div>
<div class="usage-line"><span class="k">脚本 LLM</span><span class="v">¥7.20</span></div>
<div class="usage-bar"><span style="width:5%; background:var(--black-alpha-32);"></span></div>
<div class="total"><span>合计</span><span class="v">¥162.60</span></div>
</div>
</div>
<div class="pane rule-pane" style="margin-top: 16px;">
<h3>扣费 + 四层额度预检规则</h3>
<div class="desc">// PRD §5.3.5 + §10.3 · 对接团队请以此页为准</div>
<div class="rule-list">
<strong>① 失败不扣</strong>:模型超时 / 内容审核拦截 / 生成异常一律不扣费。<br>
<strong>② 用户重跑不扣首次</strong>:第一次重跑保留原扣费,第二次起按次结算。<br>
<strong>③ 仅在你点击 <span class="mono-acc">[ 确认通过 ]</span> 时入账</strong><br>
<strong>④ 导出不再扣费</strong>,所有 token 已在过程中结算。
</div>
<div class="quota-rules">
<div class="qr-head">// 任务确认前 · 四层额度预检(任一不通过即拦截)</div>
<div class="step"><span class="num">1</span><span><strong>个人日剩余</strong> ≥ 任务预估 × <span class="formula">1.2</span></span></div>
<div class="step"><span class="num">2</span><span><strong>个人月剩余</strong> ≥ 同上</span></div>
<div class="step"><span class="num">3</span><span><strong>团队月剩余</strong> ≥ 同上</span></div>
<div class="step"><span class="num">4</span><span><strong>团队总余额</strong> ≥ 同上</span></div>
</div>
</div>
</div>
<!-- ===== Tab 2: 按项目 ===== -->
<div class="tab-panel" id="panel-by-project">
<div class="filter-bar">
<select id="proj-f-status">
<option value="all">全部状态</option>
<option value="wip">进行中</option>
<option value="ok">已完成</option>
<option value="fail">失败 · 待重跑</option>
</select>
<select id="proj-f-range">
<option value="month">本月</option>
<option value="30d">近 30 天</option>
<option value="90d">近 90 天</option>
</select>
<button class="chip" id="proj-f-reset" type="button" style="height:30px;font-size:11px;display:none;">清除筛选</button>
<span class="spacer"></span>
<span class="ct" id="proj-count"><b style="color:var(--accent-black);">0</b> 个项目 · 消耗 ¥0.00</span>
</div>
<table class="billing-table">
<thead>
<tr>
<th>项目</th>
<th>商品</th>
<th>所属成员</th>
<th>当前阶段</th>
<th>状态</th>
<th style="text-align:right;">消耗</th>
</tr>
</thead>
<tbody id="proj-body">
<!-- JS 注入 -->
</tbody>
</table>
<div id="proj-empty" class="empty-state" style="display:none; padding:48px 0; text-align:center; font-family:var(--font-mono); font-size:12px; color:var(--black-alpha-48); letter-spacing:.02em;">
// 当前筛选条件下没有项目 · 试试调整状态 / 时间范围
</div>
</div>
<!-- ===== Tab 3: 按成员 ===== -->
<div class="tab-panel" id="panel-by-member">
<div class="filter-bar">
<select id="mem-f-role">
<option value="all">全部角色</option>
<option value="super">超管</option>
<option value="admin">团管</option>
<option value="member">成员</option>
</select>
<select id="mem-f-range">
<option value="month">本月</option>
<option value="30d">近 30 天</option>
<option value="90d">近 90 天</option>
</select>
<button class="chip" id="mem-f-reset" type="button" style="height:30px;font-size:11px;display:none;">清除筛选</button>
<span class="spacer"></span>
<span class="ct" id="mem-count"><b style="color:var(--accent-black);">0</b> 人 · 合计 ¥0.00</span>
</div>
<table class="billing-table">
<thead>
<tr>
<th>成员</th>
<th>角色</th>
<th>已完成项目</th>
<th>已用 / 月度额度</th>
<th>最近活跃</th>
</tr>
</thead>
<tbody id="member-body">
<!-- JS 注入 -->
</tbody>
</table>
<div id="mem-empty" class="empty-state" style="display:none; padding:48px 0; text-align:center; font-family:var(--font-mono); font-size:12px; color:var(--black-alpha-48); letter-spacing:.02em;">
// 当前筛选条件下没有成员 · 试试调整角色 / 时间范围
</div>
</div>
<!-- ===== Tab 4: 账单流水 ===== -->
<div class="tab-panel" id="panel-bills">
<div class="filter-bar">
<select id="bills-f-stage">
<option value="all">全部阶段</option>
<option value="视频片段">视频片段</option>
<option value="故事板">故事板</option>
<option value="基础资产">基础资产</option>
<option value="脚本 LLM">脚本 LLM</option>
<option value="充值">充值</option>
<option value="导出">导出</option>
</select>
<select id="bills-f-member">
<option value="all">全部成员</option>
<option value="李">小李</option>
<option value="张">张运营</option>
<option value="王">王小姐</option>
<option value="陈">陈策划</option>
</select>
<select id="bills-f-range">
<option value="30d">近 30 天</option>
<option value="7d">近 7 天</option>
<option value="month">本月</option>
<option value="all">全部</option>
</select>
<button class="chip" id="bills-f-reset" type="button" style="height:30px;font-size:11px;display:none;">清除筛选</button>
<span class="spacer"></span>
<span class="ct"><b id="bills-count" style="color:var(--accent-black);">0</b></span>
</div>
<table class="billing-table">
<thead>
<tr>
<th>时间</th>
<th>项目 / 类型</th>
<th>详情</th>
<th>成员</th>
<th>状态</th>
<th style="text-align:right;">金额</th>
</tr>
</thead>
<tbody id="bills-body">
<!-- JS 注入 -->
</tbody>
</table>
<div id="bills-empty" class="empty-state" style="display:none; padding:48px 0; text-align:center; font-family:var(--font-mono); font-size:12px; color:var(--black-alpha-48); letter-spacing:.02em;">
// 当前筛选条件下没有账单 · 试试调整阶段 / 成员 / 时间范围
</div>
</div>
<!-- 充值 modal -->
<div class="modal-bg" id="topup-bg" onclick="if(event.target===this)Shell.closeModal('topup-bg')">
<div class="modal topup-modal">
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
<div class="modal-h">
<div class="ic-m">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4"/></svg>
</div>
<div class="ti">扫码支付<span id="topup-channel-label">// 微信支付</span></div>
</div>
<div class="modal-b">
<div class="topup-info">支付金额</div>
<div class="topup-amt" id="topup-amt">¥500.00</div>
<div class="topup-note" id="topup-bonus">// 含 ¥30 赠送 · 实到账 ¥530</div>
<div class="topup-qr">
<div class="center" id="topup-channel-name">微信扫码<br><span style="color:var(--black-alpha-32);">/topup/wx/TX...</span></div>
</div>
<div style="text-align:center; font-family:var(--font-mono); font-size:11px; color:var(--black-alpha-48); letter-spacing:.02em;">// 5 分钟内有效 · 到账后自动关闭</div>
</div>
<div class="modal-f">
<button class="btn" type="button" onclick="Shell.closeModal('topup-bg')">取消</button>
<button class="btn btn-primary" type="button" onclick="topupDone()">已完成支付</button>
</div>
</div>
</div>
</div>
<script src="assets/icons.js?v=2026052608"></script>
<script src="assets/shell.js?v=2026060101"></script>
<script>
Shell.render({ active: 'account', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '消费' }] });
/* ============================================================
Mock 数据
============================================================ */
const TREND_DAYS = [
{ d: '05.08', v: 12.40 }, { d: '05.09', v: 18.80 }, { d: '05.10', v: 6.20 }, { d: '05.11', v: 0 },
{ d: '05.12', v: 4.50 }, { d: '05.13', v: 22.10 }, { d: '05.14', v: 14.60 }, { d: '05.15', v: 9.30 },
{ d: '05.16', v: 28.40 }, { d: '05.17', v: 13.80 }, { d: '05.18', v: 8.20 }, { d: '05.19', v: 11.50 },
{ d: '05.20', v: 19.40 }, { d: '05.21', v: 7.80 },
];
// 8 周(以「W18~W21 + 前 4 周」做演示),按 7 天合计估算
const TREND_WEEKS = [
{ d: 'W14', v: 64.20 }, { d: 'W15', v: 92.80 }, { d: 'W16', v: 118.40 }, { d: 'W17', v: 78.60 },
{ d: 'W18', v: 102.30 }, { d: 'W19', v: 138.20 }, { d: 'W20', v: 86.40 }, { d: 'W21', v: 27.20 },
];
// 6 个月 demo,自然月聚合估算 + 当月真实合计 162.60
const TREND_MONTHS = [
{ d: '2025-12', v: 384.20 }, { d: '2026-01', v: 528.40 }, { d: '2026-02', v: 296.80 },
{ d: '2026-03', v: 412.00 }, { d: '2026-04', v: 348.60 }, { d: '2026-05', v: 162.60 },
];
const TREND_DATA = { day: TREND_DAYS, week: TREND_WEEKS, month: TREND_MONTHS };
const TREND_LABEL = {
day: { sub: '// 近 14 天 · 单位 ¥', sumLbl: '14 天合计', avgLbl: '日均' },
week: { sub: '// 近 8 周 · 单位 ¥', sumLbl: '8 周合计', avgLbl: '周均' },
month: { sub: '// 近 6 月 · 单位 ¥', sumLbl: '6 月合计', avgLbl: '月均' },
};
let _trendGrain = 'day';
const PROJECTS_BILL = [
{ name: '补水面膜 · v3', product: '透真补水面膜', owner: '李', role: 'super', stage: 'Stage 3 故事板', stagePct: 60, status: 'wip', statusLabel: '进行中', amount: 48.20 },
{ name: '透真防晒 · 通勤对比', product: '透真防晒', owner: '李', role: 'super', stage: 'Stage 5 导出', stagePct: 100, status: 'ok', statusLabel: '已完成', amount: 32.60 },
{ name: '蓝牙耳机 · 开箱测评', product: 'Pro 4 蓝牙耳机', owner: '张', role: 'admin', stage: 'Stage 4 视频', stagePct: 80, status: 'wip', statusLabel: '进行中', amount: 28.40 },
{ name: '速食面 · 加班场景', product: '熊猫速食面', owner: '陈', role: 'member', stage: 'Stage 5 导出', stagePct: 100, status: 'ok', statusLabel: '已完成', amount: 12.80 },
{ name: '春日新品 · 立体口红', product: '凝彩立体口红', owner: '李', role: 'super', stage: 'Stage 2 资产', stagePct: 40, status: 'wip', statusLabel: '进行中', amount: 18.30 },
{ name: '咖啡冻干 · 早八', product: '冷萃咖啡冻干', owner: '王', role: 'member', stage: 'Stage 3 故事板', stagePct: 60, status: 'fail', statusLabel: '失败 · 待重跑', amount: 0 },
{ name: '瑜伽裤 · 通勤穿搭', product: '透气速干瑜伽裤', owner: '王', role: 'member', stage: 'Stage 5 导出', stagePct: 100, status: 'ok', statusLabel: '已完成', amount: 14.80 },
{ name: '保温杯 · 户外随行', product: '316 保温杯', owner: '张', role: 'admin', stage: 'Stage 1 脚本', stagePct: 20, status: 'wip', statusLabel: '进行中', amount: 7.50 },
];
const MEMBERS_BILL = [
{ av: '李', name: '小李', role: 'super', projectsDone: 14, used: 162.60, monthly: 10000, lastActive: '15 分钟前' },
{ av: '张', name: '张运营', role: 'admin', projectsDone: 8, used: 98.40, monthly: 6000, lastActive: '10 分钟前' },
{ av: '王', name: '王小姐', role: 'member', projectsDone: 4, used: 45.20, monthly: 2000, lastActive: '28 分钟前' },
{ av: '陈', name: '陈策划', role: 'member', projectsDone: 1, used: 12.80, monthly: 2000, lastActive: '4 小时前' },
{ av: '林', name: '林新人', role: 'member', projectsDone: 0, used: 0.00, monthly: 2000, lastActive: '尚未激活' },
];
const BILLS = [
{ ts: '05.21 14:32', proj: '补水面膜 · v3', type: '视频片段', detail: 'Seedance · 场 1 · 1 镜', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -0.45 },
{ ts: '05.21 14:08', proj: '补水面膜 · v3', type: '视频片段', detail: 'Seedance · 场 2 · 1 镜', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.45 },
{ ts: '05.20 21:42', proj: '蓝牙耳机 · 开箱测评', type: '视频片段', detail: 'Seedance · 场 3 · 6 镜', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -1.20 },
{ ts: '05.20 18:21', proj: '透真防晒 · 通勤对比', type: '视频片段', detail: 'Seedance · 整段 · 6 镜', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -1.20 },
{ ts: '05.20 16:00', proj: '蓝牙耳机 · 开箱测评', type: '故事板', detail: 'image-2 · 整张重跑 · 场 2', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.45 },
{ ts: '05.20 11:02', proj: '充值', type: '充值', detail: '微信支付 · TX2024052011021Z', who: '李', role: 'super', status: 'ok', statusLabel: '到账', amount: 500.00 },
{ ts: '05.19 18:08', proj: '速食面 · 加班场景', type: '故事板', detail: 'image-2 · 场 1', who: '陈', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.30 },
{ ts: '05.19 16:08', proj: '补水面膜 · v3', type: '故事板', detail: 'image-2 · 场 1', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -0.45 },
{ ts: '05.19 14:02', proj: '补水面膜 · v3', type: '脚本 LLM', detail: '2.4k tokens · AI 全生', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -0.04 },
{ ts: '05.19 13:38', proj: '补水面膜 · v3', type: '基础资产', detail: 'image-2 · 5 张', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -1.05 },
{ ts: '05.19 11:18', proj: '补水面膜 · v3', type: '故事板', detail: 'image-2 · 场 2', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.45 },
{ ts: '05.19 11:12', proj: '速食面 · 加班场景', type: '基础资产', detail: 'image-2 · 2 张', who: '陈', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.42 },
{ ts: '05.18 15:42', proj: '咖啡冻干 · 早八', type: '故事板', detail: 'image-2 · 场 3', who: '王', role: 'member', status: 'fail', statusLabel: '失败不扣', amount: 0 },
{ ts: '05.18 09:42', proj: '蓝牙耳机 · 开箱测评', type: '基础资产', detail: 'image-2 · 4 张', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.84 },
{ ts: '05.17 14:38', proj: '蓝牙耳机 · 开箱测评', type: '脚本 LLM', detail: '1.8k tokens · 自带粘贴', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.03 },
{ ts: '05.17 10:30', proj: '瑜伽裤 · 通勤穿搭', type: '导出', detail: '1080×1920 · 9:16 · 38s', who: '王', role: 'member', status: 'ok', statusLabel: '免费', amount: 0 },
{ ts: '05.17 10:08', proj: '瑜伽裤 · 通勤穿搭', type: '视频片段', detail: 'Seedance · 整段 · 5 镜', who: '王', role: 'member', status: 'ok', statusLabel: '通过', amount: -3.20 },
{ ts: '05.16 19:38', proj: '透真防晒 · 通勤对比', type: '视频片段', detail: 'Seedance · 4 镜', who: '王', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.80 },
{ ts: '05.16 11:42', proj: '透真防晒 · 通勤对比', type: '故事板', detail: 'image-2 · 场 2', who: '王', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.45 },
{ ts: '05.15 16:08', proj: '透真防晒 · 通勤对比', type: '基础资产', detail: 'image-2 · 3 张', who: '王', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.63 },
];
const ROLE_META = {
super: { label: '超管', cls: 'role-super' },
admin: { label: '团管', cls: 'role-admin' },
member: { label: '成员', cls: 'role-member' },
};
function fmtMoney(n) { return '¥' + Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
function amtStr(n) {
if (n === 0) return '¥0.00';
return (n > 0 ? '+' : '-') + fmtMoney(n);
}
function amtCls(n) { return n > 0 ? 'pos' : (n === 0 ? 'zero' : 'neg'); }
/* ─── 趋势柱 ─── */
function renderTrend() {
const data = TREND_DATA[_trendGrain];
const meta = TREND_LABEL[_trendGrain];
const bars = document.getElementById('trend-bars');
const xax = document.getElementById('trend-xaxis');
const max = Math.max(...data.map(d => d.v));
// bars 的 grid-template-columns 默认是 repeat(14, 1fr),需要按数据长度动态调整
bars.style.gridTemplateColumns = `repeat(${data.length}, 1fr)`;
xax.style.gridTemplateColumns = `repeat(${data.length}, 1fr)`;
bars.innerHTML = data.map(d => {
const h = max > 0 ? (d.v / max * 100) : 0;
const isPeak = d.v === max;
return `<div class="bar${isPeak ? ' peak' : ''}" title="${d.d} · ${fmtMoney(d.v)}"><span style="height: ${h.toFixed(1)}%"></span></div>`;
}).join('');
// x 轴标签:日 = 隔列显示 MM·DD 的 DD 部分;周/月 = 全显示
if (_trendGrain === 'day') {
xax.innerHTML = data.map((d, i) => i % 2 === 0 ? `<span>${d.d.slice(3)}</span>` : '<span></span>').join('');
} else if (_trendGrain === 'week') {
xax.innerHTML = data.map(d => `<span>${d.d}</span>`).join('');
} else {
xax.innerHTML = data.map(d => `<span>${d.d.slice(5)}</span>`).join('');
}
const sum = data.reduce((s, d) => s + d.v, 0);
document.getElementById('trend-sub').textContent = meta.sub;
document.getElementById('trend-sum').textContent = fmtMoney(sum);
document.getElementById('trend-avg').textContent = fmtMoney(sum / data.length);
document.getElementById('trend-peak').textContent = fmtMoney(max);
// 旁标 label
document.querySelectorAll('.trend-foot .item').forEach((it, idx) => {
const k = it.querySelector('.k');
if (idx === 0 && k) k.textContent = meta.sumLbl;
else if (idx === 1 && k) k.textContent = meta.avgLbl;
});
}
/* ─── 趋势 日/周/月 切换 ─── */
document.querySelectorAll('.trend-head .chip[data-grain]').forEach(chip => {
chip.addEventListener('click', () => {
document.querySelectorAll('.trend-head .chip[data-grain]').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
_trendGrain = chip.dataset.grain;
renderTrend();
});
});
/* ─── 按项目 表格 + 筛选 ─── */
const PROJ_FILTER = { status: 'all', range: 'month' };
const RANGE_MULT = { 'month': 1, '30d': 1, '90d': 2.6 }; // 演示用,30/90 天放大倍率(月 = 当月真实)
const RANGE_LBL = { 'month': '当月', '30d': '近 30 天', '90d': '近 90 天' };
function renderProjects() {
const tb = document.getElementById('proj-body');
const mult = RANGE_MULT[PROJ_FILTER.range] || 1;
const list = PROJECTS_BILL.filter(p => PROJ_FILTER.status === 'all' || p.status === PROJ_FILTER.status);
tb.innerHTML = list.map(p => {
const r = ROLE_META[p.role];
const amt = p.amount * mult;
return `
<tr>
<td><a href="pipeline.html?product=${encodeURIComponent(p.product)}" style="color:var(--accent-black);text-decoration:none;font-weight:500;">${p.name}</a></td>
<td class="muted">${p.product}</td>
<td><span class="who"><span class="av">${p.owner}</span><span class="role-pill ${r.cls}"><span class="dot"></span>${r.label}</span></span></td>
<td><span class="muted">${p.stage}</span><span class="progress-mini"><span style="width:${p.stagePct}%"></span></span></td>
<td><span class="status-tag ${p.status}">${p.statusLabel}</span></td>
<td class="${amtCls(-amt)}">${amt === 0 ? '¥0.00' : '-' + fmtMoney(amt)}</td>
</tr>
`;
}).join('');
const sum = list.reduce((s, p) => s + p.amount * mult, 0);
document.getElementById('proj-count').innerHTML =
`共 <b style="color:var(--accent-black);">${list.length}</b> 个项目 · ${RANGE_LBL[PROJ_FILTER.range]}消耗 ${fmtMoney(sum)}`;
const empty = document.getElementById('proj-empty');
const tbl = document.querySelector('#panel-by-project .billing-table');
empty.style.display = list.length === 0 ? '' : 'none';
tbl.style.display = list.length === 0 ? 'none' : '';
const isDef = PROJ_FILTER.status === 'all' && PROJ_FILTER.range === 'month';
document.getElementById('proj-f-reset').style.display = isDef ? 'none' : '';
}
['status', 'range'].forEach(key => {
const sel = document.getElementById('proj-f-' + key);
sel.addEventListener('change', () => { PROJ_FILTER[key] = sel.value; renderProjects(); });
});
document.getElementById('proj-f-reset').addEventListener('click', () => {
PROJ_FILTER.status = 'all'; PROJ_FILTER.range = 'month';
document.getElementById('proj-f-status').value = 'all';
document.getElementById('proj-f-range').value = 'month';
renderProjects();
});
/* ─── 按成员 表格 + 筛选 ─── */
const MEM_FILTER = { role: 'all', range: 'month' };
function renderMembers() {
const tb = document.getElementById('member-body');
const mult = RANGE_MULT[MEM_FILTER.range] || 1;
const list = MEMBERS_BILL.filter(m => MEM_FILTER.role === 'all' || m.role === MEM_FILTER.role);
tb.innerHTML = list.map(m => {
const r = ROLE_META[m.role];
const used = m.used * mult;
const pct = m.monthly > 0 ? (used / m.monthly * 100) : 0;
return `
<tr style="cursor:pointer;">
<td><span class="who"><span class="av">${m.av}</span><strong style="font-weight:500;">${m.name}</strong></span></td>
<td><span class="role-pill ${r.cls}"><span class="dot"></span>${r.label}</span></td>
<td>${m.projectsDone}</td>
<td>
<strong style="font-variant-numeric:tabular-nums;font-weight:600;">${fmtMoney(used)}</strong>
<span class="muted"> / ${fmtMoney(m.monthly)} · ${pct.toFixed(1)}%</span>
<span class="progress-mini"><span style="width:${Math.min(100, pct)}%; background:${pct >= 85 ? '#B45309' : 'var(--heat)'};"></span></span>
</td>
<td><span class="ts">${m.lastActive}</span></td>
</tr>
`;
}).join('');
const sum = list.reduce((s, m) => s + m.used * mult, 0);
document.getElementById('mem-count').innerHTML =
`共 <b style="color:var(--accent-black);">${list.length}</b> 人 · ${RANGE_LBL[MEM_FILTER.range]}合计 ${fmtMoney(sum)}`;
const empty = document.getElementById('mem-empty');
const tbl = document.querySelector('#panel-by-member .billing-table');
empty.style.display = list.length === 0 ? '' : 'none';
tbl.style.display = list.length === 0 ? 'none' : '';
const isDef = MEM_FILTER.role === 'all' && MEM_FILTER.range === 'month';
document.getElementById('mem-f-reset').style.display = isDef ? 'none' : '';
}
['role', 'range'].forEach(key => {
const sel = document.getElementById('mem-f-' + key);
sel.addEventListener('change', () => { MEM_FILTER[key] = sel.value; renderMembers(); });
});
document.getElementById('mem-f-reset').addEventListener('click', () => {
MEM_FILTER.role = 'all'; MEM_FILTER.range = 'month';
document.getElementById('mem-f-role').value = 'all';
document.getElementById('mem-f-range').value = 'month';
renderMembers();
});
/* ─── 账单流水:筛选 + 表格渲染 ─── */
const BILLS_FILTER = { stage: 'all', member: 'all', range: '30d' };
// demo "今天" = 05.21,所有 ts 都是 MM.DD 格式,这里基于这一假定算时间区间
const TODAY_MD = '05.21';
function mdToDay(md) {
// 把 "MM.DD" 当成 2026 年的日子换算成 1970 epoch ms,只用来比较先后
const [m, d] = md.split('.').map(Number);
return Date.UTC(2026, (m || 1) - 1, d || 1);
}
const TODAY_MS = mdToDay(TODAY_MD);
function passRange(ts, range) {
if (range === 'all') return true;
const md = ts.slice(0, 5); // "05.21"
if (range === 'month') return md.startsWith(TODAY_MD.slice(0, 3));
const diff = TODAY_MS - mdToDay(md);
if (range === '7d') return diff <= 6 * 86400000;
if (range === '30d') return diff <= 29 * 86400000;
return true;
}
function getFilteredBills() {
return BILLS.filter(b => {
if (BILLS_FILTER.stage !== 'all' && b.type !== BILLS_FILTER.stage) return false;
if (BILLS_FILTER.member !== 'all' && b.who !== BILLS_FILTER.member) return false;
if (!passRange(b.ts, BILLS_FILTER.range)) return false;
return true;
});
}
function renderBills() {
const tb = document.getElementById('bills-body');
const list = getFilteredBills();
tb.innerHTML = list.map(b => {
const r = ROLE_META[b.role];
return `
<tr>
<td class="ts">${b.ts}</td>
<td><strong style="font-weight:500;">${b.proj}</strong><br><span class="muted">${b.type}</span></td>
<td class="muted">${b.detail}</td>
<td><span class="who"><span class="av">${b.who}</span></span></td>
<td><span class="status-tag ${b.status}">${b.statusLabel}</span></td>
<td class="${amtCls(b.amount)}">${b.amount === 0 ? '¥0.00' : (b.amount > 0 ? '+' + fmtMoney(b.amount) : '-' + fmtMoney(b.amount))}</td>
</tr>
`;
}).join('');
document.getElementById('bills-count').textContent = list.length;
const empty = document.getElementById('bills-empty');
const table = document.querySelector('#panel-bills .billing-table');
if (list.length === 0) {
empty.style.display = '';
table.style.display = 'none';
} else {
empty.style.display = 'none';
table.style.display = '';
}
// 「清除筛选」按钮:任何一个非默认就显示
const isDefault = BILLS_FILTER.stage === 'all' && BILLS_FILTER.member === 'all' && BILLS_FILTER.range === '30d';
document.getElementById('bills-f-reset').style.display = isDefault ? 'none' : '';
}
/* ─── 筛选绑定 ─── */
['stage', 'member', 'range'].forEach(key => {
const sel = document.getElementById('bills-f-' + key);
sel.addEventListener('change', () => {
BILLS_FILTER[key] = sel.value;
renderBills();
});
});
document.getElementById('bills-f-reset').addEventListener('click', () => {
BILLS_FILTER.stage = 'all'; BILLS_FILTER.member = 'all'; BILLS_FILTER.range = '30d';
document.getElementById('bills-f-stage').value = 'all';
document.getElementById('bills-f-member').value = 'all';
document.getElementById('bills-f-range').value = '30d';
renderBills();
});
/* ─── Tab 切换 ─── */
document.querySelectorAll('.billing-tabs .tab').forEach(tab => {
tab.addEventListener('click', () => {
const targetId = 'panel-' + tab.dataset.tab;
document.querySelectorAll('.billing-tabs .tab').forEach(t => {
const active = t === tab;
t.classList.toggle('active', active);
t.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('.tab-panel').forEach(p => {
p.classList.toggle('active', p.id === targetId);
});
});
});
/* ─── 快速充值卡选择 ─── */
function setRechargeCard(card) {
document.querySelectorAll('.recharge-card').forEach(c => {
const active = c === card;
c.classList.toggle('selected', active);
c.setAttribute('aria-pressed', active ? 'true' : 'false');
});
const label = document.getElementById('topup-selected-label');
if (label && card) label.textContent = '已选 ¥' + Number(card.dataset.amt).toLocaleString('zh-CN');
}
document.querySelectorAll('.recharge-card').forEach(card => {
card.addEventListener('click', () => {
setRechargeCard(card);
document.getElementById('custom-amt').value = '';
});
card.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setRechargeCard(card);
document.getElementById('custom-amt').value = '';
}
});
});
document.getElementById('custom-amt').addEventListener('input', e => {
const raw = e.target.value.trim();
const label = document.getElementById('topup-selected-label');
if (!raw) {
const selected = document.querySelector('.recharge-card.selected');
if (label && selected) label.textContent = '已选 ¥' + Number(selected.dataset.amt).toLocaleString('zh-CN');
return;
}
document.querySelectorAll('.recharge-card').forEach(c => {
c.classList.remove('selected');
c.setAttribute('aria-pressed', 'false');
});
if (label) label.textContent = Number(raw) >= 50 ? '自定义 ¥' + Number(raw).toLocaleString('zh-CN') : '最低 ¥50';
});
/* ─── 充值 modal ─── */
function openTopup(channel) {
const selected = document.querySelector('.recharge-card.selected');
const customRaw = document.getElementById('custom-amt').value.trim();
const custom = Number(customRaw);
let amt = 500, bonus = 30;
if (custom >= 50) { amt = custom; bonus = 0; }
else if (selected) {
amt = Number(selected.dataset.amt);
const bonusEl = selected.querySelector('.gift.bonus');
bonus = bonusEl ? Number(bonusEl.textContent.replace(/\D/g, '')) : 0;
}
document.getElementById('topup-amt').textContent = fmtMoney(amt);
document.getElementById('topup-bonus').textContent = bonus > 0
? `// 含 ¥${bonus} 赠送 · 实到账 ¥${(amt + bonus).toFixed(2)}`
: '// 无赠送';
const isAlipay = channel === 'alipay';
document.getElementById('topup-channel-label').textContent = isAlipay ? '// 支付宝' : '// 微信支付';
document.getElementById('topup-channel-name').innerHTML = (isAlipay ? '支付宝扫码' : '微信扫码') +
'<br><span style="color:var(--black-alpha-32);">/topup/' + (isAlipay ? 'ali' : 'wx') + '/TX' + Date.now() + '</span>';
Shell.openModal('topup-bg');
}
function topupDone() {
Shell.closeModal('topup-bg');
Shell.toast('充值成功', '余额已更新 · 可开发票');
}
/* ─── 初始化 ─── */
renderTrend();
renderProjects();
renderMembers();
renderBills();
</script>
<script src="assets/api-bridge.js?v=2026060114"></script>
</body>
</html>
@@ -0,0 +1,947 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>图片生成 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
<style>
#page-content { padding: 24px 28px 60px; }
/* ─── 三 Hero 卡片网格(模特上身图 / 平台套图 / 图片创作 · 等比)─── */
.factory-hero {
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px; margin-bottom: 56px;
}
@media (max-width: 1400px) { .factory-hero { grid-template-columns: 1fr 1fr; } }
@media (max-width: 1000px) { .factory-hero { grid-template-columns: 1fr; } }
.factory-card {
position: relative;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 28px 30px;
overflow: hidden;
}
/* 卡片内 · 文上图下 单列(3 卡并排时保持视觉一致)*/
.factory-body {
display: flex; flex-direction: column;
gap: 18px;
height: 100%;
}
.factory-text { display: flex; flex-direction: column; min-width: 0; }
.factory-tag {
align-self: flex-start;
font-family: var(--font-mono); font-size: 10.5px;
color: var(--black-alpha-48); letter-spacing: .06em;
padding: 2px 8px;
border: 1px solid var(--border-faint);
background: var(--background-lighter);
border-radius: var(--r-sm);
margin-bottom: 14px;
}
.factory-title {
font-size: 22px; font-weight: 600;
letter-spacing: -.018em; line-height: 1.25;
color: var(--accent-black);
}
.factory-desc {
margin-top: 8px;
font-size: 13.5px; color: var(--black-alpha-64); line-height: 1.55;
}
/* feature 列表 */
.factory-features {
list-style: none; padding: 0;
margin: 22px 0 0;
display: flex; flex-direction: column; gap: 11px;
}
.factory-features li {
display: flex; align-items: center; gap: 10px;
font-size: 13px; color: var(--black-alpha-72); font-weight: 500;
}
.factory-features .ff-ic {
width: 26px; height: 26px;
display: inline-flex; align-items: center; justify-content: center;
background: var(--heat-12);
color: var(--heat);
border-radius: var(--r-md);
flex-shrink: 0;
}
.factory-features .ff-ic svg { width: 14px; height: 14px; }
/* 平台 chip 行 */
.platform-row {
display: flex; flex-wrap: wrap; gap: 8px;
margin-top: 20px;
}
.platform-chip {
display: inline-flex; align-items: center; gap: 7px;
height: 30px; padding: 0 12px 0 8px;
border: 1px solid var(--border-faint);
background: var(--surface);
border-radius: var(--r-pill);
transition: background var(--t-base);
cursor: pointer;
}
.platform-chip:hover { background: var(--black-alpha-4); }
.platform-chip .code {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px;
background: var(--accent-black); color: var(--accent-white);
font-family: var(--font-mono); font-size: 8.5px; font-weight: 600;
border-radius: var(--r-pill); letter-spacing: .04em;
}
.platform-chip .nm {
font-size: 12px; color: var(--accent-black); font-weight: 500;
}
/* CTA 行:主按钮 + 价格 mono */
.factory-cta {
margin-top: auto; padding-top: 24px;
display: flex; align-items: center; gap: 14px;
}
.factory-cta .cost {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--black-alpha-48); letter-spacing: .04em;
}
/* 视觉占位 + feature 列表已隐藏 — CTA 卡片只保留标题 + 描述 + 按钮 */
.factory-visual { display: none; }
.factory-features { display: none; }
.model-visual { grid-template-columns: repeat(4, 1fr); }
.model-visual .main { aspect-ratio: 3 / 4; grid-column: span 1; }
.model-visual .stack { display: contents; }
.model-visual .stack .placeholder { aspect-ratio: 3 / 4; }
.kit-visual { grid-template-columns: repeat(4, 1fr); }
.kit-visual .placeholder { aspect-ratio: 1 / 1; }
/* ─── 任务中心 · section header ─── */
.section-h { display: flex; align-items: center; gap: 12px; margin-top: 24px; margin-bottom: 14px; }
.section-h h2 { font-size: 18px; font-weight: 600; letter-spacing: -.01em; }
.section-h .sub-mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
/* ─── 视图切换 (复用 projects.html · 图标 + 文字) ─── */
.view-toggle { display: inline-flex; border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; }
.view-toggle button { padding: 0 14px; background: var(--surface); color: var(--black-alpha-56); font-size: 13px; border: 0; border-right: 1px solid var(--border-faint); border-radius: 0; height: 36px; cursor: pointer; font-family: inherit; display: flex; align-items: center; gap: 6px; transition: background var(--t-base), color var(--t-base); }
.view-toggle button:last-child { border-right: 0; }
.view-toggle button:hover { background: var(--background-lighter); color: var(--accent-black); }
.view-toggle button.active { background: var(--heat-12); color: var(--heat); font-weight: 600; }
.view-toggle button svg { width: 13px; height: 13px; }
/* ─── 网格视图 · 卡片(原 history-grid) ─── */
.history-grid {
display: grid; grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
@media (max-width: 1280px) { .history-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 960px) { .history-grid { grid-template-columns: repeat(2, 1fr); } }
.history-grid[hidden] { display: none; }
.history-card {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 12px;
display: grid; grid-template-columns: 78px 1fr; gap: 14px;
align-items: center;
transition: background var(--t-base);
cursor: pointer;
position: relative;
}
.history-card:hover { background: var(--black-alpha-4); }
.history-card:hover .card-del-btn { opacity: 1; }
.history-card .placeholder { width: 78px; height: 78px; }
/* ─── 列表视图 · 表格 ─── */
#task-list-view {
background: var(--surface);
border: 1px solid var(--border-muted);
border-radius: var(--r-md);
overflow: hidden;
}
#task-list-view[hidden] { display: none; }
#task-list-view table.t {
border: 0;
border-radius: 0;
background: transparent;
}
#task-list-view table.t thead th {
background: var(--background-lighter);
border-bottom-color: var(--border-muted);
}
#task-list-view table.t tbody td {
border-bottom: 0;
}
.task-name-cell { display: flex; align-items: center; gap: 12px; }
.task-thumb { width: 40px; height: 40px; flex-shrink: 0; border-radius: var(--r-sm); }
.task-name { font-weight: 600; color: var(--accent-black); font-size: 13.5px; }
.task-sub { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
table.t .task-list-prog { display: flex; align-items: center; gap: 8px; min-width: 120px; }
table.t .task-list-prog .bar { flex: 1; height: 4px; background: var(--black-alpha-7); border-radius: 2px; overflow: hidden; }
table.t .task-list-prog .bar span { display: block; height: 100%; background: var(--heat); border-radius: 2px; animation: hp-pulse 1.4s ease-in-out infinite; }
table.t .task-list-prog .pct { font-family: var(--font-mono); font-size: 10.5px; color: var(--heat); letter-spacing: .02em; white-space: nowrap; }
/* ─── 行末 ⋯ 删除气泡 (复用 projects.html) ─── */
.row-action { display: flex; gap: 4px; justify-content: flex-end; }
table.t tbody tr .row-more { opacity: 0; transition: opacity .15s; }
table.t tbody tr:hover .row-more { opacity: 1; }
.row-more {
position: relative; display: inline-flex;
cursor: pointer; align-items: center;
color: var(--black-alpha-56);
padding: 4px;
}
.row-more:hover { color: var(--accent-black); }
.row-more-tip {
position: absolute; top: calc(100% + 6px); right: 0;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: 0 4px 16px rgba(0,0,0,.08);
padding: 4px; min-width: 110px;
opacity: 0; pointer-events: none;
transform: translateY(-2px);
transition: opacity .15s, transform .15s;
z-index: 12;
}
.row-more-tip::before {
content: ''; position: absolute;
top: -8px; left: 0; right: 0; height: 8px;
}
.row-more:hover .row-more-tip,
.row-more-tip:hover { opacity: 1; pointer-events: auto; transform: translateY(0); }
.row-more-tip .mi {
display: flex; align-items: center; gap: 6px;
width: 100%; padding: 6px 10px;
background: transparent; border: 0;
border-radius: var(--r-sm); cursor: pointer;
font-size: 12.5px; color: var(--accent-black);
font-family: inherit; text-align: left;
transition: background var(--t-base), color var(--t-base);
}
.row-more-tip .mi:hover {
background: var(--crimson-bg, #fdebea);
color: var(--accent-crimson, #c43d3d);
}
.row-more-tip .mi svg { width: 13px; height: 13px; }
/* ─── 删除确认 modal · 复用 ─── */
.modal-bg.show { display: flex; }
.mono-acc { font-family: var(--font-mono); color: var(--heat); font-weight: 600; }
.history-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.history-name {
font-size: 13.5px; font-weight: 600; color: var(--accent-black);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.history-type {
font-size: 11.5px; color: var(--black-alpha-48);
font-family: var(--font-mono); letter-spacing: .02em;
}
.history-foot {
display: flex; align-items: center; justify-content: space-between;
margin-top: 4px; gap: 6px; min-width: 0;
}
.history-foot .mono {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--black-alpha-48); letter-spacing: .02em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.history-foot .pill { padding: 2px 8px; font-size: 10.5px; }
.history-foot .pill .dot { width: 5px; height: 5px; }
/* 进度条(生成中状态) */
.history-prog {
margin-top: 6px;
display: flex; align-items: center; gap: 8px;
}
.history-prog .bar {
flex: 1; height: 4px;
background: var(--black-alpha-7);
border-radius: 2px; overflow: hidden;
}
.history-prog .bar span {
display: block; height: 100%;
background: var(--heat); border-radius: 2px;
animation: hp-pulse 1.4s ease-in-out infinite;
}
@keyframes hp-pulse {
0%, 100% { opacity: 1; transform: scaleY(1); }
50% { opacity: .55; transform: scaleY(.7); }
}
.history-prog .pct {
font-family: var(--font-mono); font-size: 10px;
color: var(--heat); letter-spacing: .02em; white-space: nowrap;
}
</style>
</head>
<body>
<div id="page">
<div class="page-head">
<div>
<h1>图片生成</h1>
<div class="sub">
<span class="mono">// 一键生成</span>
<span>·</span>
<span>电商视觉素材,提升内容制作效率</span>
</div>
</div>
</div>
<!-- 双 Hero 卡片 -->
<div class="factory-hero">
<!-- 卡片 A · 模特上身图 -->
<div class="factory-card with-corners">
<span class="corner-tr" aria-hidden></span>
<span class="corner-bl" aria-hidden></span>
<div class="factory-body">
<div class="factory-text">
<span class="factory-tag">[ MODEL · TRY-ON ]</span>
<div class="factory-title">模特上身图</div>
<div class="factory-desc">选择模特,AI 生成商品模特上身效果图</div>
<ul class="factory-features">
<li>
<span class="ff-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="9" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19c0-3 2.7-5 6-5s6 2 6 5M14 19c.5-2.4 2.4-4 5-4 .8 0 1.5.2 2 .5"/></svg>
</span>
支持多模特选择
</li>
<li>
<span class="ff-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="8" height="8"/><rect x="13" y="3" width="8" height="8"/><rect x="3" y="13" width="8" height="8"/><rect x="13" y="13" width="8" height="8"/></svg>
</span>
一次生成 4 张
</li>
<li>
<span class="ff-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 9-4 9 4-9 4-9-4z"/><path d="m3 12 9 4 9-4M3 17l9 4 9-4"/></svg>
</span>
支持多商品并行
</li>
</ul>
<div class="factory-cta">
<a class="btn btn-primary btn-lg" href="model-photo.html">
开始生成
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</a>
<span class="cost">[ ≈ ¥0.30 / 张 ]</span>
</div>
</div>
<div class="factory-visual model-visual">
<div class="placeholder main"><span class="ph-frame">Ava · 9:16</span></div>
<div class="stack">
<div class="placeholder"><span class="ph-frame">变体 01</span></div>
<div class="placeholder"><span class="ph-frame">变体 02</span></div>
<div class="placeholder"><span class="ph-frame">变体 03</span></div>
</div>
</div>
</div>
</div>
<!-- 卡片 B · 平台套图 -->
<div class="factory-card with-corners">
<span class="corner-tr" aria-hidden></span>
<span class="corner-bl" aria-hidden></span>
<div class="factory-body">
<div class="factory-text">
<span class="factory-tag">[ PLATFORM · KIT ]</span>
<div class="factory-title">平台套图</div>
<div class="factory-desc">选择平台模板,AI 生成电商平台套图</div>
<ul class="factory-features">
<li>
<span class="ff-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V9M9 21V5M15 21v-8M21 21V11"/></svg>
</span>
覆盖主流电商平台
</li>
<li>
<span class="ff-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="8" height="8"/><rect x="13" y="3" width="8" height="8"/><rect x="3" y="13" width="8" height="8"/><rect x="13" y="13" width="8" height="8"/></svg>
</span>
一键生成 4 张套图
</li>
<li>
<span class="ff-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z"/></svg>
</span>
智能排版设计
</li>
</ul>
<div class="factory-cta">
<a class="btn btn-primary btn-lg" href="platform-cover.html">
开始生成
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</a>
<span class="cost">[ ≈ ¥0.50 / 张 ]</span>
</div>
</div>
<div class="factory-visual kit-visual">
<div class="placeholder"><span class="ph-frame">套图 / TB</span></div>
<div class="placeholder"><span class="ph-frame">套图 / DY</span></div>
<div class="placeholder"><span class="ph-frame">套图 / XHS</span></div>
<div class="placeholder"><span class="ph-frame">套图 / PDD</span></div>
</div>
</div>
</div>
<!-- 卡片 C · 图片创作(自由创作 AI 图片工作台)-->
<div class="factory-card with-corners">
<span class="corner-tr" aria-hidden></span>
<span class="corner-bl" aria-hidden></span>
<div class="factory-body">
<div class="factory-text">
<span class="factory-tag">[ IMAGE · STUDIO ]</span>
<div class="factory-title">图片创作</div>
<div class="factory-desc">自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写</div>
<ul class="factory-features">
<li>
<span class="ff-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="9" r="3"/><path d="M3 19c0-3 2.7-5 6-5s6 2 6 5"/></svg>
</span>
人物 · 商品 全支持
</li>
<li>
<span class="ff-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="6" height="18"/><rect x="9" y="3" width="6" height="18"/><rect x="15" y="3" width="6" height="18"/></svg>
</span>
正面 / 侧面 / 背面 一次输出
</li>
<li>
<span class="ff-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
</span>
多镜头一致性保证
</li>
</ul>
<div class="factory-cta">
<a class="btn btn-primary btn-lg" href="image-optimize.html">
开始生成
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</a>
<span class="cost">[ ≈ ¥0.40 / 组 ]</span>
</div>
</div>
<div class="factory-visual tri-visual">
<div class="placeholder"><span class="ph-frame">正 / 侧 / 背 · 三视图</span></div>
</div>
</div>
</div>
</div>
<!-- ============= 任务中心 · 参考 projects.html 布局 ============= -->
<div class="section-h">
<h2>任务中心</h2>
<span class="sub-mono">// <span id="tc-sub-total">0</span> 个 · <span id="tc-sub-gen">0</span> 生成中 · <span id="tc-sub-ok">0</span> 已完成 · <span id="tc-sub-err">0</span> 失败</span>
</div>
<!-- 状态 tabs (复用 .tabs) -->
<div class="tabs" id="tc-tabs">
<div class="tab active" data-filter="all">全部 <span class="count">0</span></div>
<div class="tab" data-filter="gen">生成中 <span class="count">0</span></div>
<div class="tab" data-filter="ok">已完成 <span class="count">0</span></div>
<div class="tab" data-filter="err">失败 <span class="count">0</span></div>
</div>
<!-- toolbar: search + 类型 chip + clear + view-toggle -->
<div class="toolbar">
<div class="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input class="input" id="tc-search" placeholder="搜索任务名">
</div>
<div class="chip-wrap" data-key="time">
<button class="chip" type="button"><span class="chip-label">时间</span> <svg class="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
<div class="chip-menu">
<div class="mi selected" data-value="all"><svg class="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg><span>全部时间</span></div>
<div class="mi-sep"></div>
<div class="mi" data-value="today"><svg class="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg><span>今天</span></div>
<div class="mi" data-value="1h"><svg class="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg><span>1 小时内</span></div>
<div class="mi" data-value="10min"><svg class="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg><span>10 分钟内</span></div>
</div>
</div>
<div class="chip-wrap" data-key="type">
<button class="chip" type="button"><span class="chip-label">任务类型</span> <svg class="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
<div class="chip-menu"></div>
</div>
<button class="clear-filters" id="tc-clear" type="button" hidden>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4l8 8M12 4l-8 8"/></svg>
清空筛选
</button>
<span class="spacer"></span>
<div class="view-toggle" id="tc-view-toggle">
<button type="button" data-view="grid">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="5" height="5"/><rect x="9" y="2" width="5" height="5"/><rect x="2" y="9" width="5" height="5"/><rect x="9" y="9" width="5" height="5"/></svg>
网格
</button>
<button type="button" class="active" data-view="list">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h12M2 12h12"/></svg>
列表
</button>
</div>
</div>
<div class="result-meta" id="tc-result-meta">// 显示 <span class="count">0</span> / 0 个任务</div>
<!-- ============= LIST VIEW (默认) ============= -->
<div id="task-list-view">
<table class="t">
<thead>
<tr>
<th style="width:42%">任务</th>
<th style="width:160px">进度</th>
<th>状态</th>
<th style="width:120px">创建于</th>
<th style="width:48px"></th>
</tr>
</thead>
<tbody id="task-list-tbody"><!-- JS 从卡片同步生成 --></tbody>
</table>
</div>
<!-- ============= GRID VIEW (JS 从 localStorage 动态渲染) ============= -->
<div class="history-grid" id="task-grid" hidden></div>
<!-- 空态 -->
<div id="tc-empty" hidden style="padding:60px 20px;text-align:center;color:var(--black-alpha-48);font-size:13px;line-height:1.6">
<div style="font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;margin-bottom:6px;">// NO TASKS YET</div>
<div id="tc-empty-text">还没有任务,去上方选一个工序开始生成吧</div>
</div>
</div>
<!-- ===== 删除确认 modal (复用 projects.html 风格) ===== -->
<div class="modal-bg" id="tc-del-bg">
<div class="modal" role="dialog">
<span class="corner-tr" aria-hidden></span>
<span class="corner-bl" aria-hidden></span>
<div class="modal-h">
<div class="ic-m" style="background:var(--crimson-bg,#fdebea);color:var(--accent-crimson,#c43d3d)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6"/></svg>
</div>
<div class="ti">确认删除任务<span>// CONFIRM DELETE</span></div>
</div>
<div class="modal-b" id="tc-del-body">即将删除任务记录。</div>
<div class="modal-f">
<button class="btn" type="button" id="tc-del-cancel">取消</button>
<button class="btn" type="button" id="tc-del-ok" style="background:var(--accent-crimson);color:var(--accent-white);border-color:var(--accent-crimson)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/></svg>
确认删除
</button>
</div>
</div>
</div>
<script src="assets/icons.js?v=2026052608"></script>
<script src="assets/shell.js?v=2026060101"></script>
<script src="assets/new-product-drawer.js?v=202605211643"></script>
<script>
Shell.render({
active: 'asset-factory',
crumbs: [{ label: '工作台', href: 'index.html' }, { label: '图片生成' }]
});
/* ============================================================
任务中心 · 从 localStorage 读取(与 model-photo / platform-cover 共享)
============================================================ */
(function () {
'use strict';
const TYPE_LABEL = { model: '模特上身图', platform: '平台套图', image: '图片创作' };
const STATUS_LABEL = { ok: '已完成', gen: '生成中', err: '失败' };
const STATUS_PILL = { ok: 'ok', gen: 'info', err: 'err' };
const KEY_BY_TYPE = {
model: 'fs-image-tasks-model',
platform: 'fs-image-tasks-platform',
image: 'fs-image-tasks-image',
};
const URL_BY_TYPE = {
model: 'model-photo.html',
platform: 'platform-cover.html',
image: 'image-optimize.html',
};
const taskGrid = document.getElementById('task-grid');
const listTbody = document.getElementById('task-list-tbody');
const gridView = taskGrid;
const listView = document.getElementById('task-list-view');
let cards = []; // 动态生成的 .task-card 元素数组(顺序与任务时间倒序一致)
const state = { filter: 'all', type: 'all', time: 'all', search: '', view: 'list' };
function _timeMatch(createdAt, key) {
if (key === 'all' || !createdAt) return true;
const now = Date.now();
const diff = now - Number(createdAt);
if (key === '10min') return diff <= 10 * 60 * 1000;
if (key === '1h') return diff <= 60 * 60 * 1000;
if (key === 'today') {
const a = new Date(now); const b = new Date(Number(createdAt));
return a.toDateString() === b.toDateString();
}
return true;
}
const TIME_LABEL = { all: '时间', today: '今天', '1h': '1 小时内', '10min': '10 分钟内' };
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
/* ---------- localStorage 读写 ---------- */
function loadType(type) {
try { return JSON.parse(localStorage.getItem(KEY_BY_TYPE[type]) || '[]'); } catch (e) { return []; }
}
function saveType(type, arr) {
try { localStorage.setItem(KEY_BY_TYPE[type], JSON.stringify(arr)); } catch (e) {}
}
function loadAllTasks() {
const all = [];
Object.keys(KEY_BY_TYPE).forEach(type => {
loadType(type).forEach(t => all.push({ ...t, type })); // type 兜底
});
all.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
return all;
}
/* ---------- task → 渲染辅助 ---------- */
function subTextOf(t) {
const lbl = TYPE_LABEL[t.type] || t.type;
const count = (t.snap && t.snap.count) || 4;
return `${lbl} · ${count} 张`;
}
function thumbLabelOf(t) {
// 取「商品 × 模特/平台」中右半作为缩略图占位文字
const parts = (t.name || '').split(/\s[×x]\s/);
return (parts[1] || parts[0] || '—').slice(0, 8);
}
/* ---------- 点击行 / 卡片 → 跳转工作台(携带 taskId) ---------- */
function goToWorkbench(t) {
const base = URL_BY_TYPE[t.type] || URL_BY_TYPE.model;
location.href = base + '?taskId=' + encodeURIComponent(t.id);
}
/* ---------- 1. 从 task 数据生成卡片 + list 行 ---------- */
function cardFor(t) {
const card = document.createElement('div');
card.className = 'task-card history-card';
card.dataset.status = t.status;
card.dataset.type = t.type;
card.dataset.name = t.name;
card.dataset.taskId = t.id;
card.dataset.createdAt = String(t.createdAt || 0);
card.style.cursor = 'pointer';
card.innerHTML = `
<button class="card-del-btn" type="button" title="删除任务">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6"/></svg>
</button>
<div class="placeholder"><span class="ph-frame">${esc(thumbLabelOf(t))}</span></div>
<div class="history-body">
<div class="history-name">${esc(t.name)}</div>
<div class="history-type">${esc(subTextOf(t))}</div>
<div class="history-foot">
<span class="mono">// ${esc(t.time || '')}</span>
<span class="pill ${STATUS_PILL[t.status] || 'info'}"><span class="dot"></span>${esc(STATUS_LABEL[t.status] || t.status)}</span>
</div>
</div>
`;
return card;
}
function rowFor(t) {
const tr = document.createElement('tr');
tr.dataset.taskRow = '1';
tr.dataset.name = t.name;
tr.dataset.type = t.type;
tr.dataset.status = t.status;
tr.dataset.taskId = t.id;
tr.addEventListener('click', () => goToWorkbench(t));
tr.innerHTML = `
<td>
<div class="task-name-cell">
<div class="placeholder task-thumb"><span class="ph-frame">${esc(thumbLabelOf(t))}</span></div>
<div>
<div class="task-name">${esc(t.name)}</div>
<div class="task-sub">${esc(subTextOf(t))}</div>
</div>
</div>
</td>
<td>${t.status === 'gen'
? `<div class="task-list-prog"><div class="bar"><span style="width:60%"></span></div><span class="pct">60%</span></div>`
: (t.status === 'ok' ? '<span class="muted-2 mono" style="font-size:11px;">已完成</span>' : '<span class="muted-2 mono" style="font-size:11px;"></span>')}</td>
<td><span class="pill ${STATUS_PILL[t.status] || 'info'}"><span class="dot"></span>${esc(STATUS_LABEL[t.status] || t.status)}</span></td>
<td class="muted-2">${esc(t.time || '')}</td>
<td>
<div class="row-action">
<span class="row-more"><svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor"/><circle cx="8" cy="8" r="1.2" fill="currentColor"/><circle cx="13" cy="8" r="1.2" fill="currentColor"/></svg>
<div class="row-more-tip"><button class="mi mi-del-task" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6"/></svg>删除任务</button></div>
</span>
</div>
</td>
`;
tr.querySelector('.row-more').addEventListener('click', e => e.stopPropagation());
return tr;
}
/* ---------- 2. 全量重渲染(load → render → filter) ---------- */
function renderAll() {
// 清空 grid / list
taskGrid.innerHTML = '';
listTbody.innerHTML = '';
cards = [];
const tasks = loadAllTasks();
tasks.forEach(t => {
const card = cardFor(t);
const row = rowFor(t);
taskGrid.appendChild(card);
listTbody.appendChild(row);
card._listRow = row;
card._task = t;
row._card = card;
cards.push(card);
// 卡片点击 → 跳工作台
card.addEventListener('click', e => {
if (e.target.closest('.card-del-btn')) return;
goToWorkbench(t);
});
// 卡片删除按钮
card.querySelector('.card-del-btn').addEventListener('click', e => {
e.stopPropagation();
openDelConfirm(card);
});
});
// 类型 chip 菜单(基于现有 task 的 type 集合,动态)
rebuildTypeMenu();
applyFilter();
}
/* ---------- 3. 构建类型 chip 菜单(基于当前 cards 的 type 集合) ---------- */
const checkSvg = '<svg class="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg>';
const typeMenu = document.querySelector('.chip-wrap[data-key="type"] .chip-menu');
function rebuildTypeMenu() {
const typeOptions = [...new Set(cards.map(c => c.dataset.type))];
typeMenu.innerHTML = `<div class="mi selected" data-value="all">${checkSvg}<span>全部任务类型</span></div><div class="mi-sep"></div>`
+ typeOptions.map(v => `<div class="mi" data-value="${esc(v)}">${checkSvg}<span>${esc(TYPE_LABEL[v] || v)}</span></div>`).join('');
syncTypeChip();
}
function syncTypeChip() {
const wrap = document.querySelector('.chip-wrap[data-key="type"]');
const label = wrap.querySelector('.chip-label');
const chip = wrap.querySelector('.chip');
if (state.type === 'all') {
label.textContent = '任务类型';
chip.classList.remove('active');
} else {
label.textContent = TYPE_LABEL[state.type] || state.type;
chip.classList.add('active');
}
wrap.querySelectorAll('.mi').forEach(mi => mi.classList.toggle('selected', mi.dataset.value === state.type));
}
function syncTimeChip() {
const wrap = document.querySelector('.chip-wrap[data-key="time"]');
if (!wrap) return;
const label = wrap.querySelector('.chip-label');
const chip = wrap.querySelector('.chip');
if (state.time === 'all') {
label.textContent = '时间';
chip.classList.remove('active');
} else {
label.textContent = TIME_LABEL[state.time] || state.time;
chip.classList.add('active');
}
wrap.querySelectorAll('.mi').forEach(mi => mi.classList.toggle('selected', mi.dataset.value === state.time));
}
/* ---------- 3. applyFilter ---------- */
function applyFilter() {
const q = state.search.toLowerCase();
let visible = 0;
cards.forEach(card => {
const okStatus = state.filter === 'all' || card.dataset.status === state.filter;
const okType = state.type === 'all' || card.dataset.type === state.type;
const okTime = _timeMatch(card.dataset.createdAt, state.time);
const okSearch = !q || (card.dataset.name || '').toLowerCase().includes(q);
const show = okStatus && okType && okTime && okSearch;
card.style.display = show ? '' : 'none';
if (card._listRow) card._listRow.style.display = show ? '' : 'none';
if (show) visible++;
});
// 计数
const counts = { all: cards.length, gen: 0, ok: 0, err: 0 };
cards.forEach(c => { if (counts[c.dataset.status] !== undefined) counts[c.dataset.status]++; });
document.querySelectorAll('#tc-tabs .tab').forEach(t => {
const f = t.dataset.filter;
t.querySelector('.count').textContent = f === 'all' ? counts.all : counts[f];
});
document.getElementById('tc-sub-total').textContent = counts.all;
document.getElementById('tc-sub-gen').textContent = counts.gen;
document.getElementById('tc-sub-ok').textContent = counts.ok;
document.getElementById('tc-sub-err').textContent = counts.err;
document.getElementById('tc-result-meta').innerHTML = `// 显示 <span class="count">${visible}</span> / ${cards.length} 个任务`;
document.getElementById('tc-clear').hidden = !(state.search || state.type !== 'all' || state.time !== 'all');
// 空态
const emptyEl = document.getElementById('tc-empty');
const emptyText = document.getElementById('tc-empty-text');
if (cards.length === 0) {
emptyEl.hidden = false;
emptyText.textContent = '还没有任务,去上方选一个工序开始生成吧';
listView.hidden = true; gridView.hidden = true;
} else if (visible === 0) {
emptyEl.hidden = false;
emptyText.textContent = '没有符合筛选条件的任务';
// 视图本身保留,空表头也保留
} else {
emptyEl.hidden = true;
// 恢复 view 显示(可能在 length===0 分支被隐藏)
if (state.view === 'list') { listView.hidden = false; gridView.hidden = true; }
else { listView.hidden = true; gridView.hidden = false; }
}
}
/* ---------- 4. 事件绑定 ---------- */
// status tabs
document.querySelectorAll('#tc-tabs .tab').forEach(t => {
t.addEventListener('click', () => {
document.querySelectorAll('#tc-tabs .tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');
state.filter = t.dataset.filter;
applyFilter();
});
});
// search
document.getElementById('tc-search').addEventListener('input', e => {
state.search = e.target.value.trim();
applyFilter();
});
// type chip
const typeWrap = document.querySelector('.chip-wrap[data-key="type"]');
typeWrap.querySelector('.chip').addEventListener('click', e => {
e.stopPropagation();
const isOpen = typeWrap.classList.contains('open');
document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));
if (!isOpen) typeWrap.classList.add('open');
});
typeMenu.addEventListener('click', e => {
const mi = e.target.closest('.mi');
if (!mi) return;
e.stopPropagation();
state.type = mi.dataset.value;
typeWrap.classList.remove('open');
syncTypeChip();
applyFilter();
});
// time chip
const timeWrap = document.querySelector('.chip-wrap[data-key="time"]');
const timeMenu = timeWrap.querySelector('.chip-menu');
timeWrap.querySelector('.chip').addEventListener('click', e => {
e.stopPropagation();
const isOpen = timeWrap.classList.contains('open');
document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));
if (!isOpen) timeWrap.classList.add('open');
});
timeMenu.addEventListener('click', e => {
const mi = e.target.closest('.mi');
if (!mi) return;
e.stopPropagation();
state.time = mi.dataset.value;
timeWrap.classList.remove('open');
syncTimeChip();
applyFilter();
});
document.addEventListener('click', () => {
document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));
});
// clear filters
document.getElementById('tc-clear').addEventListener('click', () => {
state.search = '';
state.type = 'all';
state.time = 'all';
document.getElementById('tc-search').value = '';
syncTypeChip();
syncTimeChip();
applyFilter();
Shell.toast('已清空筛选');
});
// view toggle
document.querySelectorAll('#tc-view-toggle button').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('#tc-view-toggle button').forEach(x => x.classList.remove('active'));
b.classList.add('active');
state.view = b.dataset.view;
if (state.view === 'list') { listView.hidden = false; gridView.hidden = true; }
else { listView.hidden = true; gridView.hidden = false; }
});
});
/* ---------- 6. 删除 modal + 同步 localStorage ---------- */
const delBg = document.getElementById('tc-del-bg');
const delBody = document.getElementById('tc-del-body');
const delCancel = document.getElementById('tc-del-cancel');
const delOk = document.getElementById('tc-del-ok');
let _delTarget = null;
function openDelConfirm(target) {
_delTarget = target;
const name = target.dataset.name || '该任务';
delBody.innerHTML = '即将删除任务 <span class="mono-acc">' + esc(name) + '</span>。任务记录将清除,已入库的素材不受影响。';
delBg.classList.add('show');
}
function closeDelConfirm() { delBg.classList.remove('show'); _delTarget = null; }
delCancel.addEventListener('click', closeDelConfirm);
delBg.addEventListener('click', e => { if (e.target === delBg) closeDelConfirm(); });
delOk.addEventListener('click', () => {
if (!_delTarget) return;
const card = _delTarget._card || _delTarget; // 可能传 row 或 card
const taskId = card.dataset.taskId;
const taskType = card.dataset.type;
const name = card.dataset.name;
// 从 localStorage 移除对应任务
if (taskType && KEY_BY_TYPE[taskType]) {
const arr = loadType(taskType).filter(t => t.id !== taskId);
saveType(taskType, arr);
}
closeDelConfirm();
Shell.toast('已删除', name);
renderAll();
});
// 列表行 删除按钮(事件委托,因为是动态生成)
listTbody.addEventListener('click', e => {
const btn = e.target.closest('.mi-del-task');
if (!btn) return;
e.stopPropagation();
const tr = btn.closest('tr');
if (tr) openDelConfirm(tr);
});
/* ---------- 7. 初始化 ---------- */
renderAll();
})();
</script>
<script src="assets/api-bridge.js?v=2026060114"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
(function () {
const PATHS = {
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 10v10h14V10"/><path d="M9 20v-6h6v6"/>',
layoutDashboard: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
package: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>',
boxes: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>',
clapperboard: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
film: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
video: '<path d="m16 13 5.2 3.1a.5.5 0 0 0 .8-.4V8.3a.5.5 0 0 0-.8-.4L16 11"/><rect x="2" y="6" width="14" height="12" rx="2"/>',
sparkles: '<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3Z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15Z"/>',
images: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
folder: '<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2h7.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5Z"/><path d="M3 9h18"/>',
library: '<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2h7.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5Z"/><path d="M3 9h18"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
wallet: '<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><path d="M16 14h2"/>',
creditCard: '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/>',
settings: '<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.2a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.2A1.7 1.7 0 0 0 4.7 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1.1-1.5V3a2 2 0 1 1 4 0v.2a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1h.1a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
airshelf: '<path d="M4.5 19 12 4.5 19.5 19"/><path d="M8 14h8"/><path d="M7 17h10"/><path d="M9.5 10.3c1.5-1.2 3.5-1.2 5 0"/>',
flame: '<path d="M8.5 14.5c0-2 1.5-3.4 3.5-5.5.4 2.2 2.8 3.1 2.8 5.6a3.3 3.3 0 0 1-6.3-.1Z"/><path d="M12 22c4 0 7-2.8 7-7 0-4.7-3.3-7.3-5-12-2 3.3-7 6-7 12a5 5 0 0 0 5 7Z"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/>',
list: '<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
chevronLeft: '<path d="m15 18-6-6 6-6"/>',
chevronRight: '<path d="m9 18 6-6-6-6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',
productPlus: '<path d="M12 22V12"/><path d="M16 17h6"/><path d="M19 14v6"/><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="m7.5 4.3 9 5.1"/>',
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
arrowLeft: '<path d="M19 12H5"/><path d="m12 19-7-7 7-7"/>',
arrowUp: '<path d="M12 19V5"/><path d="m5 12 7-7 7 7"/>',
chevronDown: '<path d="m6 9 6 6 6-6"/>',
rotateCcw: '<path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/>',
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
moreHorizontal: '<circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none"/>',
trash: '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="m19 6-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>',
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5Z"/>',
play: '<path d="m6 4 14 8-14 8Z"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
alertCircle: '<circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 8h.01"/>',
shieldCheck: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/>',
messageCircle: '<path d="M21 11.5a8.5 8.5 0 0 1-12.5 7.5L3 20l1-5.5A8.5 8.5 0 1 1 21 11.5Z"/>',
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
lock: '<rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
eye: '<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
grid: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
save: '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/>',
userPlus: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6"/><path d="M22 11h-6"/>',
helpCircle: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4"/><path d="M12 17h.01"/>'
};
const ALIASES = {
dashboard: 'layoutDashboard',
products: 'package',
projects: 'clapperboard',
assetFactory: 'sparkles',
library: 'folder',
account: 'creditCard',
billing: 'creditCard',
team: 'users',
queue: 'list',
arrow: 'arrowRight',
back: 'arrowLeft',
close: 'x',
rerun: 'rotateCcw',
more: 'moreHorizontal',
danger: 'alertCircle'
};
function svg(name, opts) {
opts = opts || {};
const key = ALIASES[name] || name;
const body = PATHS[key] || PATHS.helpCircle;
const size = opts.size || 16;
const strokeWidth = opts.strokeWidth || 1.5;
const className = ['ui-icon', opts.className || ''].join(' ').trim();
const label = opts.label ? ` role="img" aria-label="${escapeAttr(opts.label)}"` : ' aria-hidden="true"';
return `<svg class="${className}" data-icon="${key}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" focusable="false"${label}>${body}</svg>`;
}
function escapeAttr(value) {
return String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
window.IconKit = {
svg,
names: () => Object.keys(PATHS),
has: name => !!PATHS[ALIASES[name] || name]
};
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

@@ -0,0 +1,152 @@
(function () {
const base = 'assets/mock/';
const media = {
products: {
mask: base + 'product-mask.png',
earbuds: base + 'product-earbuds.png',
noodle: base + 'product-noodle.png',
sunscreen: base + 'product-sunscreen.png',
coffee: base + 'product-coffee.png',
airFryer: base + 'product-air-fryer.png',
yoga: base + 'product-yoga-pants.png',
storage: base + 'product-storage.png'
},
covers: {
mask: base + 'cover-mask-v3.png',
maskFinal: base + 'cover-mask-final.png',
noodle: base + 'cover-noodle.png',
sunscreen: base + 'cover-sunscreen.png',
coffee: base + 'cover-coffee.png',
earbuds: base + 'cover-earbuds.png',
yoga: base + 'cover-yoga.png',
airFryer: base + 'cover-air-fryer.png'
},
people: {
linxi: base + 'person-linxi.png',
ajie: base + 'person-ajie.png',
aqiang: base + 'person-aqiang.png',
xiaosu: base + 'person-xiaosu.png'
},
scenes: {
bedroom: base + 'scene-bedroom.png',
bathroom: base + 'scene-bathroom.png',
living: base + 'scene-living.png',
kitchen: base + 'scene-kitchen.png',
office: base + 'scene-office.png',
cafe: base + 'scene-cafe.png',
street: base + 'scene-night-street.png',
tabletop: base + 'scene-tabletop.png'
}
};
function clean(text) {
return String(text || '').replace(/\s+/g, '').toLowerCase();
}
function contextText(el) {
const card = el.closest('[data-name], [data-product], [data-project], [data-cat], [data-asset-kind], [data-scene-type]');
const bits = [
el.textContent,
card?.dataset.name,
card?.dataset.product,
card?.dataset.project,
card?.dataset.cat,
card?.dataset.assetKind,
card?.dataset.sceneType,
card?.querySelector('.product-name, .card-name, .asset-name, .prod-name, strong')?.textContent
];
return clean(bits.filter(Boolean).join(' '));
}
function productFor(t) {
if (/蓝牙|耳机|earbud|南卡/.test(t)) return media.products.earbuds;
if (/速食|牛肉面|泡面|面条|noodle/.test(t)) return media.products.noodle;
if (/防晒|sunscreen/.test(t)) return media.products.sunscreen;
if (/咖啡|冻干|coffee/.test(t)) return media.products.coffee;
if (/空气炸锅|airfryer|小熊/.test(t)) return media.products.airFryer;
if (/瑜伽裤|露露|yoga/.test(t)) return media.products.yoga;
if (/收纳|storage|北欧/.test(t)) return media.products.storage;
if (/面膜|补水|玻尿酸|mask|透真/.test(t)) return media.products.mask;
return '';
}
function coverFor(t) {
if (/蓝牙|耳机|earbud|南卡/.test(t)) return media.covers.earbuds;
if (/速食|牛肉面|泡面|面条|noodle/.test(t)) return media.covers.noodle;
if (/防晒|sunscreen/.test(t)) return media.covers.sunscreen;
if (/咖啡|冻干|coffee/.test(t)) return media.covers.coffee;
if (/空气炸锅|airfryer|小熊/.test(t)) return media.covers.airFryer;
if (/瑜伽裤|露露|yoga/.test(t)) return media.covers.yoga;
if (/v1|final|已完成|5\/5|成片|敷面膜|化妆台/.test(t)) return media.covers.maskFinal;
if (/面膜|补水|玻尿酸|mask|透真|场1|场2|场3/.test(t)) return media.covers.mask;
return '';
}
function personFor(t) {
if (/阿杰|通勤男|男青年/.test(t)) return media.people.ajie;
if (/阿强|健身男|健身/.test(t)) return media.people.aqiang;
if (/小苏|文艺女|短发|阿楠|同事|小七|学生女|闺蜜|妈妈|王姐|豆豆/.test(t)) return media.people.xiaosu;
if (/林夕|主播|都市白领|主角|女性|女生/.test(t)) return media.people.linxi;
if (/小宇|李爷爷|男性|男/.test(t)) return media.people.ajie;
return '';
}
function sceneFor(t) {
if (/卧室|床头|bedroom/.test(t)) return media.scenes.bedroom;
if (/浴室|梳妆台|bathroom/.test(t)) return media.scenes.bathroom;
if (/客厅|living/.test(t)) return media.scenes.living;
if (/厨房|中岛|kitchen/.test(t)) return media.scenes.kitchen;
if (/办公室|办公桌|会议室|office|深夜办公/.test(t)) return media.scenes.office;
if (/咖啡店|窗边|cafe/.test(t)) return media.scenes.cafe;
if (/街景|夜|street/.test(t)) return media.scenes.street;
if (/平台|套图|布景|tabletop/.test(t)) return media.scenes.tabletop;
return '';
}
function imageFor(el) {
if (el.classList.contains('missing') || el.querySelector('.spinner, .fail-icon')) return '';
const t = contextText(el);
const card = el.closest('.asset-card, .asset-card-2, .proj-card, .product-card, .video-card');
if (el.id === 'ed-canvas') return media.covers.maskFinal;
if (el.id === 'sb-main-img' || el.id === 'vd-main-img') return media.covers.mask;
if (el.matches('.video-thumb')) {
if (card?.dataset.videoId === 'v2') return media.products.mask;
if (card?.dataset.videoId === 'v3') return media.covers.maskFinal;
}
if (el.matches('.card-thumb, .proj-thumb, .video-thumb') || card?.classList.contains('video')) return coverFor(t);
if (el.matches('.product-thumb, .prod-thumb, .pl-thumb')) return productFor(t);
if (el.matches('.m-thumb')) return personFor(t);
if (el.matches('.thumb-2, .asset-thumb')) {
return personFor(t) || sceneFor(t) || productFor(t) || coverFor(t);
}
if (el.matches('.sb-scene-thumb, .sb-history-thumb, .vd-history-thumb')) return coverFor(t) || media.covers.mask;
return productFor(t) || personFor(t) || sceneFor(t) || coverFor(t);
}
function applyOne(el) {
if (!el || el.dataset.mockMediaApplied === '1') return;
const src = imageFor(el);
if (!src) return;
el.dataset.mockMediaApplied = '1';
el.classList.add('has-mock-media');
el.style.setProperty('--mock-media-url', `url("${src}")`);
el.style.backgroundImage = `url("${src}")`;
}
function apply() {
document.querySelectorAll('.placeholder, #ed-canvas').forEach(applyOne);
}
function boot() {
apply();
const mo = new MutationObserver(() => apply());
mo.observe(document.body, { childList: true, subtree: true });
window.MockMedia = { apply, media };
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 KiB

@@ -0,0 +1,784 @@
/* ============================================================
新建商品 · 共享 Drawer 模块
----------------------------------------------------------
在任意页面只需 <script src="assets/new-product-drawer.js"> 引入,
然后调用 NewProductDrawer.open({ onSave: fn }) 即可在当前页之上
弹出右侧 Drawer点击遮罩 / X / 取消 / ESC 关闭后,用户停在原页面
提供:
window.NewProductDrawer.open(opts?)
window.NewProductDrawer.close()
opts 字段:
onSave(product) 保存时回调,product = { id, name, cat, target,
points: string[], images: { id, dataUrl, name }[] }
============================================================ */
(function () {
'use strict';
if (window.NewProductDrawer) return; // idempotent
const DRAWER_ID = 'npd-drawer';
const DRAWER_BG_ID = 'npd-drawer-bg';
/* ---------- 注入样式(独立 namespace 以免与 products.html 冲突) ---------- */
const CSS = `
/* drawer base (相同尺寸/动画) */
#${DRAWER_BG_ID} {
position: fixed; inset: 0;
background: rgba(21, 20, 15, .32);
display: none; z-index: 1100;
}
#${DRAWER_BG_ID}.show { display: block; }
#${DRAWER_ID} {
position: fixed; right: 0; top: 0; bottom: 0;
width: 820px; max-width: 100vw;
background: var(--surface);
border-left: 1px solid var(--border-faint);
z-index: 1101;
transform: translateX(100%);
transition: transform .25s cubic-bezier(.32, .72, 0, 1);
display: flex; flex-direction: column;
box-shadow: -4px 0 24px rgba(21, 20, 15, .04);
}
#${DRAWER_ID}.show { transform: translateX(0); }
#${DRAWER_ID} .drawer-h {
padding: 20px 24px;
border-bottom: 1px solid var(--border-faint);
display: flex; align-items: center;
}
#${DRAWER_ID} .drawer-h h3 { font-size: 16px; font-weight: 600; color: var(--accent-black); }
#${DRAWER_ID} .drawer-h .x {
margin-left: auto; width: 32px; height: 32px;
border-radius: var(--r-md);
display: grid; place-items: center;
color: var(--black-alpha-56); cursor: pointer;
background: transparent; border: 0;
transition: background var(--t-base);
}
#${DRAWER_ID} .drawer-h .x:hover { background: var(--black-alpha-4); color: var(--accent-black); }
#${DRAWER_ID} .drawer-b { padding: 24px 28px; overflow-y: auto; flex: 1; overscroll-behavior: contain; }
#${DRAWER_ID} .drawer-f {
padding: 14px 24px;
border-top: 1px solid var(--border-faint);
display: flex; gap: 10px; align-items: center;
background: var(--surface);
}
#${DRAWER_ID} .drawer-f .btn-guide {
margin-right: auto;
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--black-alpha-56);
background: transparent; border: 0; cursor: pointer;
padding: 8px 10px; border-radius: var(--r-md);
font-family: inherit;
transition: background var(--t-base), color var(--t-base);
}
#${DRAWER_ID} .drawer-f .btn-guide:hover { color: var(--accent-black); background: var(--black-alpha-4); }
#${DRAWER_ID} .drawer-f .btn-guide svg { width: 14px; height: 14px; }
/* form-card */
#${DRAWER_ID} .form-h {
font-size: 15px; font-weight: 600; color: var(--accent-black);
margin-bottom: 18px; padding-bottom: 12px;
border-bottom: 1px solid var(--border-faint);
}
#${DRAWER_ID} .field { margin-bottom: 16px; }
#${DRAWER_ID} .field:last-child { margin-bottom: 0; }
#${DRAWER_ID} .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 16px; }
#${DRAWER_ID} .field-label {
display: block; font-size: 13px; font-weight: 500;
color: var(--accent-black); margin-bottom: 6px;
}
#${DRAWER_ID} .field-label .req { color: var(--heat); margin-left: 2px; }
#${DRAWER_ID} .field-label .opt {
color: var(--black-alpha-48); font-weight: 400; font-size: 12px; margin-left: 6px;
}
#${DRAWER_ID} .input,
#${DRAWER_ID} .select {
width: 100%; height: 38px;
background: var(--background-lighter);
border: 1px solid var(--black-alpha-12);
border-radius: var(--r-md);
padding: 0 14px;
font-size: 13.5px; color: var(--accent-black);
outline: none; font-family: inherit;
transition: border-color var(--t-base);
}
#${DRAWER_ID} .input:focus,
#${DRAWER_ID} .select:focus {
border-color: var(--heat-40);
box-shadow: inset 0 0 0 1px var(--heat-40);
}
/* upload */
#${DRAWER_ID} .pf-upload-row {
display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: 16px; align-items: stretch;
}
#${DRAWER_ID} .pf-upload-zone {
border: 1.5px dashed var(--black-alpha-24);
border-radius: var(--r-md);
padding: 28px 20px;
background: var(--background-lighter);
cursor: pointer; text-align: center;
transition: border-color var(--t-base), background var(--t-base);
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 180px;
}
#${DRAWER_ID} .pf-upload-zone:hover { border-color: var(--heat); background: var(--heat-8); }
#${DRAWER_ID} .pf-upload-zone .uz-ic {
width: 44px; height: 44px;
margin: 0 auto 10px;
background: var(--surface);
border: 1px solid var(--heat-20);
border-radius: var(--r-md);
color: var(--heat);
display: grid; place-items: center;
}
#${DRAWER_ID} .pf-upload-zone .uz-ic svg { width: 20px; height: 20px; }
#${DRAWER_ID} .pf-upload-zone .uz-t { font-size: 14px; color: var(--accent-black); font-weight: 500; }
#${DRAWER_ID} .pf-upload-zone .uz-t strong { color: var(--heat); font-weight: 600; }
#${DRAWER_ID} .pf-upload-zone .uz-d {
margin-top: 8px;
font-family: var(--font-mono); font-size: 11.5px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
#${DRAWER_ID} .pf-example {
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 16px;
display: flex; flex-direction: column; gap: 10px;
}
#${DRAWER_ID} .pf-example .ex-h { font-size: 13px; font-weight: 600; color: var(--accent-black); }
#${DRAWER_ID} .pf-example .ex-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
#${DRAWER_ID} .pf-example .ex-grid .ex-thumb {
aspect-ratio: 1;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
overflow: hidden; position: relative;
display: grid; place-items: center;
color: var(--black-alpha-32);
}
#${DRAWER_ID} .pf-example .ex-grid .ex-thumb svg { width: 22px; height: 22px; }
#${DRAWER_ID} .pf-example .ex-grid .ex-thumb::after {
content: ''; position: absolute; inset: 0;
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
pointer-events: none;
}
#${DRAWER_ID} .pf-example .ex-d { font-size: 12px; color: var(--black-alpha-56); line-height: 1.5; }
#${DRAWER_ID} .pf-grid {
display: grid; grid-template-columns: repeat(5, 1fr);
gap: 8px; margin-top: 12px;
}
#${DRAWER_ID} .pf-grid:empty { display: none; }
#${DRAWER_ID} .pf-thumb {
aspect-ratio: 1;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
position: relative; overflow: hidden; cursor: pointer;
}
#${DRAWER_ID} .pf-thumb img { width: 100%; height: 100%; object-fit: cover; }
#${DRAWER_ID} .pf-thumb .pf-x {
position: absolute; top: 4px; right: 4px;
width: 22px; height: 22px;
background: rgba(0,0,0,.7); color: var(--accent-white);
border: 0; border-radius: 50%; cursor: pointer;
display: grid; place-items: center;
opacity: 0; transition: opacity var(--t-base);
}
#${DRAWER_ID} .pf-thumb:hover .pf-x { opacity: 1; }
#${DRAWER_ID} .pf-thumb .pf-x svg { width: 11px; height: 11px; }
/* bullet-list */
#${DRAWER_ID} .bullet-list { list-style: none; padding: 0; margin: 0; }
#${DRAWER_ID} .bullet-list .bl-item,
#${DRAWER_ID} .bullet-list .bl-add {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
margin-bottom: 6px;
font-size: 13.5px;
}
#${DRAWER_ID} .bullet-list .bl-add { background: transparent; border-style: dashed; }
#${DRAWER_ID} .bullet-list .bl-add-row {
margin: 8px 0 0;
display: none;
padding: 0;
background: transparent;
border: 0;
}
#${DRAWER_ID} .bullet-list .bl-add-row.show { display: flex; justify-content: flex-start; }
#${DRAWER_ID} .bl-add-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px;
background: var(--heat); color: #fff;
border: 0; border-radius: var(--r-md);
font-size: 13px; font-weight: 600;
cursor: pointer; font-family: inherit;
transition: filter var(--t-base);
}
#${DRAWER_ID} .bl-add-btn:hover { filter: brightness(.94); }
#${DRAWER_ID} .bl-add-btn svg { width: 12px; height: 12px; }
#${DRAWER_ID} .bullet-list .num {
width: 22px; height: 22px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: 11px; color: var(--heat); font-weight: 700;
display: grid; place-items: center; flex-shrink: 0;
}
#${DRAWER_ID} .bullet-list .bl-text { flex: 1; color: var(--accent-black); }
#${DRAWER_ID} .bullet-list .bl-input {
flex: 1; background: transparent; border: 0; outline: none;
font-size: 13.5px; color: var(--accent-black); font-family: inherit;
}
#${DRAWER_ID} .bullet-list .bl-x {
width: 22px; height: 22px;
color: var(--black-alpha-48);
cursor: pointer; display: grid; place-items: center;
border-radius: var(--r-sm);
transition: color var(--t-base), background var(--t-base);
}
#${DRAWER_ID} .bullet-list .bl-x:hover { color: var(--accent-crimson); background: var(--crimson-bg); }
#${DRAWER_ID} .bullet-list .bl-x svg { width: 11px; height: 11px; }
@media (max-width: 900px) {
#${DRAWER_ID} .pf-upload-row { grid-template-columns: 1fr; }
}
/* 放弃填写 · 确认弹窗 · 覆盖在 drawer 上 */
#npd-confirm-bg {
position: fixed; inset: 0; z-index: 1200;
background: rgba(21, 20, 15, .42);
display: grid; place-items: center;
padding: 16px;
}
#npd-confirm-bg[hidden] { display: none; }
.npd-confirm-modal {
width: min(420px, 92vw);
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: 0 16px 48px rgba(21, 20, 15, .18);
overflow: hidden;
}
.npd-confirm-modal .dh {
display: flex; align-items: center; gap: 12px;
padding: 18px 20px 14px;
}
.npd-confirm-modal .dh .ic {
width: 36px; height: 36px; flex-shrink: 0;
border-radius: var(--r-md);
background: rgba(180, 30, 30, .08); color: var(--accent-crimson);
display: grid; place-items: center;
}
.npd-confirm-modal .dh .ic svg { width: 18px; height: 18px; }
.npd-confirm-modal .dh .ti { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.npd-confirm-modal .dh .ti strong { font-size: 14.5px; color: var(--accent-black); font-weight: 600; }
.npd-confirm-modal .dh .ti .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; line-height: 1.5; }
.npd-confirm-modal .df {
display: flex; gap: 8px;
padding: 0 20px 18px;
justify-content: flex-end;
}
.npd-confirm-modal .df button {
height: 32px; padding: 0 14px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
font-size: 12.5px; color: var(--accent-black);
font-family: inherit; cursor: pointer;
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
}
.npd-confirm-modal .df button:hover { border-color: var(--heat-20); background: var(--heat-12); color: var(--heat); }
.npd-confirm-modal .df button.danger { color: var(--accent-crimson); }
.npd-confirm-modal .df button.danger:hover {
background: rgba(180, 30, 30, .08);
border-color: rgba(180, 30, 30, .35);
color: var(--accent-crimson);
}
`;
const HTML = `
<div id="${DRAWER_BG_ID}"></div>
<aside id="${DRAWER_ID}" role="dialog" aria-label="新建商品" aria-hidden="true">
<div class="drawer-h">
<h3>新建商品</h3>
<button class="x" type="button" data-act="close" aria-label="关闭">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M6 18L18 6"/></svg>
</button>
</div>
<div class="drawer-b">
<div class="form-card">
<div class="form-h">基础信息</div>
<div class="field">
<label class="field-label">商品名称<span class="req">*</span></label>
<input class="input" data-f="name" placeholder="请输入商品名称(必填)" maxlength="100">
</div>
<div class="field-row">
<div>
<label class="field-label">品类<span class="req">*</span></label>
<select class="select" data-f="cat">
<option>美妆个护</option>
<option>服饰内衣</option>
<option>食品饮料</option>
<option>家居家电</option>
<option>数码 3C</option>
<option>个护清洁</option>
<option>运动户外</option>
<option>母婴亲子</option>
</select>
</div>
<div>
<label class="field-label">目标人群<span class="opt">(选填)</span></label>
<input class="input" data-f="target" placeholder="例: 22-32 岁女性、敏感肌、办公室通勤">
</div>
</div>
<div class="field">
<label class="field-label">商品主图<span class="req">*</span></label>
<input type="file" data-f="file" accept="image/*" multiple hidden>
<div class="pf-upload-row">
<div class="pf-upload-zone" data-act="upload-zone">
<div class="uz-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
</div>
<div class="uz-t">点击上传或<strong>拖拽图片</strong></div>
<div class="uz-d">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>
</div>
<div class="pf-example">
<div class="ex-h">示例图</div>
<div class="ex-grid">
<div class="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4h10l1 4v12H6V8l1-4z"/><path d="M9 4v3M15 4v3M9 11h6M9 14h6"/></svg></div>
<div class="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="5" width="12" height="15" rx="2"/><path d="M9 9h6M9 12h6M9 15h4"/></svg></div>
<div class="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3h8l1 5v12H7V8l1-5z"/><circle cx="12" cy="13" r="2.5"/></svg></div>
</div>
<div class="ex-d">优质的商品图有助于生成更好的素材效果</div>
</div>
</div>
<div class="pf-grid" data-f="grid"></div>
</div>
<div class="field" style="margin-bottom: 0;">
<label class="field-label">核心卖点<span class="req">*</span></label>
<ul class="bullet-list" data-f="bullets">
<li class="bl-add"><span class="num">+</span><input class="bl-input" placeholder=" · "></li>
<li class="bl-add-row" data-f="add-row">
<button type="button" class="bl-add-btn" data-act="bl-add">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
添加卖点
</button>
</li>
</ul>
</div>
</div>
</div>
<div class="drawer-f">
<button class="btn-guide" type="button" data-act="guide">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 4M12 17h.01"/></svg>
使用指南
</button>
<button class="btn" type="button" data-act="cancel">取消</button>
<button class="btn btn-primary" type="button" data-act="save">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>
创建商品
</button>
</div>
</aside>
<!-- 放弃填写 · 确认弹窗(覆盖在 drawer 之上) -->
<div id="npd-confirm-bg" hidden>
<div class="npd-confirm-modal" role="dialog" aria-label="放弃填写">
<div class="dh">
<div class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>
</div>
<div class="ti">
<strong>放弃当前填写?</strong>
<span class="mono">// 已填写的商品名、卖点、图片等内容将不会保留</span>
</div>
</div>
<div class="df">
<button type="button" data-act="keep">继续填写</button>
<button type="button" class="danger" data-act="discard">放弃退出</button>
</div>
</div>
</div>
`;
/* ---------- DOM refs (populated by ensureInjected) ---------- */
let injected = false;
let bg, drawer, $f, $grid, $bullets, $blInput;
let confirmBg;
let _forceClose = false; // 保存成功后绕过脏检查
let _initialCat = ''; // 打开时类目下拉的初始值,用来判断是否被改过
let currentOpts = {};
const PF_MAX = 5;
let pfFiles = []; // { id, dataUrl, name }
const blXSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
function esc(s) { return String(s == null ? '' : s).replace(/[<>&"]/g, c => ({ '<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;' })[c]); }
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 5); }
function toast(msg, sub) {
if (typeof Shell !== 'undefined' && Shell && Shell.toast) Shell.toast(msg, sub);
}
function ensureInjected() {
if (injected) return;
// style
const styleEl = document.createElement('style');
styleEl.textContent = CSS;
document.head.appendChild(styleEl);
// html
const wrap = document.createElement('div');
wrap.innerHTML = HTML;
while (wrap.firstChild) document.body.appendChild(wrap.firstChild);
bg = document.getElementById(DRAWER_BG_ID);
drawer = document.getElementById(DRAWER_ID);
confirmBg = document.getElementById('npd-confirm-bg');
$f = {
name: drawer.querySelector('[data-f="name"]'),
cat: drawer.querySelector('[data-f="cat"]'),
target: drawer.querySelector('[data-f="target"]'),
file: drawer.querySelector('[data-f="file"]'),
};
$grid = drawer.querySelector('[data-f="grid"]');
$bullets = drawer.querySelector('[data-f="bullets"]');
$blInput = $bullets.querySelector('.bl-add .bl-input');
bindEvents();
injected = true;
}
function bindEvents() {
// 关闭交互
bg.addEventListener('click', close);
drawer.addEventListener('click', e => {
const a = e.target.closest('[data-act]');
if (!a) return;
const act = a.dataset.act;
if (act === 'close') return close();
if (act === 'cancel') return close();
if (act === 'guide') return toast('使用指南', '点击查看完整填写指南');
if (act === 'save') return save();
if (act === 'upload-zone') return openFilePicker();
if (act === 'bl-add') {
blAdd($blInput.value);
$blInput.value = '';
$blInput.focus();
updateBlAddBtn();
return;
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && drawer.classList.contains('show')) close();
});
// 上传
$f.file.addEventListener('change', e => { addFiles(e.target.files); e.target.value = ''; });
const zone = drawer.querySelector('[data-act="upload-zone"]');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.style.borderColor = 'var(--heat)'; });
zone.addEventListener('dragleave', () => { zone.style.borderColor = ''; });
zone.addEventListener('drop', e => {
e.preventDefault(); zone.style.borderColor = '';
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
});
// 卖点 bullet-list
$blInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
blAdd($blInput.value);
$blInput.value = '';
updateBlAddBtn();
}
});
$blInput.addEventListener('input', updateBlAddBtn);
}
function updateBlAddBtn() {
const row = $bullets && $bullets.querySelector('[data-f="add-row"]');
if (!row) return;
row.classList.toggle('show', !!($blInput.value || '').trim());
}
function openFilePicker() { if (pfFiles.length < PF_MAX) $f.file.click(); }
function pfRender() {
$grid.innerHTML = pfFiles.map(u => `
<div class="pf-thumb" data-id="${u.id}">
<img src="${u.dataUrl}" alt="${esc(u.name)}">
<button class="pf-x" type="button" title="删除">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
</button>
</div>
`).join('');
$grid.querySelectorAll('.pf-thumb .pf-x').forEach(b => {
b.onclick = e => {
e.stopPropagation();
const id = b.closest('.pf-thumb').dataset.id;
const i = pfFiles.findIndex(f => f.id === id);
if (i >= 0) { pfFiles.splice(i, 1); pfRender(); }
};
});
}
function addFiles(fileList) {
const room = PF_MAX - pfFiles.length;
if (room <= 0) { toast('已达上限', PF_MAX + ' / ' + PF_MAX + ' 张'); return; }
const incoming = [...fileList].filter(f => f.type.startsWith('image/')).slice(0, room);
let done = 0;
incoming.forEach(f => {
const r = new FileReader();
r.onload = e => {
pfFiles.push({ id: uid(), dataUrl: e.target.result, name: f.name });
if (++done === incoming.length) {
pfRender();
toast('已上传', '+ ' + done + ' 张 · 共 ' + pfFiles.length + ' / ' + PF_MAX);
}
};
r.readAsDataURL(f);
});
}
function blRenumber() {
[...$bullets.querySelectorAll('.bl-item')].forEach((li, i) => {
li.querySelector('.num').textContent = i + 1;
});
}
function blAdd(text) {
const t = (text || '').trim();
if (!t) return;
const li = document.createElement('li');
li.className = 'bl-item';
li.innerHTML = '<span class="num">0</span><span class="bl-text">' + esc(t) + '</span><span class="bl-x" title="删除">' + blXSvg + '</span>';
$bullets.querySelector('.bl-add').before(li);
li.querySelector('.bl-x').addEventListener('click', () => {
li.style.transition = 'opacity .15s, transform .15s';
li.style.opacity = 0;
li.style.transform = 'translateX(-8px)';
setTimeout(() => { li.remove(); blRenumber(); }, 150);
});
blRenumber();
}
function getBullets() {
return [...$bullets.querySelectorAll('.bl-item .bl-text')].map(t => t.textContent.trim()).filter(Boolean);
}
/* ---------- API ---------- */
function resetForm() {
$f.name.value = '';
$f.cat.value = $f.cat.options[0].value;
$f.target.value = '';
pfFiles = [];
pfRender();
[...$bullets.querySelectorAll('.bl-item')].forEach(li => li.remove());
$blInput.value = '';
updateBlAddBtn();
}
function lockBody() {
// 优先用 Shell 的引用计数实现(避免多 overlay 互相解锁)
if (typeof Shell !== 'undefined' && Shell && typeof Shell.lockScroll === 'function') {
Shell.lockScroll();
return;
}
// 兜底: Shell 未加载时本地锁
const docEl = document.documentElement;
const sbw = window.innerWidth - docEl.clientWidth;
drawer._lockSnap = {
bodyOverflow: document.body.style.overflow,
bodyPaddingRight: document.body.style.paddingRight,
htmlOverflow: docEl.style.overflow,
};
document.body.style.overflow = 'hidden';
docEl.style.overflow = 'hidden';
if (sbw > 0) document.body.style.paddingRight = sbw + 'px';
}
function unlockBody() {
if (typeof Shell !== 'undefined' && Shell && typeof Shell.unlockScroll === 'function') {
Shell.unlockScroll();
return;
}
const s = drawer._lockSnap;
if (s) {
document.body.style.overflow = s.bodyOverflow;
document.body.style.paddingRight = s.bodyPaddingRight;
document.documentElement.style.overflow = s.htmlOverflow;
drawer._lockSnap = null;
}
}
function open(opts) {
ensureInjected();
if (drawer.classList.contains('show')) return; // 已开则不重复锁
currentOpts = opts || {};
resetForm();
_initialCat = $f.cat.value; // 快照「打开时类目」用于脏检查
bg.classList.add('show');
drawer.classList.add('show');
drawer.setAttribute('aria-hidden', 'false');
lockBody();
setTimeout(() => $f.name.focus(), 280);
}
/* ---------- 脏检查 + 退出确认 ---------- */
function isDirty() {
if (!injected) return false;
if (($f.name.value || '').trim()) return true;
if (($f.target.value || '').trim()) return true;
if (($blInput.value || '').trim()) return true;
if (pfFiles.length > 0) return true;
if ($bullets.querySelectorAll('.bl-item').length > 0) return true;
if ($f.cat.value !== _initialCat) return true;
return false;
}
function confirmDiscard() {
return new Promise(resolve => {
confirmBg.hidden = false;
const buttons = confirmBg.querySelectorAll('button[data-act]');
function done(choice) {
confirmBg.hidden = true;
buttons.forEach(b => b.onclick = null);
confirmBg.onclick = null;
document.removeEventListener('keydown', escHandler, true);
resolve(choice === 'discard');
}
function escHandler(e) {
if (e.key === 'Escape') {
e.stopPropagation();
done('keep');
}
}
buttons.forEach(b => b.onclick = () => done(b.dataset.act));
confirmBg.onclick = e => { if (e.target === confirmBg) done('keep'); };
// 用 capture · 截在外层 Esc 监听器之前
document.addEventListener('keydown', escHandler, true);
});
}
async function close() {
if (!injected) return;
if (!drawer.classList.contains('show')) return; // 已关则不重复解锁
if (!_forceClose && isDirty()) {
const ok = await confirmDiscard();
if (!ok) return;
}
bg.classList.remove('show');
drawer.classList.remove('show');
drawer.setAttribute('aria-hidden', 'true');
unlockBody();
if (typeof currentOpts.onClose === 'function') currentOpts.onClose();
}
function closeForce() {
_forceClose = true;
try { close(); } finally { _forceClose = false; }
}
function save() {
// 兜底:用户在卖点输入框敲了字但没回车/没点「添加卖点」就直接点创建,
// 这里自动提交一次,避免静默丢失最后一条卖点(常见误操作)
if ($blInput && ($blInput.value || '').trim()) {
blAdd($blInput.value);
$blInput.value = '';
updateBlAddBtn();
}
const name = ($f.name.value || '').trim();
const cat = $f.cat.value;
const target = ($f.target.value || '').trim();
const points = getBullets();
const images = pfFiles.slice();
if (!name) {
toast('请填写商品名称');
$f.name.focus();
return;
}
if (images.length === 0) {
toast('请上传商品主图', '至少 1 张');
return;
}
if (points.length === 0) {
toast('请添加核心卖点', '至少 1 条');
$blInput.focus();
return;
}
const product = {
id: 'np-' + uid(),
name, cat, target,
points,
images,
imgs: images.length,
};
// 持久化到 sessionStorage('fs-extra-products') · 仅当前标签页生命周期内有效
// 关闭标签页/浏览器后自动清空,不会跨会话累积演示残留
try {
const KEY = 'fs-extra-products';
const list = JSON.parse(sessionStorage.getItem(KEY) || '[]');
list.push({
id: product.id,
name, cat, target,
tags: '',
assets: 0,
videos: 0,
bullets: points,
date: new Date().toISOString().slice(0, 10),
createdAt: Date.now(),
});
sessionStorage.setItem(KEY, JSON.stringify(list));
} catch (e) { /* storage 不可用降级到只跳转 */ }
// 短期 sessionStorage 缓存完整 product(含图片 dataUrl),供 detail 页 ?id=new
// 读出后渲染。读完即清除,避免污染后续操作。
try {
sessionStorage.setItem('npd-last-created', JSON.stringify(product));
} catch (e) { /* dataUrl 可能过大 → 退化为不带图,detail 仍能渲染文本字段 */ }
if (typeof currentOpts.onSave === 'function') {
toast('商品已创建', '+ ' + name);
currentOpts.onSave(product);
closeForce();
return;
}
// 默认行为: 不 close drawer · 跳转期间 drawer 仍覆盖 host 页面 → 视觉上彻底
// 消除"闪 host"(浏览器导航开始后,整页被新页面替换,drawer 自然消失)
toast('商品已创建', '+ ' + name);
const url = 'product-detail.html?product=' + encodeURIComponent(name) + '&id=new';
location.href = url;
}
window.NewProductDrawer = { open, close };
/* ---------- sessionStorage 自动打开钩子 ---------- */
// 任何页面只要在跳转前 sessionStorage.setItem('npd-auto-open','1') 即可,
// 落地页加载完模块后,会自动 open() 一次并清掉 flag。
// 用于:product-create.html 重定向后让落地页弹出 drawer,而不是用户重新点击。
function checkAutoOpen() {
try {
if (sessionStorage.getItem('npd-auto-open') === '1') {
sessionStorage.removeItem('npd-auto-open');
// 延后一拍,确保宿主页面自己的 init 已经跑完
setTimeout(() => open(), 50);
}
} catch (e) { /* sessionStorage 不可用就静默放弃 */ }
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', checkAutoOpen);
} else {
checkAutoOpen();
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>工作台 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
<style>
.dash-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 24px; align-items: start; }
.recent-row { display: grid; grid-template-columns: 54px 1fr 110px 130px 60px; align-items: center; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border-faint); cursor: pointer; }
.recent-row .prog, .recent-row .pill, .recent-row .btn { justify-self: start; }
.recent-row:last-child { border-bottom: 0; }
.recent-row:hover { background: var(--background-lighter); }
.recent-row .thumb { width: 54px; height: 70px; border-radius: var(--r-md); }
.recent-meta .name { font-weight: 600; font-size: 13.5px; color: var(--accent-black); }
.recent-meta .sub { font-size: 12px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .01em; }
.shortcuts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.shortcut { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 16px; display: flex; align-items: flex-start; gap: 12px; cursor: pointer; transition: background var(--t-base); }
.shortcut:hover { background: var(--black-alpha-4); }
.shortcut .ic { width: 32px; height: 32px; background: var(--heat-12); color: var(--heat); display: grid; place-items: center; border-radius: var(--r-md); flex-shrink: 0; }
.shortcut .ic svg { width: 16px; height: 16px; }
.shortcut .t { font-size: 13px; font-weight: 600; }
.shortcut .d { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .01em; }
.tip { background: var(--surface); border: 1px dashed var(--border-faint); padding: 14px 16px; font-size: 12.5px; color: var(--black-alpha-56); line-height: 1.6; border-radius: var(--r-md); }
.tip strong { color: var(--accent-black); font-weight: 600; display: block; margin-bottom: 4px; }
.tip .mono { font-family: var(--font-mono); color: var(--heat); background: var(--heat-12); padding: 1px 5px; border-radius: var(--r-sm); font-size: 11.5px; }
</style>
</head>
<body>
<div id="page">
<div class="page-head">
<div>
<h1>欢迎回来,小李</h1>
<div class="sub">
<span class="mono">// 05.14 · 周三</span>
<span>·</span>
<span>你有 <b style="color:var(--accent-black)">3 个项目</b> 正在进行中</span>
</div>
</div>
<div class="actions">
<a class="btn btn-create" href="javascript:void(0)" onclick="event.preventDefault(); window.NewProductDrawer && NewProductDrawer.open();">
<span data-iconkit="productPlus" data-icon-size="16"></span>
新建商品
</a>
<a class="btn btn-primary btn-lg btn-create" href="projects-new.html">
<span data-iconkit="clapperboard" data-icon-size="16"></span>
新建项目
</a>
</div>
</div>
<div class="stats with-corners">
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
<a class="stat" href="projects.html">
<div class="lbl">总项目 <span class="badge">ALL</span></div>
<div class="v">8</div>
<div class="delta up"><span data-iconkit="arrowUp" data-icon-size="14"></span> 本月 +3</div>
</a>
<a class="stat" href="projects.html?filter=wip">
<div class="lbl">进行中 <span class="badge">WIP</span></div>
<div class="v">3</div>
<div class="delta">2 个待审核</div>
</a>
<a class="stat" href="projects.html?filter=done">
<div class="lbl">本月成片 <span class="badge">DONE</span></div>
<div class="v">3</div>
<div class="delta up"><span data-iconkit="arrowUp" data-icon-size="14"></span> 较上月 +33%</div>
</a>
<a class="stat" href="account.html">
<div class="lbl">余额 <span class="badge">¥</span></div>
<div class="v">¥327<small>.40</small></div>
<div class="bar"><span style="width:33%"></span></div>
<div class="sub">已用 ¥162.60 / ¥500</div>
</a>
</div>
<div class="dash-grid">
<div>
<div class="section-h">
<h2>最近项目</h2>
<a class="more" href="projects.html">[ ALL · 8 ]</a>
</div>
<div class="card-hard">
<a class="recent-row" href="pipeline.html?product=%E9%80%8F%E7%9C%9F%E8%A1%A5%E6%B0%B4%E9%9D%A2%E8%86%9C#stage-3">
<div class="placeholder thumb"><span class="ph-frame">9:16</span></div>
<div class="recent-meta">
<div class="name">补水面膜 · 痛点种草 · v3</div>
<div class="sub">透真补水面膜 / AI 全生 / 7 镜</div>
</div>
<div class="prog"><span class="done"></span><span class="done"></span><span class="cur"></span><span></span><span></span></div>
<span class="pill info"><span class="dot"></span>故事板 待确认</span>
<span class="btn btn-sm">继续</span>
</a>
<a class="recent-row" href="pipeline.html?product=%E9%80%8F%E7%9C%9F%E9%98%B2%E6%99%92#stage-5">
<div class="placeholder thumb"><span class="ph-frame">9:16</span></div>
<div class="recent-meta">
<div class="name">透真防晒 · 通勤对比</div>
<div class="sub">透真防晒 / AI 全生 / 6 镜</div>
</div>
<div class="prog"><span class="done"></span><span class="done"></span><span class="done"></span><span class="done"></span><span class="done"></span></div>
<span class="pill ok"><span class="dot"></span>已完成</span>
<span class="btn btn-sm">打开</span>
</a>
<a class="recent-row" href="pipeline.html?product=Pro%204%20%E8%93%9D%E7%89%99%E8%80%B3%E6%9C%BA#stage-4">
<div class="placeholder thumb"><span class="ph-frame">9:16</span></div>
<div class="recent-meta">
<div class="name">蓝牙耳机 · 开箱测评</div>
<div class="sub">Pro 4 蓝牙耳机 / 自带脚本 / 6 镜</div>
</div>
<div class="prog"><span class="done"></span><span class="done"></span><span class="done"></span><span class="cur"></span><span></span></div>
<span class="pill info"><span class="dot"></span>视频生成 4/6</span>
<span class="btn btn-sm">继续</span>
</a>
<a class="recent-row" href="pipeline.html?product=%E5%87%9D%E5%BD%A9%E7%AB%8B%E4%BD%93%E5%8F%A3%E7%BA%A2#stage-2">
<div class="placeholder thumb"><span class="ph-frame">9:16</span></div>
<div class="recent-meta">
<div class="name">春日新品 · 立体口红</div>
<div class="sub">凝彩立体口红 / 一句话 / 5 镜</div>
</div>
<div class="prog"><span class="done"></span><span class="cur"></span><span></span><span></span><span></span></div>
<span class="pill info"><span class="dot"></span>资产生成中</span>
<span class="btn btn-sm">继续</span>
</a>
<a class="recent-row" href="pipeline.html?product=%E5%86%B7%E8%90%83%E5%92%96%E5%95%A1%E5%86%BB%E5%B9%B2#stage-3">
<div class="placeholder thumb"><span class="ph-frame">9:16</span></div>
<div class="recent-meta">
<div class="name">咖啡冻干 · 早八</div>
<div class="sub">冷萃咖啡冻干 / 一句话 / 5 镜</div>
</div>
<div class="prog"><span class="done"></span><span class="done"></span><span class="fail"></span><span></span><span></span></div>
<span class="pill err"><span class="dot"></span>故事板失败</span>
<span class="btn btn-sm">查看</span>
</a>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:24px">
<div>
<div class="section-h"><h2>快捷入口</h2><span class="more">[ /shortcuts ]</span></div>
<div class="shortcuts">
<a class="shortcut" href="products.html">
<div class="ic" data-iconkit="package"></div>
<div><div class="t">商品库</div><div class="d">7 SKU</div></div>
</a>
<a class="shortcut" href="library.html">
<div class="ic" data-iconkit="images"></div>
<div><div class="t">资产库</div><div class="d">人 8 · 景 14 · 片 8</div></div>
</a>
<a class="shortcut" href="account.html">
<div class="ic" data-iconkit="creditCard"></div>
<div><div class="t">充值</div><div class="d">¥327.40</div></div>
</a>
<a class="shortcut" href="projects.html">
<div class="ic" data-iconkit="clapperboard"></div>
<div><div class="t">所有项目</div><div class="d">8 个</div></div>
</a>
</div>
</div>
<div>
<div class="section-h"><h2>提示</h2><span class="more">[ FAQ ]</span></div>
<div class="tip">
<strong>扣费规则</strong>
生成失败、超时、用户重跑 — 均不扣费。仅在你点 <span class="mono">[ 确认通过 ]</span> 时按 token 实际结算。
</div>
</div>
</div>
</div>
</div>
<script src="assets/icons.js?v=2026052608"></script>
<script src="assets/shell.js?v=2026060101"></script>
<script src="assets/new-product-drawer.js?v=202605211643"></script>
<script>
document.querySelectorAll('[data-iconkit]').forEach(el => {
el.innerHTML = IconKit.svg(el.dataset.iconkit, { size: Number(el.dataset.iconSize || 16) });
});
Shell.render({ active: 'dashboard', crumbs: [{ label: '工作台' }] });
</script>
<script src="assets/api-bridge.js?v=2026060114"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>登录 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
<style>
body { margin: 0; min-height: 100vh; background: var(--background-base); display: grid; place-items: center; padding: 32px 24px; }
.auth-wrap { width: 100%; max-width: 980px; display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 0; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); position: relative; overflow: hidden; min-height: 560px; }
/* 4 装订线 */
.auth-wrap::before, .auth-wrap::after,
.auth-wrap > .corner-tr, .auth-wrap > .corner-bl {
content: ''; position: absolute; width: 14px; height: 14px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center;
background-size: contain; pointer-events: none; z-index: 2;
}
.auth-wrap::before { top: -7px; left: -7px; }
.auth-wrap::after { bottom: -7px; right: -7px; }
.auth-wrap > .corner-tr { top: -7px; right: -7px; }
.auth-wrap > .corner-bl { bottom: -7px; left: -7px; }
/* 左侧品牌区 · 深色 */
.auth-brand { background: var(--accent-black); color: var(--accent-white); padding: 40px 44px; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.auth-brand .logo { display: flex; align-items: center; height: 56px; }
.auth-brand .logo-img { display: block; width: 196px; height: auto; margin: -14px 0 -10px -14px; object-fit: contain; }
.auth-brand .tag { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.5); letter-spacing: .08em; text-transform: uppercase; margin-top: 4px; }
.auth-brand .hero { margin-top: auto; margin-bottom: auto; }
.auth-brand .hero h1 { font-size: 30px; font-weight: 700; letter-spacing: -.02em; line-height: 1.2; margin: 0 0 14px; }
.auth-brand .hero h1 .h { color: var(--heat); }
.auth-brand .hero p { font-size: 13.5px; color: rgba(255,255,255,.62); line-height: 1.7; max-width: 320px; margin: 0; }
/* ASCII 装饰行 */
.auth-brand .ascii { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.32); letter-spacing: .04em; line-height: 1.8; margin-top: 28px; }
.auth-brand .ascii .ln .k { color: rgba(255,255,255,.5); }
.auth-brand .ascii .ln .v { color: var(--heat); }
.auth-brand .foot { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.32); letter-spacing: .04em; margin-top: 22px; display: flex; gap: 14px; }
.auth-brand .foot a { color: rgba(255,255,255,.5); text-decoration: none; }
.auth-brand .foot a:hover { color: var(--heat); }
/* 右侧表单区 */
.auth-form { padding: 44px 44px 36px; display: flex; flex-direction: column; }
.auth-form .h-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
.auth-form h2 { font-size: 22px; font-weight: 600; letter-spacing: -.012em; margin: 0; }
.auth-form .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; }
.auth-form .lead { font-size: 13px; color: var(--black-alpha-56); margin: 0 0 28px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
.field-label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; }
.field-label .req { color: var(--accent-crimson); }
.field-input-wrap { position: relative; }
.field-input-wrap .ic-l { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--black-alpha-32); width: 14px; height: 14px; pointer-events: none; }
.field input { width: 100%; box-sizing: border-box; padding: 11px 12px 11px 36px; font-size: 13.5px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); font-family: inherit; color: var(--accent-black); transition: border-color var(--t-base), box-shadow var(--t-base); }
.field input:focus { outline: none; border-color: var(--heat); box-shadow: 0 0 0 3px var(--heat-12); }
.field input::placeholder { color: var(--black-alpha-32); }
.field .toggle-pwd { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: var(--black-alpha-48); cursor: pointer; background: none; border: 0; padding: 0; display: grid; place-items: center; }
.field .toggle-pwd:hover { color: var(--accent-black); }
.row-between { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 22px; font-size: 12.5px; }
.row-between label { display: inline-flex; align-items: center; gap: 6px; color: var(--black-alpha-56); cursor: pointer; user-select: none; }
.row-between label input { width: 13px; height: 13px; accent-color: var(--heat); }
.row-between a { color: var(--heat); text-decoration: none; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .02em; }
.row-between a:hover { text-decoration: underline; }
.btn-cta { background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-md); padding: 12px 14px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; transition: box-shadow var(--t-base), transform var(--t-base); display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
.btn-cta:hover { box-shadow: 0 4px 14px rgba(250,93,25,.28); }
.btn-cta:active { transform: translateY(1px); }
.btn-cta svg { width: 15px; height: 15px; }
.divider { display: flex; align-items: center; gap: 10px; margin: 22px 0 18px; }
.divider .line { flex: 1; height: 1px; background: var(--border-faint); }
.divider .txt { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-32); letter-spacing: .08em; text-transform: uppercase; }
.sso-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.sso-btn { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 10px 12px; font-size: 12.5px; color: var(--accent-black); cursor: pointer; font-family: inherit; display: inline-flex; align-items: center; justify-content: center; gap: 8px; transition: border-color var(--t-base); }
.sso-btn:hover { border-color: var(--black-alpha-32); }
.sso-btn svg { width: 14px; height: 14px; }
.switch-row { margin-top: auto; padding-top: 28px; text-align: center; font-size: 12.5px; color: var(--black-alpha-56); }
.switch-row a { color: var(--heat); text-decoration: none; font-weight: 500; }
.switch-row a:hover { text-decoration: underline; }
/* 响应式 */
@media (max-width: 820px) {
.auth-wrap { grid-template-columns: 1fr; min-height: 0; }
.auth-brand { padding: 32px 28px; }
.auth-brand .ascii { display: none; }
.auth-form { padding: 32px 28px; }
}
/* 顶部小返回链(可选) */
.top-back { position: fixed; top: 20px; left: 24px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); text-decoration: none; letter-spacing: .04em; }
.top-back:hover { color: var(--heat); }
</style>
</head>
<body>
<a class="top-back" href="index.html">← 返回工作台</a>
<div class="auth-wrap">
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
<!-- 左:品牌 -->
<aside class="auth-brand">
<div class="logo">
<img class="logo-img" src="assets/logo-dark.png" alt="Airshelf">
</div>
<div class="tag">// SHORT-VIDEO COMMERCE PLATFORM</div>
<div class="hero">
<h1>AI 全流程<br><span class="h">短剧化</span>带货生成</h1>
<p>商品 → AI 脚本 → 故事板 → Seedance 视频片段 → 一键导出 9:16 ≤60s 成片。</p>
</div>
<div class="ascii">
<div class="ln"><span class="k">// step·1</span> &nbsp; 脚本生成 &nbsp; &nbsp; <span class="v">●●●●●</span></div>
<div class="ln"><span class="k">// step·2</span> &nbsp; 基础资产 &nbsp; &nbsp; <span class="v">●●●●○</span></div>
<div class="ln"><span class="k">// step·3</span> &nbsp; 故事板 &nbsp; &nbsp; &nbsp; <span class="v">●●●○○</span></div>
<div class="ln"><span class="k">// step·4</span> &nbsp; 视频片段 &nbsp; &nbsp; <span class="v">●●○○○</span></div>
<div class="ln"><span class="k">// step·5</span> &nbsp; 拼接导出 &nbsp; &nbsp; <span class="v">●○○○○</span></div>
</div>
<div class="foot">
<a href="#">关于</a>
<a href="#">定价</a>
<a href="#">联系</a>
<a href="#">隐私</a>
</div>
</aside>
<!-- 右:表单 -->
<main class="auth-form">
<div class="h-row">
<h2>登录</h2>
<span class="sub">// /auth/login</span>
</div>
<p class="lead">使用团队邀请邮箱登录,接受邀请后自动加入对应团队。</p>
<form id="login-form" autocomplete="off" onsubmit="event.preventDefault(); doLogin();">
<div class="field">
<label class="field-label" for="auth-email">邮箱 <span class="req">*</span></label>
<div class="field-input-wrap">
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><path d="m22 6-10 7L2 6"/></svg>
<input type="email" id="auth-email" placeholder="name@company.com" value="li@shop.com" required>
</div>
</div>
<div class="field">
<label class="field-label" for="auth-pwd">密码 <span class="req">*</span></label>
<div class="field-input-wrap">
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input type="password" id="auth-pwd" placeholder="••••••••" value="demo-1234" required>
<button type="button" class="toggle-pwd" aria-label="切换密码可见" onclick="togglePwd()">
<svg id="pwd-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
<div class="row-between">
<label><input type="checkbox" checked> 记住我 7 天</label>
<a href="#" onclick="event.preventDefault();_loginToast('已发送重置邮件','请到 li@shop.com 收件箱查看 · 链接 30 分钟有效');">忘记密码?</a>
</div>
<button class="btn-cta" type="submit">
登录
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
<div class="divider"><span class="line"></span><span class="txt">OR</span><span class="line"></span></div>
<div class="sso-row">
<button type="button" class="sso-btn" onclick="_loginToast('微信扫码','请在 60s 内用微信扫一扫完成授权 · 内测中,以邮箱登录为准');">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 4C5 4 2 6.5 2 9.5c0 1.7 1 3.2 2.5 4.2L4 16l2.2-1.1c.7.2 1.5.3 2.3.3-.3-.5-.5-1.1-.5-1.7 0-2.8 2.9-5 6.5-5h.5C14.6 6 12 4 9 4zm-2 3.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zM15 9c-3.3 0-6 2.2-6 5s2.7 5 6 5c.7 0 1.4-.1 2-.3L19 20l-.4-1.7c1.4-.9 2.4-2.3 2.4-3.8 0-2.8-2.7-5-6-5zm-2 2.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1z"/></svg>
微信扫码
</button>
<button type="button" class="sso-btn" onclick="_loginToast('飞书 SSO','即将打开企业飞书登录授权页 · 内测中,以邮箱登录为准');">
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="18" height="18" rx="3"/></svg>
飞书 SSO
</button>
</div>
<div class="switch-row">
还没账号? <a href="register.html">注册团队 →</a>
</div>
</form>
</main>
</div>
<script>
function togglePwd() {
const inp = document.getElementById('auth-pwd');
inp.type = inp.type === 'password' ? 'text' : 'password';
}
function doLogin() {
const btn = document.querySelector('.btn-cta');
btn.innerHTML = '<span style="font-family:var(--font-mono);font-size:12px;letter-spacing:.04em;">// 验证中...</span>';
btn.disabled = true;
setTimeout(() => { location.href = 'index.html'; }, 700);
}
// 轻量 toast(不依赖 shell.js,免登录页拉重)
function _loginToast(t, sub) {
let el = document.getElementById('__login-toast');
if (!el) {
el = document.createElement('div');
el.id = '__login-toast';
el.style.cssText = 'position:fixed;left:50%;bottom:36px;transform:translateX(-50%) translateY(20px);background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:12px 18px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:flex;flex-direction:column;gap:2px;opacity:0;transition:opacity .2s,transform .2s;z-index:9999;font-family:inherit;max-width:360px;';
document.body.appendChild(el);
}
el.innerHTML = '<div style="font-size:13.5px;font-weight:600;color:#262626;">' + t + '</div>' + (sub ? '<div style="font-size:11.5px;color:rgba(0,0,0,.56);font-family:\'Inter\',system-ui,sans-serif;letter-spacing:.02em;">// ' + sub + '</div>' : '');
requestAnimationFrame(() => { el.style.opacity = '1'; el.style.transform = 'translateX(-50%) translateY(0)'; });
clearTimeout(el._t);
el._t = setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateX(-50%) translateY(20px)'; }, 2800);
}
</script>
<script src="assets/api-bridge.js?v=2026060114"></script>
</body>
</html>
+743
View File
@@ -0,0 +1,743 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>消息中心 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
<style>
.msg-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.msg-page .page-head {
margin-bottom: 0;
}
.msg-head-actions {
display: inline-flex;
align-items: center;
gap: 10px;
}
.msg-workbench {
display: grid;
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
min-height: 640px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
}
.msg-panel {
min-width: 0;
background: transparent;
border: 0;
border-radius: 0;
overflow: hidden;
}
.msg-inbox,
.msg-detail {
display: flex;
flex-direction: column;
min-height: 0;
}
.msg-inbox { border-right: 1px solid var(--border-faint); }
.msg-panel-h {
min-height: 58px;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border-faint);
}
.msg-panel-h .ti {
font-size: 13px;
font-weight: 600;
color: var(--accent-black);
}
.msg-panel-h .mono {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--black-alpha-48);
letter-spacing: .04em;
}
.msg-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-faint);
}
.msg-filter {
height: 30px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border: 1px solid var(--border-faint);
border-radius: var(--r-pill);
background: var(--surface);
color: var(--black-alpha-56);
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.msg-filter:hover {
border-color: var(--black-alpha-24);
color: var(--accent-black);
background: var(--black-alpha-4);
}
.msg-filter.active {
border-color: var(--heat-20);
background: var(--heat-12);
color: var(--heat);
font-weight: 600;
}
.msg-filter .ct {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: .02em;
}
.msg-search {
position: relative;
padding: 0 14px 12px;
border-bottom: 1px solid var(--border-faint);
}
.msg-search svg {
position: absolute;
left: 26px;
top: 10px;
width: 13px;
height: 13px;
color: var(--black-alpha-48);
pointer-events: none;
}
.msg-search input {
width: 100%;
height: 34px;
padding: 0 12px 0 32px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--background-lighter);
color: var(--accent-black);
font-family: inherit;
font-size: 13px;
outline: none;
}
.msg-search input:focus {
background: var(--surface);
border-color: var(--heat-40);
box-shadow: inset 0 0 0 1px var(--heat-40);
}
.msg-list {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.msg-item {
position: relative;
width: 100%;
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
gap: 10px;
padding: 14px 16px;
border: 0;
border-bottom: 1px solid var(--border-faint);
background: transparent;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.msg-item:hover { background: var(--black-alpha-4); }
.msg-item.active { background: var(--heat-12); }
.msg-item.active::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 3px;
background: var(--heat);
}
.msg-item.read .msg-item-title { color: var(--black-alpha-56); font-weight: 500; }
.msg-type-ic {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
background: var(--background-lighter);
color: var(--black-alpha-72);
}
.msg-type-ic svg { width: 14px; height: 14px; }
.msg-type-ic.task { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
.msg-type-ic.team { background: var(--black-alpha-4); color: var(--accent-black); }
.msg-type-ic.billing { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }
.msg-type-ic.system { background: var(--black-alpha-7); color: var(--black-alpha-72); }
.msg-item-main { min-width: 0; }
.msg-item-row {
display: flex;
align-items: center;
gap: 8px;
}
.msg-dot {
width: 7px;
height: 7px;
border-radius: var(--r-pill);
background: var(--heat);
flex-shrink: 0;
}
.msg-item.read .msg-dot { display: none; }
.msg-item-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent-black);
font-size: 13px;
font-weight: 600;
}
.msg-time {
flex-shrink: 0;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .02em;
}
.msg-brief {
margin-top: 4px;
color: var(--black-alpha-56);
font-size: 12px;
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.msg-item-foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.msg-priority {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 7px;
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
background: var(--background-lighter);
color: var(--black-alpha-56);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: .02em;
}
.msg-priority.ok { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); }
.msg-priority.warn { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }
.msg-priority.err { background: var(--crimson-bg); border-color: var(--crimson-bd); color: var(--accent-crimson); }
.msg-priority.info { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
.msg-empty {
min-height: 320px;
display: grid;
place-items: center;
gap: 8px;
padding: 24px;
color: var(--black-alpha-48);
font-size: 13px;
text-align: center;
}
.msg-empty svg { width: 24px; height: 24px; color: var(--black-alpha-40); }
.msg-detail-empty {
flex: 1;
min-height: 520px;
display: grid;
place-items: center;
gap: 8px;
color: var(--black-alpha-48);
text-align: center;
}
.msg-detail-empty .ic {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--background-lighter);
}
.msg-detail-empty svg { width: 21px; height: 21px; }
.msg-detail-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 22px 24px 24px;
}
.msg-detail-top {
display: flex;
align-items: flex-start;
gap: 12px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-faint);
}
.msg-detail-title {
min-width: 0;
flex: 1;
}
.msg-detail-title h2 {
margin: 0;
font-size: 20px;
line-height: 1.35;
font-weight: 600;
letter-spacing: -.012em;
color: var(--accent-black);
}
.msg-detail-title .meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .04em;
}
.msg-body-text {
margin: 18px 0 0;
color: var(--accent-black);
font-size: 14px;
line-height: 1.75;
}
.msg-props {
display: grid;
grid-template-columns: 110px 1fr;
gap: 10px 16px;
margin-top: 18px;
padding: 14px 16px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--background-lighter);
}
.msg-props .k {
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .04em;
}
.msg-props .v {
min-width: 0;
color: var(--accent-black);
font-size: 13px;
}
.msg-props .v a { color: var(--heat); }
.msg-timeline {
margin-top: 18px;
padding: 16px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
}
.msg-timeline-h {
margin-bottom: 12px;
color: var(--accent-black);
font-size: 13px;
font-weight: 600;
}
.msg-step {
display: grid;
grid-template-columns: 68px 1fr;
gap: 12px;
padding: 10px 0;
border-top: 1px solid var(--border-faint);
}
.msg-step:first-of-type { border-top: 0; padding-top: 0; }
.msg-step .t {
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .02em;
}
.msg-step .d {
color: var(--black-alpha-72);
font-size: 12.5px;
line-height: 1.55;
}
.msg-log {
margin-top: 14px;
padding: 12px 14px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--accent-black);
color: var(--accent-white);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.7;
letter-spacing: .02em;
white-space: pre-wrap;
}
.msg-detail-f {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
border-top: 1px solid var(--border-faint);
background: var(--background-lighter);
}
.msg-detail-f .spacer { flex: 1; }
.msg-foot-note {
display: flex;
align-items: center;
gap: 8px;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .04em;
}
.msg-foot-note a { color: var(--heat); }
@media (max-width: 1280px) {
.msg-workbench { grid-template-columns: minmax(300px, 340px) minmax(0, 1fr); }
}
@media (max-width: 860px) {
.msg-workbench { grid-template-columns: 1fr; }
.msg-inbox { border-right: 0; border-bottom: 1px solid var(--border-faint); }
.msg-list { max-height: 360px; }
}
</style>
</head>
<body>
<div id="page">
<div class="msg-page">
<div class="page-head">
<div>
<h1>消息中心</h1>
<div class="sub"><span class="mono" id="msg-head-sub">// INBOX</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
</div>
<div class="msg-head-actions">
<button class="btn" type="button" id="msg-mark-all">全部标已读</button>
<button class="btn" type="button" id="msg-settings">通知设置</button>
</div>
</div>
<div class="msg-workbench">
<section class="msg-panel msg-inbox">
<div class="msg-panel-h">
<span class="ti">收件箱</span>
<span class="mono" id="msg-list-count">// 0 条</span>
</div>
<div class="msg-filters" id="msg-filters"></div>
<div class="msg-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="msg-search" type="text" placeholder="搜索项目、来源、内容">
</div>
<div class="msg-list" id="msg-list"></div>
</section>
<section class="msg-panel msg-detail" id="msg-detail"></section>
</div>
<div class="msg-foot-note">
<span>// 消息保留 90 天 · 高风险任务会同时进入工作台队列</span>
<a href="settings.html#sec-notify">管理通知策略 →</a>
</div>
</div>
</div>
<script src="assets/icons.js?v=2026052608"></script>
<script src="assets/shell.js?v=2026060101"></script>
<script>
Shell.render({ active: '', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '消息中心' }] });
(function () {
const NOW = new Date('2026-05-28T10:30:00');
const $ = sel => document.querySelector(sel);
const icon = name => window.IconKit ? window.IconKit.svg(name) : '';
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
const mins = n => new Date(NOW.getTime() - n * 60000);
const zhType = { all: '全部', unread: '未读', task: '任务', team: '团队', billing: '计费', system: '系统' };
const typeIcon = { task: 'clapperboard', team: 'users', billing: 'creditCard', system: 'info' };
const priLabel = { ok: '已完成', warn: '需关注', err: '风险', info: '更新' };
function fmtTime(d) {
const diff = Math.round((NOW - d) / 60000);
if (diff < 60) return diff + 'm';
if (diff < 1440) return Math.floor(diff / 60) + 'h';
return Math.floor(diff / 1440) + 'd';
}
function fmtFull(d) {
const z = n => String(n).padStart(2, '0');
return d.getFullYear() + '-' + z(d.getMonth() + 1) + '-' + z(d.getDate()) + ' ' + z(d.getHours()) + ':' + z(d.getMinutes());
}
const messages = [
{
id: 'task-video-ready', type: 'task', priority: 'ok', unread: true,
title: '补水面膜 · 痛点种草 v3 成片已完成',
brief: '7 镜 · 40 秒 · ¥18.40 已结算,可确认进入投放阶段。',
body: 'Seedance 视频生成全部完成,本次输出 3 条 9:16 成片。系统检测到镜头 3 的停留率预测最高,建议优先作为首版投放素材。',
source: '视频生成队列', project: '补水面膜', stage: 'Stage 5 · 拼接导出', owner: '李', cost: '¥18.40',
time: mins(12), href: 'pipeline.html?product=' + encodeURIComponent('补水面膜'),
log: 'render_id=vid-20260528-1030\nstatus=ok\nclips=7\nratio=9:16\ncharge=18.40',
timeline: [['10:06', '脚本与镜头配置锁定'], ['10:14', '3 条视频完成生成'], ['10:26', '字幕/BGM 合成完成'], ['10:30', '成片已入库,等待确认投放']],
actions: [{ id: 'goto', label: '进入项目', primary: true }, { id: 'preview', label: '查看成片' }, { id: 'rerun', label: '重跑视频' }],
},
{
id: 'task-script-failed', type: 'task', priority: 'err', unread: true,
title: '脚本生成失败 · 618 大促',
brief: 'Prompt 超过服务限制,本次失败未扣费,可一键重试。',
body: '脚本助手收到的商品卖点和参考文案过长,超过当前脚本模型单次上下文限制。系统已保存失败日志,并建议先压缩参考内容再重跑。',
source: '脚本助手', project: '618 大促', stage: 'Stage 1 · 脚本', owner: '张', cost: '¥0.00',
time: mins(64), href: 'pipeline.html?product=' + encodeURIComponent('618 大促'),
log: 'request_id=script-413\nerror=context_limit\nprompt_tokens=8124\nmax_tokens=8000\ncharged=false',
timeline: [['09:12', '提交脚本生成'], ['09:13', '模型返回 context_limit'], ['09:13', '费用回滚完成']],
actions: [{ id: 'rerun', label: '重试生成', primary: true }, { id: 'log', label: '查看日志' }],
},
{
id: 'task-asset-done', type: 'task', priority: 'ok', unread: true,
title: '4 张模特上身图已加入资产库',
brief: '祛痘精华 · 通勤白领 · 3:4,可直接进入视频项目。',
body: '模特上身图通过基础审核,已同步到资产库「未分类」。建议把其中 2 张加入祛痘精华项目作为 Stage 3 镜头参考。',
source: '图片生成', project: '祛痘精华', stage: '图片生成 · 模特上身图', owner: '陈', cost: '¥3.20',
time: mins(140), href: 'library.html',
timeline: [['08:05', '收到 2 张参考图'], ['08:08', '生成 4 张候选'], ['08:11', '通过筛选并写入资产库']],
actions: [{ id: 'goto', label: '查看素材', primary: true }, { id: 'attach', label: '加入项目' }],
},
{
id: 'team-edit-lock', type: 'team', priority: 'info', unread: true,
title: '@刘 正在编辑「补水面膜」项目',
brief: '你暂时以只读方式查看该项目,避免两人同时改动覆盖。',
body: '系统检测到同项目正在被团队成员编辑。为避免脚本、镜头或资产配置被互相覆盖,后进入者会进入只读查看状态。编辑结束后会自动恢复操作权限。',
source: '协作锁定', project: '补水面膜', stage: 'Stage 3 · 镜头', owner: '刘', cost: '-',
time: mins(210), href: 'pipeline.html?product=' + encodeURIComponent('补水面膜') + '#stage-3',
timeline: [['07:00', '刘进入项目编辑'], ['07:01', '系统为后进入成员开启只读状态']],
actions: [{ id: 'goto', label: '查看项目', primary: true }, { id: 'ack', label: '我已了解' }],
},
{
id: 'team-member', type: 'team', priority: 'info', unread: false,
title: '@王芳 已加入团队',
brief: '角色为运营,月限额 ¥800,可读写团队资产。',
body: '新成员已完成邮箱验证。她可以查看并编辑团队项目,但不能调整计费、安全与团队角色设置。',
source: '@李(超管)', project: '团队', stage: '成员管理', owner: '王', cost: '-',
time: mins(320), href: 'team.html',
timeline: [['05:02', '邀请邮件送达'], ['05:18', '成员完成验证'], ['05:19', '角色权限生效']],
actions: [{ id: 'goto', label: '查看团队', primary: true }, { id: 'settings', label: '调整权限' }],
},
{
id: 'billing-low', type: 'billing', priority: 'warn', unread: false,
title: '团队余额低于预警线',
brief: '当前余额 ¥87.20,按近 7 天速度预计可支撑 4 个视频项目。',
body: '余额预警阈值为 ¥100。若今天继续批量生成视频,建议先充值或临时降低成员月限额。',
source: '计费中心', project: '消费', stage: '余额监控', owner: '系统', cost: '¥87.20',
time: mins(460), href: 'account.html',
timeline: [['02:50', '余额低于 ¥100'], ['02:51', '已发送站内通知']],
actions: [{ id: 'goto', label: '前往充值', primary: true }, { id: 'settings', label: '预警设置' }],
},
{
id: 'system-maintenance', type: 'system', priority: 'info', unread: false,
title: '视频生成服务今晚例行维护',
brief: '23:00 - 01:00 期间提交任务会自动排队。',
body: '维护期间已进入生成中的任务不受影响。新提交的视频生成、三视图生成会进入队列,维护结束后按提交时间继续处理。',
source: 'Airshelf 运维', project: '系统', stage: '服务公告', owner: '系统', cost: '-',
time: mins(720), href: '',
timeline: [['昨天', '发布维护窗口'], ['今晚', '队列自动延后处理']],
actions: [{ id: 'ack', label: '我已了解', primary: true }],
},
{
id: 'system-review', type: 'system', priority: 'ok', unread: false,
title: '祛痘精华首版素材通过平台审核',
brief: '4 张图 / 2 条视频审核通过,可进入投放。',
body: '素材合规审核耗时 1 小时 12 分钟。系统未发现夸大功效、违禁词或画面遮挡问题。',
source: '审核中台', project: '祛痘精华', stage: 'Stage 5 · 投放', owner: '系统', cost: '-',
time: mins(1120), href: 'pipeline.html?product=' + encodeURIComponent('祛痘精华'),
timeline: [['昨天 16:40', '提交平台审核'], ['昨天 17:52', '审核通过'], ['昨天 17:53', '写入项目状态']],
actions: [{ id: 'goto', label: '进入投放', primary: true }],
},
];
const state = { tab: 'all', q: '', selectedId: messages[0].id, showLogId: null };
function counts() {
return {
unread: messages.filter(m => m.unread).length,
task: messages.filter(m => m.type === 'task').length,
team: messages.filter(m => m.type === 'team').length,
billing: messages.filter(m => m.type === 'billing').length,
system: messages.filter(m => m.type === 'system').length,
risk: messages.filter(m => m.priority === 'err' || m.priority === 'warn').length,
};
}
function visibleMessages() {
const q = state.q.toLowerCase();
return messages.filter(m => {
if (state.tab === 'unread' && !m.unread) return false;
if (!['all', 'unread'].includes(state.tab) && m.type !== state.tab) return false;
if (q && ![m.title, m.brief, m.body, m.source, m.project, m.stage].join(' ').toLowerCase().includes(q)) return false;
return true;
});
}
function renderSummary() {
const c = counts();
$('#msg-head-sub').textContent = '// ' + c.unread + ' 条未读 · ' + messages.length + ' 条总计';
}
function renderFilters() {
const c = counts();
const filters = [
['all', '全部', messages.length],
['unread', '未读', c.unread],
['task', '任务', c.task],
['team', '团队', c.team],
['billing', '计费', c.billing],
['system', '系统', c.system],
];
$('#msg-filters').innerHTML = filters.map(([id, label, ct]) => `
<button class="msg-filter${state.tab === id ? ' active' : ''}" type="button" data-tab="${id}">
${label}<span class="ct">${ct}</span>
</button>
`).join('');
$('#msg-filters').querySelectorAll('[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
state.tab = btn.dataset.tab;
render();
});
});
}
function renderList() {
const list = visibleMessages();
$('#msg-list-count').textContent = '// 显示 ' + list.length + ' 条';
if (!list.length) {
$('#msg-list').innerHTML = `<div class="msg-empty">${icon('search')}<span>没有符合条件的消息</span></div>`;
return;
}
$('#msg-list').innerHTML = list.map(m => `
<button class="msg-item${state.selectedId === m.id ? ' active' : ''}${m.unread ? '' : ' read'}" type="button" data-id="${esc(m.id)}">
<span class="msg-type-ic ${esc(m.type)}">${icon(typeIcon[m.type] || 'info')}</span>
<span class="msg-item-main">
<span class="msg-item-row">
<span class="msg-dot"></span>
<span class="msg-item-title">${esc(m.title)}</span>
<span class="msg-time">${fmtTime(m.time)}</span>
</span>
<span class="msg-brief">${esc(m.brief)}</span>
<span class="msg-item-foot">
<span class="msg-priority ${esc(m.priority)}">${esc(priLabel[m.priority] || '更新')}</span>
<span class="msg-priority">${esc(m.project)}</span>
</span>
</span>
</button>
`).join('');
$('#msg-list').querySelectorAll('[data-id]').forEach(btn => {
btn.addEventListener('click', () => {
state.selectedId = btn.dataset.id;
const msg = messages.find(m => m.id === state.selectedId);
if (msg) msg.unread = false;
render();
});
});
}
function renderDetail() {
const m = messages.find(x => x.id === state.selectedId) || visibleMessages()[0] || messages[0];
if (!m) {
$('#msg-detail').innerHTML = `<div class="msg-detail-empty"><div class="ic">${icon('bell')}</div><div>暂无消息</div></div>`;
return;
}
state.selectedId = m.id;
const props = [
['来源', m.source],
['类别', zhType[m.type]],
['项目', m.project],
['阶段', m.stage],
['负责人', m.owner],
['费用', m.cost],
['时间', fmtFull(m.time)],
['关联资源', m.href ? `<a href="${esc(m.href)}">${esc(m.project)} →</a>` : '无'],
].map(([k, v]) => `<span class="k">${k}</span><span class="v">${v}</span>`).join('');
const actions = m.actions.map(a => `<button class="btn${a.primary ? ' btn-primary' : ''}" type="button" data-action="${esc(a.id)}">${esc(a.label)}</button>`).join('');
$('#msg-detail').innerHTML = `
<div class="msg-detail-body">
<div class="msg-detail-top">
<span class="msg-type-ic ${esc(m.type)}">${icon(typeIcon[m.type] || 'info')}</span>
<div class="msg-detail-title">
<h2>${esc(m.title)}</h2>
<div class="meta"><span>${esc(m.source)}</span><span>// ${esc(zhType[m.type])}</span><span>${fmtFull(m.time)}</span></div>
</div>
<span class="msg-priority ${esc(m.priority)}">${esc(priLabel[m.priority] || '更新')}</span>
</div>
<p class="msg-body-text">${esc(m.body)}</p>
<div class="msg-props">${props}</div>
<div class="msg-timeline">
<div class="msg-timeline-h">处理记录</div>
${m.timeline.map(([t, d]) => `<div class="msg-step"><span class="t">${esc(t)}</span><span class="d">${esc(d)}</span></div>`).join('')}
</div>
${state.showLogId === m.id && m.log ? `<pre class="msg-log">${esc(m.log)}</pre>` : ''}
</div>
<div class="msg-detail-f">
<button class="btn btn-ghost" type="button" data-action="archive">归档</button>
<button class="btn btn-ghost" type="button" data-action="mute">静音同类</button>
<span class="spacer"></span>
${actions}
</div>
`;
$('#msg-detail').querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', () => runAction(m, btn.dataset.action));
});
}
function runAction(m, act) {
if (!m) return;
if (act === 'goto') {
if (m.href) location.href = m.href;
else location.href = 'settings.html#sec-notify';
return;
}
if (act === 'preview') { Shell.toast('打开视频预览', m.project + ' · 成片'); return; }
if (act === 'rerun') {
m.priority = 'info';
m.brief = '任务已重新排队,完成后会再次提醒。';
Shell.toast('已重新排队', m.project + ' · ' + m.stage);
render();
return;
}
if (act === 'log') { state.showLogId = state.showLogId === m.id ? null : m.id; renderDetail(); return; }
if (act === 'attach') { location.href = 'projects-new.html'; return; }
if (act === 'settings') { location.href = 'settings.html#sec-notify'; return; }
if (act === 'ack') { m.unread = false; Shell.toast('已确认', m.title); render(); return; }
if (act === 'archive') {
const i = messages.findIndex(x => x.id === m.id);
if (i >= 0) messages.splice(i, 1);
state.selectedId = messages[0]?.id || null;
Shell.toast('已归档', m.title);
render();
return;
}
if (act === 'mute') { Shell.toast('已静音同类', zhType[m.type] + ' 类提醒可在设置恢复'); return; }
}
function render() {
renderSummary();
renderFilters();
renderList();
renderDetail();
}
$('#msg-search').addEventListener('input', e => {
state.q = e.target.value.trim();
renderList();
});
$('#msg-mark-all').addEventListener('click', () => {
messages.forEach(m => { m.unread = false; });
Shell.toast('已全部标为已读', messages.length + ' 条');
render();
});
$('#msg-settings').addEventListener('click', () => { location.href = 'settings.html#sec-notify'; });
render();
})();
</script>
<script src="assets/api-bridge.js?v=2026060114"></script>
</body>
</html>
@@ -0,0 +1,666 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>方案 A · 商品空间 · 模特上身图 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
<style>
.app { height: 100vh; overflow: hidden; }
main { display: flex; flex-direction: column; min-height: 0; }
#page-content { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0; }
/* ===== 两栏:左侧栏 / 右侧主区 ===== */
.dma {
flex: 1; min-height: 0;
display: grid;
grid-template-columns: 260px 1fr;
}
/* ── 左侧栏 · 仅商品空间(搜索 + 列表 + 全部入口) ── */
.dma-side {
border-right: 1px solid var(--border-faint);
background: var(--surface);
display: flex; flex-direction: column;
min-height: 0;
}
.dma-side-h {
padding: 14px 14px 10px;
flex-shrink: 0;
}
.dma-side-h .ti-row {
display: flex; align-items: center;
margin-bottom: 10px;
}
.dma-side-h .ti {
font-size: 11px; font-family: var(--font-mono);
color: var(--black-alpha-48); letter-spacing: .08em;
text-transform: uppercase;
}
.dma-side-h .ti-row .add {
margin-left: auto;
width: 22px; height: 22px;
display: grid; place-items: center;
background: var(--heat-12); color: var(--heat);
border: 0; border-radius: var(--r-sm);
cursor: pointer;
}
.dma-side-h .add svg { width: 11px; height: 11px; }
/* 搜索框 (Q1-A) */
.dma-search {
display: flex; align-items: center; gap: 8px;
height: 32px; padding: 0 10px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
transition: border-color var(--t-base), background var(--t-base);
}
.dma-search:focus-within { border-color: var(--heat-40); background: var(--surface); }
.dma-search svg { width: 13px; height: 13px; color: var(--black-alpha-48); flex-shrink: 0; }
.dma-search input {
flex: 1; min-width: 0; height: 100%;
border: 0; outline: 0; background: transparent;
font-size: 12.5px; color: var(--accent-black); font-family: inherit;
}
/* 商品空间列表(flex:1 占据中部所有剩余空间) */
.dma-prod-list {
flex: 1; min-height: 0;
overflow-y: auto;
padding: 4px 10px 10px;
display: flex; flex-direction: column; gap: 4px;
}
.dma-prod {
display: flex; align-items: center; gap: 10px;
padding: 8px;
border: 1px solid transparent;
border-radius: var(--r-sm);
cursor: pointer;
transition: background var(--t-base), border-color var(--t-base);
}
.dma-prod:hover { background: var(--background-lighter); }
.dma-prod.active { background: var(--heat-12); border-color: var(--heat-20); }
.dma-prod .thumb {
flex-shrink: 0;
width: 40px; height: 40px;
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
overflow: hidden;
background: repeating-linear-gradient(135deg, transparent 0 4px, rgba(0,0,0,.04) 4px 5px);
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 9px;
color: var(--black-alpha-32);
}
.dma-prod.active .thumb { border-color: var(--heat); }
.dma-prod .body { flex: 1; min-width: 0; }
.dma-prod .nm {
font-size: 12.5px;
color: var(--accent-black); font-weight: 500;
line-height: 1.3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dma-prod.active .nm { color: var(--heat); font-weight: 600; }
.dma-prod .sub {
margin-top: 2px;
font-family: var(--font-mono); font-size: 10px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
/* 全部商品入口(Q1-B · 固定贴底) */
.dma-all {
flex-shrink: 0;
margin: 0 10px 12px;
padding: 10px 12px;
background: var(--background-lighter);
border: 1px dashed var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-72);
font-size: 12px;
font-family: inherit;
cursor: pointer;
display: flex; align-items: center; gap: 6px;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.dma-all:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }
.dma-all .ct {
color: var(--black-alpha-48);
font-family: var(--font-mono); font-size: 10.5px;
margin-left: auto;
}
.dma-all svg { width: 12px; height: 12px; }
/* ── 右侧主区 ── */
.dma-main {
display: flex; flex-direction: column;
min-height: 0;
overflow: hidden;
}
.dma-main-h {
flex-shrink: 0;
display: flex; align-items: center; gap: 14px;
padding: 14px 28px;
border-bottom: 1px solid var(--border-faint);
background: var(--surface);
}
.dma-main-h .crumb {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--black-alpha-48); letter-spacing: .04em;
}
.dma-main-h h2 {
font-size: 20px; font-weight: 600;
letter-spacing: -.015em;
color: var(--accent-black);
}
.dma-main-h .stats {
margin-left: auto;
display: flex; gap: 6px;
font-family: var(--font-mono); font-size: 11px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
.dma-main-h .stats b { color: var(--accent-black); font-weight: 600; }
.dma-main-h .stats .sep { color: var(--black-alpha-24); }
/* 主区:参数 + 结果横向二栏 */
.dma-body {
flex: 1; min-height: 0;
display: grid;
grid-template-columns: 320px 1fr;
gap: 0;
}
/* 左侧参数面板 */
.dma-form {
border-right: 1px solid var(--border-faint);
background: var(--surface);
display: flex; flex-direction: column;
min-height: 0;
}
.dma-form-scroll {
flex: 1; min-height: 0; overflow-y: auto;
padding: 16px 18px;
}
.dma-field { margin-bottom: 16px; }
.dma-field-h {
font-size: 12px; font-weight: 600;
color: var(--accent-black);
margin-bottom: 8px;
}
.dma-field-h .opt {
font-weight: 400; font-size: 11px;
color: var(--black-alpha-48); margin-left: 4px;
}
.dma-models {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.dma-model {
aspect-ratio: 3 / 4;
border: 1px solid var(--border-faint);
background: var(--background-lighter);
border-radius: var(--r-sm);
position: relative; cursor: pointer;
overflow: hidden;
transition: border-color var(--t-base);
}
.dma-model:hover { border-color: var(--black-alpha-32); }
.dma-model.selected { border-color: var(--heat); border-width: 2px; }
.dma-model .ph {
position: absolute; inset: 0;
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 10px;
color: var(--black-alpha-32);
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
}
.dma-model .nm {
position: absolute; bottom: 0; left: 0; right: 0;
padding: 4px 6px;
background: linear-gradient(transparent, rgba(0,0,0,.5));
font-size: 10px; color: #fff; font-weight: 500;
}
.dma-model.selected::after {
content: ''; position: absolute; top: 4px; right: 4px;
width: 14px; height: 14px;
background: var(--heat) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") no-repeat center / 9px;
border-radius: 50%;
}
.dma-chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
.dma-chip {
display: inline-flex; align-items: center;
height: 28px; padding: 0 11px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-size: 12px; color: var(--black-alpha-72);
font-family: inherit; cursor: pointer;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.dma-chip:hover { border-color: var(--black-alpha-32); color: var(--accent-black); }
.dma-chip.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
.dma-form-cta {
flex-shrink: 0;
padding: 14px 18px;
border-top: 1px solid var(--border-faint);
background: var(--surface);
}
.dma-cost {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px;
font-family: var(--font-mono); font-size: 11px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
.dma-cost .v { color: var(--accent-black); font-weight: 600; }
.dma-gen {
width: 100%; height: 42px;
background: var(--heat); color: #fff;
border: 1px solid var(--heat); border-radius: var(--r-md);
font-size: 14px; font-weight: 600; font-family: inherit;
cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
box-shadow: var(--shadow-cta);
}
.dma-gen svg { width: 15px; height: 15px; }
/* 右侧结果区 */
.dma-result {
background: var(--background-base);
min-height: 0; overflow-y: auto;
padding: 22px 24px;
}
.dma-result-h {
display: flex; align-items: baseline; gap: 10px;
margin-bottom: 14px;
}
.dma-result-h .ti { font-size: 15px; font-weight: 600; color: var(--accent-black); }
.dma-result-h .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
/* 批次卡 */
.dma-batch {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 14px 16px;
margin-bottom: 14px;
}
.dma-batch-h {
display: flex; align-items: center; gap: 10px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-faint);
}
.dma-batch-h .pic {
width: 28px; height: 28px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
display: grid; place-items: center;
color: var(--heat);
font-family: var(--font-mono); font-size: 11px; font-weight: 600;
}
.dma-batch-h .meta { flex: 1; min-width: 0; }
.dma-batch-h .nm { font-size: 13px; font-weight: 600; color: var(--accent-black); }
.dma-batch-h .info { margin-top: 2px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.dma-batch-h .info .sep { color: var(--black-alpha-24); }
.dma-batch-h .ops { display: flex; gap: 4px; }
.dma-batch-h .ops button {
width: 28px; height: 28px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-56); cursor: pointer;
display: grid; place-items: center;
}
.dma-batch-h .ops button:hover { border-color: var(--heat-20); color: var(--heat); }
.dma-batch-h .ops button svg { width: 13px; height: 13px; }
.dma-batch-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
@media (max-width: 1400px) { .dma-batch-grid { grid-template-columns: repeat(3, 1fr); } }
.dma-cell {
aspect-ratio: 3 / 4;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
overflow: hidden; position: relative;
cursor: pointer;
}
.dma-cell .ph {
position: absolute; inset: 0;
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 11px;
color: var(--black-alpha-32);
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
}
.dma-cell .tag {
position: absolute; top: 6px; left: 6px;
padding: 2px 6px;
background: rgba(0,0,0,.65);
color: #fff;
border-radius: var(--r-sm);
font-size: 10px; font-weight: 500;
backdrop-filter: blur(4px);
}
/* 空态 */
.dma-empty {
height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px;
color: var(--black-alpha-48);
text-align: center;
}
.dma-empty .ic {
width: 56px; height: 56px;
background: var(--surface); border: 1px solid var(--border-faint);
border-radius: var(--r-md);
display: grid; place-items: center;
color: var(--heat);
}
.dma-empty .ic svg { width: 22px; height: 22px; }
.dma-empty .mono { font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em; }
/* 顶部"提示条" · 这是 demo */
.dma-banner {
margin: 12px 28px 0;
padding: 8px 12px;
background: var(--heat-12);
border: 1px dashed var(--heat-20);
border-radius: var(--r-sm);
font-size: 12px;
color: var(--accent-black);
font-family: var(--font-mono); letter-spacing: .02em;
}
.dma-banner b { color: var(--heat); }
</style>
</head>
<body>
<div id="page">
<div class="dma-banner">// DEMO · 方案 A · <b>商品 = 项目空间</b>(Q1: A+B)。左栏仅商品空间:🔍 搜索 + 最近 6 条 + <b>全部商品</b>兜底入口;历史任务已挪进主区。主区:模特卡 + 张数 + 比例 + 立即生成,生成结果自动绑定到当前商品。</div>
<div class="dma">
<!-- ===== 左侧栏 · 仅商品空间 (搜索 + 列表 + 全部入口) ===== -->
<aside class="dma-side">
<div class="dma-side-h">
<div class="ti-row">
<span class="ti">商品空间</span>
<button class="add" type="button" title="新建商品">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<!-- Q1-A · 搜索框 -->
<div class="dma-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input type="text" placeholder="搜索商品 / 分类">
</div>
</div>
<!-- 商品列表 · 最近 6 条 -->
<div class="dma-prod-list">
<div class="dma-prod active">
<div class="thumb">主图</div>
<div class="body">
<div class="nm">透真补水面膜</div>
<div class="sub">// 美妆个护 · 6 批</div>
</div>
</div>
<div class="dma-prod">
<div class="thumb">主图</div>
<div class="body">
<div class="nm">透真清透防晒霜</div>
<div class="sub">// 美妆个护 · 3 批</div>
</div>
</div>
<div class="dma-prod">
<div class="thumb">主图</div>
<div class="body">
<div class="nm">南卡 Lite Pro 蓝牙耳机</div>
<div class="sub">// 数码 3C · 2 批</div>
</div>
</div>
<div class="dma-prod">
<div class="thumb">主图</div>
<div class="body">
<div class="nm">滋啦速食牛肉面</div>
<div class="sub">// 食品饮料 · 1 批</div>
</div>
</div>
<div class="dma-prod">
<div class="thumb">主图</div>
<div class="body">
<div class="nm">三顿半同款冻干咖啡</div>
<div class="sub">// 食品饮料 · 1 批</div>
</div>
</div>
<div class="dma-prod">
<div class="thumb">主图</div>
<div class="body">
<div class="nm">小熊 4L 可视空气炸锅</div>
<div class="sub">// 家居家电 · 0 批</div>
</div>
</div>
</div>
<!-- Q1-B · 全部商品入口(贴底) -->
<button class="dma-all" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M9 4v16"/></svg>
全部商品
<span class="ct">24 个</span>
<svg style="margin-left:4px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</aside>
<!-- ===== 右侧主区 ===== -->
<section class="dma-main">
<!-- 顶部 · 商品名 + 统计 -->
<div class="dma-main-h">
<div>
<div class="crumb">// 商品空间</div>
<h2>透真补水面膜</h2>
</div>
<div class="stats">
<span>本商品 <b>6</b></span>
<span class="sep">·</span>
<span>累计 <b>22</b> 张图</span>
<span class="sep">·</span>
<span>最近 <b>3 分钟前</b></span>
</div>
</div>
<!-- 中间:参数面板 + 结果 -->
<div class="dma-body">
<!-- 参数面板(单一职责:挑模特 + 张数) -->
<div class="dma-form">
<div class="dma-form-scroll">
<div class="dma-field">
<div class="dma-field-h">选择模特<span class="opt">(已锁定商品 · 透真补水面膜)</span></div>
<div class="dma-models">
<div class="dma-model selected">
<div class="ph">Ava · 3:4</div>
<div class="nm">Ava</div>
</div>
<div class="dma-model">
<div class="ph">Zoe · 3:4</div>
<div class="nm">Zoe</div>
</div>
<div class="dma-model">
<div class="ph">Ben · 3:4</div>
<div class="nm">Ben</div>
</div>
<div class="dma-model">
<div class="ph">Lin · 3:4</div>
<div class="nm">Lin</div>
</div>
<div class="dma-model">
<div class="ph">Mia · 3:4</div>
<div class="nm">Mia</div>
</div>
<div class="dma-model" style="border-style:dashed;display:flex;align-items:center;justify-content:center;color:var(--black-alpha-48);">
<span style="font-size:18px">+</span>
</div>
</div>
</div>
<div class="dma-field">
<div class="dma-field-h">生成张数</div>
<div class="dma-chip-row">
<button class="dma-chip" type="button">1 张</button>
<button class="dma-chip" type="button">2 张</button>
<button class="dma-chip active" type="button">4 张</button>
<button class="dma-chip" type="button">8 张</button>
</div>
</div>
<div class="dma-field">
<div class="dma-field-h">画面比例</div>
<div class="dma-chip-row">
<button class="dma-chip" type="button">1:1</button>
<button class="dma-chip active" type="button">3:4</button>
<button class="dma-chip" type="button">9:16</button>
<button class="dma-chip" type="button">16:9</button>
</div>
</div>
<div class="dma-field">
<div class="dma-field-h">补充提示词<span class="opt">(选填)</span></div>
<textarea style="width:100%;min-height:60px;padding:8px 10px;background:var(--background-lighter);border:1px solid var(--black-alpha-12);border-radius:var(--r-sm);font-family:inherit;font-size:12.5px;outline:none;resize:vertical" placeholder="例:户外阳光、敷面膜的特写、白底产品摄影"></textarea>
</div>
</div>
<div class="dma-form-cta">
<div class="dma-cost">
<span>预估扣费 <span class="v">≈ ¥1.20</span></span>
<span>余额 ¥327.40</span>
</div>
<button class="dma-gen" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z"/></svg>
立即生成 · 透真补水面膜 × Ava
</button>
</div>
</div>
<!-- 右侧结果 · 当前商品全部批次 -->
<div class="dma-result">
<div class="dma-result-h">
<span class="ti">最近批次 · Ava × 4 张</span>
<span class="sub">// 3 分钟前 · 已完成</span>
</div>
<!-- 批次 1 -->
<div class="dma-batch">
<div class="dma-batch-h">
<div class="pic">4×</div>
<div class="meta">
<div class="nm">Ava × 4 张</div>
<div class="info">透真补水面膜 <span class="sep">·</span> 3:4 <span class="sep">·</span> 3 分钟前 <span class="sep">·</span> ¥1.20</div>
</div>
<div class="ops">
<button type="button" title="全部重跑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg></button>
<button type="button" title="全部下载"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg></button>
<button type="button" title="加入资产库"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg></button>
</div>
</div>
<div class="dma-batch-grid">
<div class="dma-cell"><div class="ph">Ava · #1</div><span class="tag">3:4</span></div>
<div class="dma-cell"><div class="ph">Ava · #2</div><span class="tag">3:4</span></div>
<div class="dma-cell"><div class="ph">Ava · #3</div><span class="tag">3:4</span></div>
<div class="dma-cell"><div class="ph">Ava · #4</div><span class="tag">3:4</span></div>
</div>
</div>
<!-- 批次 2 -->
<div class="dma-batch">
<div class="dma-batch-h">
<div class="pic">4×</div>
<div class="meta">
<div class="nm">Zoe × 4 张</div>
<div class="info">透真补水面膜 <span class="sep">·</span> 3:4 <span class="sep">·</span> 12 分钟前 <span class="sep">·</span> ¥1.20</div>
</div>
<div class="ops">
<button type="button" title="全部重跑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg></button>
<button type="button" title="全部下载"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg></button>
<button type="button" title="加入资产库"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg></button>
</div>
</div>
<div class="dma-batch-grid">
<div class="dma-cell"><div class="ph">Zoe · #1</div><span class="tag">3:4</span></div>
<div class="dma-cell"><div class="ph">Zoe · #2</div><span class="tag">3:4</span></div>
<div class="dma-cell"><div class="ph">Zoe · #3</div><span class="tag">3:4</span></div>
<div class="dma-cell"><div class="ph">Zoe · #4</div><span class="tag">3:4</span></div>
</div>
</div>
<!-- 批次 3 -->
<div class="dma-batch">
<div class="dma-batch-h">
<div class="pic">2×</div>
<div class="meta">
<div class="nm">Ben × 2 张</div>
<div class="info">透真补水面膜 <span class="sep">·</span> 3:4 <span class="sep">·</span> 刚刚 <span class="sep">·</span> 生成中</div>
</div>
<div class="ops">
<button type="button" title="取消"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M6 18L18 6"/></svg></button>
</div>
</div>
<div class="dma-batch-grid">
<div class="dma-cell"><div class="ph">生成中…</div></div>
<div class="dma-cell"><div class="ph">生成中…</div></div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
<script src="assets/icons.js?v=2026052608"></script>
<script src="assets/shell.js?v=2026060101"></script>
<script>
Shell.render({
active: 'asset-factory',
crumbs: [
{ label: '工作台', href: 'index.html' },
{ label: '图片生成', href: 'asset-factory.html' },
{ label: '模特上身图 · 方案 A · Demo' }
]
});
// 简单交互:点击商品 / 任务切换激活态
document.querySelectorAll('.dma-prod').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.dma-prod').forEach(x => x.classList.remove('active'));
el.classList.add('active');
});
});
document.querySelectorAll('.dma-task').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.dma-task').forEach(x => x.classList.remove('active'));
el.classList.add('active');
});
});
document.querySelectorAll('.dma-model').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.dma-model').forEach(x => x.classList.remove('selected'));
el.classList.add('selected');
});
});
document.querySelectorAll('.dma-chip').forEach(el => {
el.addEventListener('click', () => {
// 同组互斥
el.parentElement.querySelectorAll('.dma-chip').forEach(x => x.classList.remove('active'));
el.classList.add('active');
});
});
</script>
<script src="assets/api-bridge.js?v=2026060114"></script>
</body>
</html>
@@ -0,0 +1,657 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>方案 A · v2 · 模特上身图 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
<style>
.app { height: 100vh; overflow: hidden; }
main { display: flex; flex-direction: column; min-height: 0; }
#page-content { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0; }
/* ===== 两栏:左栏(纯商品空间) + 主区 ===== */
.dmb {
flex: 1; min-height: 0;
display: grid;
grid-template-columns: 260px 1fr;
}
/* ── 左栏 · 商品空间 ── */
.dmb-side {
border-right: 1px solid var(--border-faint);
background: var(--surface);
display: flex; flex-direction: column;
min-height: 0;
}
.dmb-side-h {
padding: 14px 16px 10px;
flex-shrink: 0;
}
.dmb-side-h .ti {
font-size: 11px; font-family: var(--font-mono);
color: var(--black-alpha-48); letter-spacing: .08em;
text-transform: uppercase;
margin-bottom: 10px;
}
/* 搜索框 */
.dmb-search {
display: flex; align-items: center; gap: 8px;
height: 32px; padding: 0 10px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
transition: border-color var(--t-base);
}
.dmb-search:focus-within { border-color: var(--heat-40); background: var(--surface); }
.dmb-search svg { width: 13px; height: 13px; color: var(--black-alpha-48); flex-shrink: 0; }
.dmb-search input {
flex: 1; min-width: 0; height: 100%;
border: 0; outline: 0; background: transparent;
font-size: 12.5px; color: var(--accent-black); font-family: inherit;
}
/* 商品列表 */
.dmb-list {
flex: 1; min-height: 0;
overflow-y: auto;
padding: 8px 10px 8px;
display: flex; flex-direction: column; gap: 6px;
}
.dmb-prod {
display: flex; align-items: center; gap: 10px;
padding: 8px;
border: 1px solid transparent;
border-radius: var(--r-sm);
cursor: pointer;
transition: background var(--t-base), border-color var(--t-base);
}
.dmb-prod:hover { background: var(--background-lighter); }
.dmb-prod.active { background: var(--heat-12); border-color: var(--heat-20); }
.dmb-prod .thumb {
flex-shrink: 0;
width: 44px; height: 44px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
overflow: hidden; position: relative;
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 9px;
color: var(--black-alpha-32);
background: repeating-linear-gradient(135deg, transparent 0 4px, rgba(0,0,0,.04) 4px 5px);
}
.dmb-prod.active .thumb { border-color: var(--heat); }
.dmb-prod .body { flex: 1; min-width: 0; }
.dmb-prod .nm {
font-size: 12.5px;
color: var(--accent-black); font-weight: 500;
line-height: 1.3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dmb-prod.active .nm { color: var(--heat); font-weight: 600; }
.dmb-prod .sub {
margin-top: 2px;
font-family: var(--font-mono); font-size: 10px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
/* 底部 · 全部商品入口 */
.dmb-all {
flex-shrink: 0;
margin: 0 10px 12px;
padding: 10px 12px;
background: var(--background-lighter);
border: 1px dashed var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-72);
font-size: 12px;
cursor: pointer;
display: flex; align-items: center; gap: 6px;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.dmb-all:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }
.dmb-all .ct { color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 10.5px; margin-left: auto; }
.dmb-all svg { width: 12px; height: 12px; }
/* ── 主区 ── */
.dmb-main {
display: flex; flex-direction: column;
min-height: 0; overflow: hidden;
position: relative;
}
.dmb-main-h {
flex-shrink: 0;
padding: 16px 28px 12px;
border-bottom: 1px solid var(--border-faint);
background: var(--surface);
}
.dmb-main-h .crumb {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--black-alpha-48); letter-spacing: .04em;
margin-bottom: 4px;
}
.dmb-main-h h2 {
font-size: 22px; font-weight: 600;
letter-spacing: -.015em;
color: var(--accent-black);
}
.dmb-main-h .row {
display: flex; align-items: center; gap: 16px;
margin-top: 6px;
}
.dmb-main-h .stats {
font-family: var(--font-mono); font-size: 11px;
color: var(--black-alpha-48); letter-spacing: .02em;
display: flex; gap: 4px;
}
.dmb-main-h .stats b { color: var(--accent-black); font-weight: 600; }
.dmb-main-h .stats .sep { color: var(--black-alpha-24); }
.dmb-main-h .spacer { flex: 1; }
/* 主区 toolbar (筛选条 · 仿 image-optimize 顶部) */
.dmb-main-tb { display: flex; gap: 8px; align-items: center; }
.dmb-main-tb .icbtn {
width: 30px; height: 30px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-72);
cursor: pointer; display: grid; place-items: center;
}
.dmb-main-tb .icbtn:hover { border-color: var(--heat-20); color: var(--heat); }
.dmb-main-tb .icbtn svg { width: 13px; height: 13px; }
.dmb-main-tb .chip {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-size: 12px; color: var(--black-alpha-72);
font-family: inherit; cursor: pointer;
}
.dmb-main-tb .chip:hover { border-color: var(--heat-20); color: var(--heat); }
.dmb-main-tb .chip svg { width: 10px; height: 10px; opacity: .6; }
/* 主区滚动体 · 任务流 */
.dmb-stream {
flex: 1; min-height: 0;
overflow-y: auto;
padding: 22px 28px 200px; /* 底部留出参数面板高度 */
background: var(--background-base);
}
.dmb-day-h {
display: flex; align-items: baseline; gap: 8px;
margin: 6px 0 10px;
font-family: var(--font-mono); font-size: 10.5px;
color: var(--black-alpha-48); letter-spacing: .06em;
text-transform: uppercase;
}
.dmb-day-h::before {
content: ''; width: 14px; height: 1px;
background: var(--black-alpha-24);
display: inline-block; margin-right: 2px;
}
.dmb-day-h .ct {
color: var(--black-alpha-72); font-weight: 500;
margin-left: auto;
text-transform: none; letter-spacing: 0;
}
/* 批次卡 */
.dmb-batch {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 14px 16px;
margin-bottom: 14px;
}
.dmb-batch-h {
display: flex; align-items: center; gap: 10px;
margin-bottom: 12px;
}
.dmb-batch-h .pic {
flex-shrink: 0;
width: 30px; height: 30px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
display: grid; place-items: center;
color: var(--heat);
font-family: var(--font-mono); font-size: 11px; font-weight: 600;
}
.dmb-batch-h .meta { flex: 1; min-width: 0; }
.dmb-batch-h .nm { font-size: 13.5px; font-weight: 600; color: var(--accent-black); }
.dmb-batch-h .info {
margin-top: 2px;
display: flex; flex-wrap: wrap; gap: 4px;
font-family: var(--font-mono); font-size: 10.5px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
.dmb-batch-h .info .sep { color: var(--black-alpha-24); }
.dmb-batch-h .ops { display: flex; gap: 4px; }
.dmb-batch-h .ops button {
width: 28px; height: 28px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-56); cursor: pointer;
display: grid; place-items: center;
transition: border-color var(--t-base), color var(--t-base);
}
.dmb-batch-h .ops button:hover { border-color: var(--heat-20); color: var(--heat); }
.dmb-batch-h .ops button svg { width: 13px; height: 13px; }
/* 状态 pill 紧贴标题右侧 */
.dmb-batch-h .stat-pill {
margin-left: 8px;
padding: 2px 7px;
font-size: 10px;
}
.dmb-batch-h .stat-pill .dot { width: 4px; height: 4px; }
.dmb-batch-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
}
@media (max-width: 1400px) { .dmb-batch-grid { grid-template-columns: repeat(3, 1fr); } }
.dmb-cell {
aspect-ratio: 3 / 4;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
overflow: hidden; position: relative;
cursor: pointer;
}
.dmb-cell .ph {
position: absolute; inset: 0;
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 10.5px;
color: var(--black-alpha-32);
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
}
.dmb-cell.gen .ph { animation: dmb-pulse 1.4s ease-in-out infinite; }
@keyframes dmb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
.dmb-cell.err { border-color: var(--accent-crimson, #c43d3d); }
.dmb-cell.err .ph { color: var(--accent-crimson, #c43d3d); background: rgba(196,61,61,.05); }
.dmb-cell .tag {
position: absolute; top: 6px; left: 6px;
padding: 2px 6px;
background: rgba(0,0,0,.65); color: #fff;
border-radius: var(--r-sm);
font-size: 10px; font-weight: 500; backdrop-filter: blur(4px);
}
/* ── 底部 fixed 参数面板 ── */
.dmb-param-wrap {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 14px 28px 22px;
background: linear-gradient(to bottom, transparent 0, var(--background-base) 24px);
z-index: 5;
}
.dmb-param {
max-width: 1180px; margin: 0 auto;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: 14px;
padding: 10px 14px;
display: flex; align-items: center; gap: 10px;
box-shadow: 0 6px 24px rgba(0,0,0,.06);
}
.dmb-param .label-mono {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--black-alpha-48); letter-spacing: .04em;
flex-shrink: 0;
}
.dmb-param .pchip {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 12px;
background: var(--background-lighter);
border: 1px solid transparent;
border-radius: var(--r-pill);
font-size: 12px; color: var(--black-alpha-72);
cursor: pointer; font-family: inherit;
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
}
.dmb-param .pchip:hover { background: var(--surface); border-color: var(--border-faint); }
.dmb-param .pchip.active { background: var(--heat-12); color: var(--heat); }
.dmb-param .pchip svg { width: 10px; height: 10px; opacity: .6; }
.dmb-param .pchip .lbl-mono {
font-family: var(--font-mono); font-size: 10px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
.dmb-param .pchip.active .lbl-mono { color: var(--heat); }
.dmb-param .spacer { flex: 1; }
.dmb-param .meta-right {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--black-alpha-48); letter-spacing: .02em;
margin-right: 6px;
text-align: right;
}
.dmb-param .meta-right .v { color: var(--accent-black); font-weight: 600; }
.dmb-param .gen-btn {
height: 38px; padding: 0 20px;
background: var(--heat); color: #fff;
border: 0; border-radius: var(--r-md);
font-size: 13.5px; font-weight: 600; font-family: inherit;
cursor: pointer;
display: inline-flex; align-items: center; gap: 8px;
box-shadow: var(--shadow-cta);
}
.dmb-param .gen-btn svg { width: 14px; height: 14px; }
/* 顶部 demo 提示条 */
.dmb-banner {
margin: 12px 28px 0;
padding: 8px 12px;
background: var(--heat-12);
border: 1px dashed var(--heat-20);
border-radius: var(--r-sm);
font-size: 12px; color: var(--accent-black);
font-family: var(--font-mono); letter-spacing: .02em;
}
.dmb-banner b { color: var(--heat); }
</style>
</head>
<body>
<div id="page">
<div class="dmb-banner">// DEMO v2 · 方案 A · <b>商品空间(A+B) + 任务流主区</b>。左栏只保留商品空间(搜索+最近6条+全部入口),任务列表搬到主区,筛选放主区顶部 toolbar,参数面板底部 fixed 化(类 image-optimize)。</div>
<div class="dmb">
<!-- ===== 左栏 · 商品空间 ===== -->
<aside class="dmb-side">
<div class="dmb-side-h">
<div class="ti">商品空间</div>
<div class="dmb-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input type="text" placeholder="搜索商品 / 分类">
</div>
</div>
<div class="dmb-list">
<div class="dmb-prod active">
<div class="thumb">主图</div>
<div class="body">
<div class="nm">透真补水面膜</div>
<div class="sub">美妆个护 · 6 批</div>
</div>
</div>
<div class="dmb-prod">
<div class="thumb">主图</div>
<div class="body">
<div class="nm">透真清透防晒霜</div>
<div class="sub">美妆个护 · 3 批</div>
</div>
</div>
<div class="dmb-prod">
<div class="thumb">主图</div>
<div class="body">
<div class="nm">南卡 Lite Pro 蓝牙耳机</div>
<div class="sub">数码 3C · 2 批</div>
</div>
</div>
<div class="dmb-prod">
<div class="thumb">主图</div>
<div class="body">
<div class="nm">滋啦速食牛肉面</div>
<div class="sub">食品饮料 · 1 批</div>
</div>
</div>
<div class="dmb-prod">
<div class="thumb">主图</div>
<div class="body">
<div class="nm">三顿半同款冻干咖啡</div>
<div class="sub">食品饮料 · 1 批</div>
</div>
</div>
<div class="dmb-prod">
<div class="thumb">主图</div>
<div class="body">
<div class="nm">小熊 4L 可视空气炸锅</div>
<div class="sub">家居家电 · 0 批</div>
</div>
</div>
</div>
<button class="dmb-all" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M9 4v16"/></svg>
全部商品
<span class="ct">24 个</span>
<svg style="margin-left:4px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</aside>
<!-- ===== 主区 ===== -->
<section class="dmb-main">
<!-- 顶部 标题 + stats + toolbar -->
<div class="dmb-main-h">
<div class="crumb">// 商品空间 · 模特上身图</div>
<h2>透真补水面膜</h2>
<div class="row">
<div class="stats">
<span>美妆个护</span><span class="sep">·</span>
<span>本商品 <b>6</b></span><span class="sep">·</span>
<span>累计 <b>22</b> 张图</span><span class="sep">·</span>
<span>最近 <b>3 分钟前</b></span>
</div>
<div class="spacer"></div>
<div class="dmb-main-tb">
<button class="icbtn" type="button" title="搜索批次">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
</button>
<button class="chip" type="button">时间 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
<button class="chip" type="button">状态 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
<button class="chip" type="button">模特 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
</div>
</div>
</div>
<!-- 任务流 -->
<div class="dmb-stream">
<div class="dmb-day-h">
<span>今天</span>
<span class="ct">3 批 · 10 张</span>
</div>
<!-- 批次 1 -->
<div class="dmb-batch">
<div class="dmb-batch-h">
<div class="pic">4×</div>
<div class="meta">
<div class="nm">Ava × 4 张 <span class="pill ok stat-pill"><span class="dot"></span>已完成</span></div>
<div class="info">
<span>3:4</span><span class="sep">·</span>
<span>3 分钟前</span><span class="sep">·</span>
<span>¥1.20</span>
</div>
</div>
<div class="ops">
<button type="button" title="全部重跑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg></button>
<button type="button" title="全部下载"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg></button>
<button type="button" title="加入资产库"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg></button>
</div>
</div>
<div class="dmb-batch-grid">
<div class="dmb-cell"><div class="ph">Ava · #1</div><span class="tag">3:4</span></div>
<div class="dmb-cell"><div class="ph">Ava · #2</div><span class="tag">3:4</span></div>
<div class="dmb-cell"><div class="ph">Ava · #3</div><span class="tag">3:4</span></div>
<div class="dmb-cell"><div class="ph">Ava · #4</div><span class="tag">3:4</span></div>
</div>
</div>
<!-- 批次 2 -->
<div class="dmb-batch">
<div class="dmb-batch-h">
<div class="pic">4×</div>
<div class="meta">
<div class="nm">Zoe × 4 张 <span class="pill ok stat-pill"><span class="dot"></span>已完成</span></div>
<div class="info">
<span>3:4</span><span class="sep">·</span>
<span>12 分钟前</span><span class="sep">·</span>
<span>¥1.20</span>
</div>
</div>
<div class="ops">
<button type="button" title="全部重跑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg></button>
<button type="button" title="全部下载"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg></button>
<button type="button" title="加入资产库"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg></button>
</div>
</div>
<div class="dmb-batch-grid">
<div class="dmb-cell"><div class="ph">Zoe · #1</div><span class="tag">3:4</span></div>
<div class="dmb-cell"><div class="ph">Zoe · #2</div><span class="tag">3:4</span></div>
<div class="dmb-cell"><div class="ph">Zoe · #3</div><span class="tag">3:4</span></div>
<div class="dmb-cell"><div class="ph">Zoe · #4</div><span class="tag">3:4</span></div>
</div>
</div>
<!-- 批次 3 · 生成中 -->
<div class="dmb-batch">
<div class="dmb-batch-h">
<div class="pic">2×</div>
<div class="meta">
<div class="nm">Ben × 2 张 <span class="pill info stat-pill"><span class="dot"></span>生成中</span></div>
<div class="info">
<span>3:4</span><span class="sep">·</span>
<span>刚刚</span><span class="sep">·</span>
<span>¥0.60</span>
</div>
</div>
<div class="ops">
<button type="button" title="取消"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M6 18L18 6"/></svg></button>
</div>
</div>
<div class="dmb-batch-grid">
<div class="dmb-cell gen"><div class="ph">生成中…</div></div>
<div class="dmb-cell gen"><div class="ph">生成中…</div></div>
</div>
</div>
<!-- 昨天 -->
<div class="dmb-day-h">
<span>昨天</span>
<span class="ct">2 批 · 8 张</span>
</div>
<div class="dmb-batch">
<div class="dmb-batch-h">
<div class="pic">4×</div>
<div class="meta">
<div class="nm">Lin × 4 张 <span class="pill ok stat-pill"><span class="dot"></span>已完成</span></div>
<div class="info">
<span>3:4</span><span class="sep">·</span>
<span>昨天 18:24</span><span class="sep">·</span>
<span>¥1.20</span>
</div>
</div>
<div class="ops">
<button type="button" title="全部重跑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg></button>
<button type="button" title="全部下载"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg></button>
<button type="button" title="加入资产库"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg></button>
</div>
</div>
<div class="dmb-batch-grid">
<div class="dmb-cell"><div class="ph">Lin · #1</div><span class="tag">3:4</span></div>
<div class="dmb-cell"><div class="ph">Lin · #2</div><span class="tag">3:4</span></div>
<div class="dmb-cell"><div class="ph">Lin · #3</div><span class="tag">3:4</span></div>
<div class="dmb-cell"><div class="ph">Lin · #4</div><span class="tag">3:4</span></div>
</div>
</div>
<!-- 更早 -->
<div class="dmb-day-h">
<span>更早</span>
<span class="ct">1 批 · 2 张 · 含 1 失败</span>
</div>
<div class="dmb-batch">
<div class="dmb-batch-h">
<div class="pic">2×</div>
<div class="meta">
<div class="nm">Ava × 2 张 <span class="pill err stat-pill"><span class="dot"></span>失败</span></div>
<div class="info">
<span>3:4</span><span class="sep">·</span>
<span>2 天前</span>
</div>
</div>
<div class="ops">
<button type="button" title="全部重跑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg></button>
<button type="button" title="删除"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg></button>
</div>
</div>
<div class="dmb-batch-grid">
<div class="dmb-cell err"><div class="ph">失败 · 点重跑</div></div>
<div class="dmb-cell err"><div class="ph">失败 · 点重跑</div></div>
</div>
</div>
</div>
<!-- 底部 fixed 参数面板 -->
<div class="dmb-param-wrap">
<div class="dmb-param">
<button class="pchip active" type="button">
<span class="lbl-mono">模特</span>
<span>Ava</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
</button>
<button class="pchip" type="button">
<span class="lbl-mono">张数</span>
<span>4</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
</button>
<button class="pchip" type="button">
<span class="lbl-mono">比例</span>
<span>3:4</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
</button>
<button class="pchip" type="button">
<span class="lbl-mono">补充提示词</span>
<span style="color:var(--black-alpha-48)">+ 添加</span>
</button>
<span class="spacer"></span>
<span class="meta-right">预估 <span class="v">¥1.20</span> · 余额 <span class="v">¥327.40</span></span>
<button class="gen-btn" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z"/></svg>
生成 · 透真补水面膜 × Ava
</button>
</div>
</div>
</section>
</div>
</div>
<script src="assets/icons.js?v=2026052608"></script>
<script src="assets/shell.js?v=2026060101"></script>
<script>
Shell.render({
active: 'asset-factory',
crumbs: [
{ label: '工作台', href: 'index.html' },
{ label: '图片生成', href: 'asset-factory.html' },
{ label: '模特上身图 · 方案 A · v2' }
]
});
// 商品 / chip 切换
document.querySelectorAll('.dmb-prod').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.dmb-prod').forEach(x => x.classList.remove('active'));
el.classList.add('active');
});
});
document.querySelectorAll('.dmb-param .pchip').forEach(el => {
el.addEventListener('click', () => {
// demo:只切自己 active(不互斥,每个 chip 都可独立切下拉)
el.classList.toggle('active');
});
});
</script>
<script src="assets/api-bridge.js?v=2026060114"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
server_tokens off;
charset utf-8;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# 设计稿迭代期:CSS/JS 不带 hash,不能用 immutable,否则改了浏览器仍吃旧版
# 缓存 1 小时 + must-revalidate(过期后用 If-Modified-Since 回源验证,服务器没改返回 304,改了取新文件)
location ~* \.(css|js)$ {
expires 1h;
add_header Cache-Control "public, must-revalidate";
}
# 图片/字体/媒体:可以缓存稍长(改动频率低),仍允许重新验证
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|mp4|webm)$ {
expires 7d;
add_header Cache-Control "public, must-revalidate";
}
# HTML:不进浏览器磁盘缓存,每次都问服务器有没有新版本
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate" always;
expires off;
}
# Multi-page (not SPA): try exact file, then .html fallback (friendly URLs), then 404.
# NEVER fallback to index.html that masks 404s for a multi-entry design shelf.
location / {
try_files $uri $uri/ $uri.html =404;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>新建商品 · Airshelf</title>
<script>
// 已废弃 · 新建商品改为 products.html 上的居中弹窗(Shell.openNewProduct)
// 直接访问此 URL 时,跳回商品库并自动打开弹窗
sessionStorage.setItem('auto-open-new-product', '1');
location.replace('products.html');
</script>
</head>
<body><script src="assets/api-bridge.js?v=2026060114"></script>
</body>
</html>
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>新建商品 · Airshelf</title>
<meta http-equiv="Cache-Control" content="no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<!-- 双保险:JS 没跑也能跳走 (1.5s 后强制回 products.html) -->
<meta http-equiv="refresh" content="1.5; url=products.html?npd=1">
<style>
html,body{background:#f9f9f9;margin:0;height:100%;font-family:'Inter',system-ui,sans-serif;}
.stub-msg{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;color:#999;font-size:13px;}
.stub-msg .ttl{font-size:14px;color:#262626;font-weight:500;}
.stub-msg a{color:#fa5d19;text-decoration:underline;}
</style>
</head>
<body>
<div class="stub-msg">
<div class="ttl">正在打开「新建商品」…</div>
<div>如未自动跳转,<a href="products.html?npd=1">点击这里</a></div>
</div>
<script>
/* ============================================================
新建商品 · 重定向 stub
----------------------------------------------------------
旧版本是一个 3883 行的全屏 drawer 页(legacy 已归档到 _archive/deprecated-pages-20260528/)。
现在「新建商品」改为 drawer 形态在原页面弹出,直接访问此 URL 没有意义。
行为:
1. 设置 sessionStorage 标志,通知落地页自动打开 drawer
2. 优先回退到 referrer(用户来源页),否则去 products.html
3. cache-bust 参数 ?npd=1 强制浏览器重新拉新版宿主页
============================================================ */
(function () {
try { sessionStorage.setItem('npd-auto-open', '1'); } catch (e) {}
try { sessionStorage.setItem('auto-open-new-product', '1'); } catch (e) {}
const ref = document.referrer || '';
const here = location.origin + location.pathname;
// 同源 referrer 且不是自身才回跳, 否则去 products.html
let target = 'products.html';
if (ref && ref.indexOf(location.origin) === 0 && ref.split('#')[0].split('?')[0] !== here) {
target = ref;
}
// 加 cache-bust query · 强制浏览器拉新版宿主页,避免再次命中缓存旧版
const sep = target.indexOf('?') >= 0 ? '&' : '?';
target = target + sep + 'npd=' + Date.now().toString(36);
location.replace(target);
})();
</script>
<script src="assets/api-bridge.js?v=2026060114"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+248
View File
@@ -0,0 +1,248 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>注册团队 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
<style>
body { margin: 0; min-height: 100vh; background: var(--background-base); display: grid; place-items: center; padding: 32px 24px; }
.auth-wrap { width: 100%; max-width: 980px; display: grid; grid-template-columns: minmax(0, 1fr) 460px; gap: 0; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); position: relative; overflow: hidden; min-height: 620px; }
/* 4 装订线 */
.auth-wrap::before, .auth-wrap::after,
.auth-wrap > .corner-tr, .auth-wrap > .corner-bl {
content: ''; position: absolute; width: 14px; height: 14px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center;
background-size: contain; pointer-events: none; z-index: 2;
}
.auth-wrap::before { top: -7px; left: -7px; }
.auth-wrap::after { bottom: -7px; right: -7px; }
.auth-wrap > .corner-tr { top: -7px; right: -7px; }
.auth-wrap > .corner-bl { bottom: -7px; left: -7px; }
/* 左:品牌 + 价值点 */
.auth-brand { background: var(--accent-black); color: var(--accent-white); padding: 40px 44px; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.auth-brand .logo { display: flex; align-items: center; height: 56px; }
.auth-brand .logo-img { display: block; width: 196px; height: auto; margin: -14px 0 -10px -14px; object-fit: contain; }
.auth-brand .tag { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.5); letter-spacing: .08em; text-transform: uppercase; margin-top: 4px; }
.auth-brand .hero { margin-top: 28px; }
.auth-brand .hero h1 { font-size: 28px; font-weight: 700; letter-spacing: -.02em; line-height: 1.25; margin: 0 0 14px; }
.auth-brand .hero h1 .h { color: var(--heat); }
.auth-brand .hero p { font-size: 13.5px; color: rgba(255,255,255,.62); line-height: 1.7; max-width: 320px; margin: 0; }
/* 价值点列表 */
.val-list { margin: 30px 0 0; display: flex; flex-direction: column; gap: 14px; }
.val-item { display: grid; grid-template-columns: 24px minmax(0,1fr); gap: 12px; align-items: start; }
.val-item .ic-v { width: 22px; height: 22px; border: 1px solid rgba(255,255,255,.18); border-radius: var(--r-sm); display: grid; place-items: center; color: var(--heat); }
.val-item .ic-v svg { width: 12px; height: 12px; }
.val-item .txt-v { font-size: 12.5px; color: rgba(255,255,255,.78); line-height: 1.5; }
.val-item .txt-v b { color: var(--accent-white); font-weight: 600; }
.auth-brand .foot { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.32); letter-spacing: .04em; margin-top: auto; padding-top: 24px; display: flex; gap: 14px; }
.auth-brand .foot a { color: rgba(255,255,255,.5); text-decoration: none; }
.auth-brand .foot a:hover { color: var(--heat); }
/* 右:表单 */
.auth-form { padding: 40px 44px 32px; display: flex; flex-direction: column; }
.auth-form .h-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
.auth-form h2 { font-size: 22px; font-weight: 600; letter-spacing: -.012em; margin: 0; }
.auth-form .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; }
.auth-form .lead { font-size: 13px; color: var(--black-alpha-56); margin: 0 0 22px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.field-label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; }
.field-label .req { color: var(--accent-crimson); }
.field-label .hint { margin-left: auto; font-size: 10.5px; color: var(--black-alpha-32); }
.field-input-wrap { position: relative; }
.field-input-wrap .ic-l { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--black-alpha-32); width: 14px; height: 14px; pointer-events: none; }
.field input { width: 100%; box-sizing: border-box; padding: 11px 12px 11px 36px; font-size: 13.5px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); font-family: inherit; color: var(--accent-black); transition: border-color var(--t-base), box-shadow var(--t-base); }
.field input:focus { outline: none; border-color: var(--heat); box-shadow: 0 0 0 3px var(--heat-12); }
.field input::placeholder { color: var(--black-alpha-32); }
.field .toggle-pwd { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: var(--black-alpha-48); cursor: pointer; background: none; border: 0; padding: 0; display: grid; place-items: center; }
.field .toggle-pwd:hover { color: var(--accent-black); }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
.field-row .field { margin-bottom: 0; }
.agree { display: flex; align-items: flex-start; gap: 8px; font-size: 12px; color: var(--black-alpha-56); line-height: 1.6; margin: 8px 0 18px; cursor: pointer; user-select: none; }
.agree input { margin-top: 3px; width: 13px; height: 13px; accent-color: var(--heat); flex-shrink: 0; }
.agree a { color: var(--heat); text-decoration: none; }
.agree a:hover { text-decoration: underline; }
.btn-cta { background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-md); padding: 12px 14px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; transition: box-shadow var(--t-base), transform var(--t-base); display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
.btn-cta:hover { box-shadow: 0 4px 14px rgba(250,93,25,.28); }
.btn-cta:active { transform: translateY(1px); }
.btn-cta:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
.btn-cta svg { width: 15px; height: 15px; }
.switch-row { margin-top: 22px; text-align: center; font-size: 12.5px; color: var(--black-alpha-56); }
.switch-row a { color: var(--heat); text-decoration: none; font-weight: 500; }
.switch-row a:hover { text-decoration: underline; }
@media (max-width: 820px) {
.auth-wrap { grid-template-columns: 1fr; min-height: 0; }
.auth-brand { padding: 32px 28px; }
.auth-brand .val-list { display: none; }
.auth-form { padding: 32px 28px; }
}
.top-back { position: fixed; top: 20px; left: 24px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); text-decoration: none; letter-spacing: .04em; }
.top-back:hover { color: var(--heat); }
</style>
</head>
<body>
<a class="top-back" href="login.html">← 返回登录</a>
<div class="auth-wrap">
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
<aside class="auth-brand">
<div class="logo">
<img class="logo-img" src="assets/logo-dark.png" alt="Airshelf">
</div>
<div class="tag">// SHORT-VIDEO COMMERCE PLATFORM</div>
<div class="hero">
<h1>开通团队,<br>开始 <span class="h">AI 带货</span> 第一条短剧</h1>
<p>个人 / 企业团队 1-2 小时出成片,失败不扣费,确认后扣。</p>
</div>
<div class="val-list">
<div class="val-item">
<span class="ic-v"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>
<span class="txt-v"><b>5 阶段流水线</b> · 脚本 → 资产 → 故事板 → 视频片段 → 拼接导出</span>
</div>
<div class="val-item">
<span class="ic-v"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>
<span class="txt-v"><b>失败不扣费</b> · 任务仅在用户通过时扣费,失败 / 超时 / 重跑一律不扣</span>
</div>
<div class="val-item">
<span class="ic-v"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>
<span class="txt-v"><b>团队 + 角色 + 四层额度</b> · 超管 / 团管 / 成员,日 / 月 + 团队 / 总四层防超支</span>
</div>
<div class="val-item">
<span class="ic-v"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>
<span class="txt-v"><b>跨项目资产库</b> · 主播 / 场景 / 商品图沉淀复用,不重复生成</span>
</div>
</div>
<div class="foot">
<a href="#">关于</a>
<a href="#">定价</a>
<a href="#">联系</a>
<a href="#">隐私</a>
</div>
</aside>
<main class="auth-form">
<div class="h-row">
<h2>注册团队</h2>
<span class="sub">// /auth/register</span>
</div>
<p class="lead">填写团队信息开通账户,默认成为团队超管。</p>
<form id="register-form" autocomplete="off" onsubmit="event.preventDefault(); doRegister();">
<div class="field">
<label class="field-label" for="reg-team">团队名 <span class="req">*</span></label>
<div class="field-input-wrap">
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/></svg>
<input type="text" id="reg-team" placeholder="例: 小李的店 / XX 文化传媒" required>
</div>
</div>
<div class="field">
<label class="field-label" for="reg-email">超管邮箱 <span class="req">*</span><span class="hint">用于成员邀请 + 找回密码</span></label>
<div class="field-input-wrap">
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><path d="m22 6-10 7L2 6"/></svg>
<input type="email" id="reg-email" placeholder="name@company.com" required>
</div>
</div>
<div class="field-row">
<div class="field">
<label class="field-label" for="reg-pwd">密码 <span class="req">*</span></label>
<div class="field-input-wrap">
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input type="password" id="reg-pwd" placeholder="至少 8 位" required>
<button type="button" class="toggle-pwd" onclick="togglePwd('reg-pwd')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
<div class="field">
<label class="field-label" for="reg-pwd2">确认密码 <span class="req">*</span></label>
<div class="field-input-wrap">
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input type="password" id="reg-pwd2" placeholder="再输一次" required>
<button type="button" class="toggle-pwd" onclick="togglePwd('reg-pwd2')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
</div>
<div class="field">
<label class="field-label" for="reg-invite">邀请码 <span class="hint">可选 · 团队邀请才需要</span></label>
<div class="field-input-wrap">
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
<input type="text" id="reg-invite" placeholder="例: TEAM-XXXX-XXXX">
</div>
</div>
<label class="agree">
<input type="checkbox" id="reg-agree" checked>
<span>我已阅读并同意 <a href="#" onclick="event.preventDefault();_regToast('用户协议','含数据处理 / 内容生成版权 / 计费规则三章 · 完整文本将在正式版接入');">用户协议</a><a href="#" onclick="event.preventDefault();_regToast('隐私政策','遵循《个人信息保护法》· 团队数据存中国境内 · 默认不用于模型训练');">隐私政策</a>,知悉「失败不扣费 · 确认后扣」的扣费规则。</span>
</label>
<button class="btn-cta" type="submit" id="reg-submit">
创建团队 · 开始使用
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
<div class="switch-row">
已有账号? <a href="login.html">登录 →</a>
</div>
</form>
</main>
</div>
<script>
function togglePwd(id) {
const inp = document.getElementById(id);
inp.type = inp.type === 'password' ? 'text' : 'password';
}
function doRegister() {
const team = document.getElementById('reg-team').value.trim();
const email = document.getElementById('reg-email').value.trim();
const p1 = document.getElementById('reg-pwd').value;
const p2 = document.getElementById('reg-pwd2').value;
const agree = document.getElementById('reg-agree').checked;
if (!team || !email) return alert('请补全团队名 + 邮箱');
if (p1.length < 8) return alert('密码至少 8 ');
if (p1 !== p2) return alert('两次密码不一致');
if (!agree) return alert('请同意用户协议');
const btn = document.getElementById('reg-submit');
btn.innerHTML = '<span style="font-family:var(--font-mono);font-size:12px;letter-spacing:.04em;">// 创建团队中...</span>';
btn.disabled = true;
setTimeout(() => { location.href = 'index.html'; }, 900);
}
// 轻量 toast(不依赖 shell.js)
function _regToast(t, sub) {
let el = document.getElementById('__reg-toast');
if (!el) {
el = document.createElement('div');
el.id = '__reg-toast';
el.style.cssText = 'position:fixed;left:50%;bottom:36px;transform:translateX(-50%) translateY(20px);background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:12px 18px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:flex;flex-direction:column;gap:2px;opacity:0;transition:opacity .2s,transform .2s;z-index:9999;font-family:inherit;max-width:380px;';
document.body.appendChild(el);
}
el.innerHTML = '<div style="font-size:13.5px;font-weight:600;color:#262626;">' + t + '</div>' + (sub ? '<div style="font-size:11.5px;color:rgba(0,0,0,.56);font-family:\'Inter\',system-ui,sans-serif;letter-spacing:.02em;">// ' + sub + '</div>' : '');
requestAnimationFrame(() => { el.style.opacity = '1'; el.style.transform = 'translateX(-50%) translateY(0)'; });
clearTimeout(el._t);
el._t = setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateX(-50%) translateY(20px)'; }, 3000);
}
</script>
<script src="assets/api-bridge.js?v=2026060114"></script>
</body>
</html>
+855
View File
@@ -0,0 +1,855 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>设置 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
<style>
/* ─── 设置布局:左 nav + 右 panel ─── */
.settings-grid { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 24px; align-items: start; }
.settings-nav { position: sticky; top: 16px; }
.settings-nav .nav-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; padding: 0 12px 8px; }
.settings-nav :where(a, button) { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; font: inherit; font-size: 13px; color: var(--accent-black); border-radius: var(--r-md); border: 1px solid transparent; background: transparent; cursor: pointer; text-decoration: none; text-align: left; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); position: relative; }
.settings-nav :where(a, button):hover { background: var(--background-lighter); }
.settings-nav :where(a, button):focus-visible { outline: 2px solid var(--heat); outline-offset: 2px; }
.settings-nav a.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
.settings-nav :where(a, button) svg { width: 16px; height: 16px; stroke-width: 1.5; flex: 0 0 auto; }
.settings-nav a .nav-badge { margin-left: auto; font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); padding: 1px 6px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-pill); letter-spacing: .02em; line-height: 14px; }
.settings-nav a.active .nav-badge { color: var(--heat); background: var(--accent-white); border-color: var(--heat-20); }
.settings-nav a .nav-dot { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--heat); display: none; }
.settings-nav a.has-changes .nav-dot { display: block; }
.settings-nav a.active .nav-dot { right: -4px; }
.settings-nav .logout-pill {
width: calc(100% - 24px);
height: 38px;
margin: 4px 12px 0;
justify-content: center;
border-radius: var(--r-pill);
background: var(--accent-black);
border-color: var(--accent-black);
color: var(--accent-white);
font-weight: 500;
}
.settings-nav .logout-pill:hover,
.settings-nav .logout-pill:focus-visible {
background: var(--black-alpha-88);
border-color: var(--black-alpha-88);
color: var(--accent-white);
}
/* ─── pane ─── */
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 24px; margin-bottom: 16px; }
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.pane .pane-desc { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; margin-bottom: 18px; }
/* ─── form row ─── */
.form-row { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--border-faint); align-items: center; }
.form-row:last-child { border-bottom: 0; }
.form-row .lbl { font-size: 12.5px; color: var(--black-alpha-56); }
.form-row .lbl .req { color: var(--accent-crimson); margin-left: 2px; }
.form-row .lbl-sub { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }
.form-row .val { display: flex; align-items: center; gap: 10px; min-width: 0; }
.form-row .val .input, .form-row .val .select { width: 100%; max-width: 380px; }
.form-row .val .static { font-size: 13px; color: var(--accent-black); font-variant-numeric: tabular-nums; }
.form-row .val .static.mono { font-family: var(--font-mono); font-size: 12.5px; color: var(--black-alpha-56); }
.form-row .val .role-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 11px; font-weight: 500; background: var(--heat-12); color: var(--heat); }
.form-row .val .role-tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--heat); }
/* ─── 头像上传 ─── */
.avatar-edit { display: flex; align-items: center; gap: 16px; }
.avatar-edit .av-big { width: 64px; height: 64px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 24px; font-weight: 600; color: var(--accent-black); }
.avatar-edit .av-actions { display: flex; gap: 8px; }
/* ─── toggle switch ─── */
.switch { position: relative; width: 36px; height: 20px; flex: 0 0 36px; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch .slider { position: absolute; inset: 0; background: var(--black-alpha-24); border-radius: 20px; cursor: pointer; transition: background var(--t-base); }
.switch .slider::before { content: ''; position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; background: var(--accent-white); border-radius: 50%; transition: transform var(--t-base); }
.switch input:checked + .slider { background: var(--heat); }
.switch input:checked + .slider::before { transform: translateX(16px); }
/* ─── 偏好选项卡 ─── */
.pref-choices { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; max-width: 540px; }
.pref-choice { padding: 10px 12px; border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: all var(--t-base); }
.pref-choice:hover { background: var(--background-lighter); }
.pref-choice.selected { border-color: var(--heat); background: var(--heat-12); }
.pref-choice .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); }
.pref-choice .d { font-size: 11px; color: var(--black-alpha-48); margin-top: 2px; font-family: var(--font-mono); letter-spacing: .02em; }
.pref-choice.selected .t { color: var(--heat); }
/* ─── 时长档 ─── */
.duration-row { display: flex; gap: 8px; }
.dur-chip { padding: 6px 14px; border: 1px solid var(--border-faint); border-radius: var(--r-md); font-size: 13px; cursor: pointer; font-family: var(--font-mono); font-variant-numeric: tabular-nums; transition: all var(--t-base); background: var(--surface); }
.dur-chip:hover { background: var(--background-lighter); }
.dur-chip.selected { border-color: var(--heat); background: var(--heat-12); color: var(--heat); font-weight: 600; }
/* ─── 设备列表 ─── */
.device-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border-faint); }
.device-row:last-child { border-bottom: 0; }
.device-row .ic { width: 36px; height: 36px; border-radius: var(--r-md); background: var(--background-lighter); display: grid; place-items: center; color: var(--black-alpha-56); }
.device-row .ic svg { width: 18px; height: 18px; }
.device-row .nm { font-size: 13px; font-weight: 500; }
.device-row .meta { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }
.device-row .tag-cur { font-family: var(--font-mono); font-size: 10.5px; padding: 1px 6px; background: var(--accent-forest); color: var(--accent-white); border-radius: var(--r-sm); margin-left: 8px; letter-spacing: .04em; font-weight: 600; }
.device-row .spacer { margin-left: auto; }
/* ─── 头像上传 modal · V2.1 Restraint ─── */
.av-up-modal { width: min(440px, 92vw); max-width: min(440px, 92vw); }
/* 预览区:左圆头像 + 右 mono 元数据 · 装订线点划线分隔 */
.av-up-preview-row { display: flex; align-items: center; gap: 14px; padding-bottom: 14px; margin-bottom: 14px; position: relative; }
.av-up-preview-row::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: repeating-linear-gradient(to right, var(--border-faint) 0, var(--border-faint) 4px, transparent 4px, transparent 8px); }
.av-up-preview { width: 64px; height: 64px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 22px; font-weight: 600; color: var(--accent-black); overflow: hidden; flex: 0 0 64px; }
.av-up-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.av-up-preview-meta { min-width: 0; }
.av-up-preview-meta .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); margin-bottom: 3px; letter-spacing: .01em; }
.av-up-preview-meta .d { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.55; }
/* 规则文本 · 纯 mono, 装订线分隔 */
.av-up-rules { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--border-faint); font-size: 11px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.7; }
.av-up-rules .li { display: flex; gap: 8px; }
.av-up-rules .li::before { content: '//'; color: var(--black-alpha-32); flex: 0 0 auto; }
.logout-confirm-modal { width: min(440px, 92vw); max-width: min(440px, 92vw); }
.logout-confirm-copy { margin: 0 0 12px; color: var(--black-alpha-72); }
.logout-confirm-points {
display: grid;
gap: 8px;
padding: 12px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
}
.logout-confirm-points .li {
display: flex;
gap: 8px;
font-size: 12.5px;
line-height: 1.55;
color: var(--black-alpha-64);
}
.logout-confirm-points .li::before {
content: '//';
flex: 0 0 auto;
font-family: var(--font-mono);
color: var(--black-alpha-32);
}
.logout-unsaved-note {
margin-top: 12px;
padding: 9px 11px;
border: 1px solid var(--heat-20);
border-radius: var(--r-md);
background: var(--heat-12);
color: var(--heat);
font-size: 12.5px;
line-height: 1.6;
}
</style>
</head>
<body>
<div id="page">
<div class="page-head">
<div>
<h1>设置</h1>
<div class="sub"><span class="mono">// 个人信息 · 偏好 · 通知 · 安全</span></div>
</div>
<div class="actions">
<button class="btn" id="save-cancel" disabled style="opacity:.5; cursor:not-allowed;">取消</button>
<button class="btn btn-primary" id="save-btn" disabled style="opacity:.5; cursor:not-allowed;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>
保存所有变更
</button>
</div>
</div>
<div class="settings-grid">
<!-- 左侧 nav -->
<aside class="settings-nav" role="tablist" aria-label="设置分区">
<div class="nav-h">个人</div>
<a href="#sec-profile" class="active" data-jump="sec-profile" role="tab" aria-controls="sec-profile">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="7.5" r="3.5"/><path d="M5 21a7 7 0 0 1 14 0"/></svg>
<span>个人信息</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<a href="#sec-security" data-jump="sec-security" role="tab" aria-controls="sec-security">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></svg>
<span>安全</span>
<span class="nav-badge" data-count-source="sec-security">3</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<a href="#sec-notify" data-jump="sec-notify" role="tab" aria-controls="sec-notify">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></svg>
<span>通知</span>
<span class="nav-badge" data-count-source="sec-notify">5</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<div class="nav-h" style="margin-top: 16px;">偏好</div>
<a href="#sec-pref" data-jump="sec-pref" role="tab" aria-controls="sec-pref">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 4h-7"/><path d="M10 4H3"/><path d="M21 12h-9"/><path d="M8 12H3"/><path d="M21 20h-5"/><path d="M12 20H3"/><path d="M14 2v4"/><path d="M8 10v4"/><path d="M16 18v4"/></svg>
<span>创作默认</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<a href="#sec-display" data-jump="sec-display" role="tab" aria-controls="sec-display">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
<span>显示</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<div class="nav-h" style="margin-top: 16px;">账号</div>
<button class="logout-pill" type="button" id="settings-logout-btn" onclick="logoutCurrentDevice()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
<span>退出登录</span>
</button>
</aside>
<!-- 右侧内容 -->
<main>
<!-- ─── 个人信息 ─── -->
<section class="pane" id="sec-profile">
<h3>个人信息</h3>
<div class="pane-desc">// 头像、姓名、联系方式 · 邮箱用于接收通知</div>
<div class="form-row">
<div class="lbl">头像</div>
<div class="val">
<div class="avatar-edit">
<div class="av-big" id="prof-avatar-preview"></div>
<div class="av-actions">
<button class="btn btn-sm" type="button" onclick="Shell.openModal('avatar-up-bg')">上传新头像</button>
<button class="btn btn-ghost btn-sm" type="button" id="prof-avatar-reset">恢复默认</button>
</div>
</div>
</div>
</div>
<div class="form-row">
<div class="lbl">显示名称<span class="req">*</span></div>
<div class="val"><input class="input" id="prof-name" value="小李" data-track></div>
</div>
<div class="form-row">
<div class="lbl">登录邮箱</div>
<div class="val">
<input class="input" id="prof-email" value="li@shop.com" data-track>
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('已发送验证邮件', 'li@shop.com')">验证</button>
</div>
</div>
<div class="form-row">
<div class="lbl">手机号</div>
<div class="val">
<input class="input" id="prof-phone" value="138****8000" data-track>
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('已发送短信验证码', '+86 138****8000')">更换</button>
</div>
</div>
<div class="form-row">
<div class="lbl">所属团队<div class="lbl-sub">// 一人一团队</div></div>
<div class="val">
<span class="static">小李的店</span>
<span class="role-tag"><span class="dot"></span>超管 · 创建者</span>
<a href="team.html" style="font-size: 12px; color: var(--heat); text-decoration: none; margin-left: auto;">管理团队 →</a>
</div>
</div>
<div class="form-row">
<div class="lbl">用户 ID<div class="lbl-sub">// 不可改</div></div>
<div class="val"><span class="static mono">USR-2026-A8F2-001</span></div>
</div>
</section>
<!-- ─── 安全 ─── -->
<section class="pane" id="sec-security">
<h3>安全</h3>
<div class="pane-desc">// 登录密码、双因素、在用设备</div>
<div class="form-row">
<div class="lbl">登录密码</div>
<div class="val">
<span class="static mono">●●●●●●●●●●</span>
<span class="muted-2 mono" style="font-size: 11px; margin-left: auto;">上次修改 2026-04-12</span>
<button class="btn btn-sm" onclick="Shell.toast('修改密码', '/settings/password')" style="margin-left: 10px;">修改</button>
</div>
</div>
<div class="form-row">
<div class="lbl">两步验证<div class="lbl-sub">// 推荐开启</div></div>
<div class="val">
<label class="switch"><input type="checkbox" id="opt-2fa"><span class="slider"></span></label>
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48);">短信 + Authenticator</span>
</div>
</div>
<h3 style="margin-top: 24px;">在用设备</h3>
<div class="pane-desc">// 不在此列表上的设备登录会触发短信告警</div>
<div>
<div class="device-row">
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="14" rx="2"/><path d="M2 20h20"/></svg></div>
<div>
<div class="nm">MacBook Pro · Chrome<span class="tag-cur">CURRENT</span></div>
<div class="meta">// 上海 · 2026-05-21 14:08 · IP 116.xxx.xxx.42</div>
</div>
<div class="spacer"></div>
<span class="muted-2 mono" style="font-size: 11px;">当前会话</span>
</div>
<div class="device-row">
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M11 18h2"/></svg></div>
<div>
<div class="nm">iPhone 15 · Safari</div>
<div class="meta">// 上海 · 2026-05-20 21:43</div>
</div>
<div class="spacer"></div>
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('已下线', 'iPhone 15')">下线</button>
</div>
<div class="device-row">
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="14" rx="2"/><path d="M2 20h20"/></svg></div>
<div>
<div class="nm">Windows · Edge</div>
<div class="meta">// 杭州 · 2026-05-18 09:12</div>
</div>
<div class="spacer"></div>
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('已下线', 'Windows Edge')">下线</button>
</div>
</div>
<div style="margin-top: 14px;">
<button class="btn" onclick="if(confirm('下线所有其他设备?')) Shell.toast('已下线其他设备', '2 个')">下线所有其他设备</button>
</div>
</section>
<!-- ─── 通知 ─── -->
<section class="pane" id="sec-notify">
<h3>通知</h3>
<div class="pane-desc">// 邮件、短信、站内提示开关</div>
<div class="form-row">
<div class="lbl">项目完成通知<div class="lbl-sub">// 视频导出后</div></div>
<div class="val">
<label class="switch"><input type="checkbox" id="n-export" checked><span class="slider"></span></label>
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48);">站内 · 邮件 · 短信</span>
</div>
</div>
<div class="form-row">
<div class="lbl">任务失败告警</div>
<div class="val">
<label class="switch"><input type="checkbox" id="n-fail" checked><span class="slider"></span></label>
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48);">站内 · 邮件</span>
</div>
</div>
<div class="form-row">
<div class="lbl">额度不足提醒<div class="lbl-sub">// 团队或个人剩余 < 20%</div></div>
<div class="val">
<label class="switch"><input type="checkbox" id="n-quota" checked><span class="slider"></span></label>
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48);">站内 · 短信</span>
</div>
</div>
<div class="form-row">
<div class="lbl">异地登录告警</div>
<div class="val">
<label class="switch"><input type="checkbox" id="n-login" checked><span class="slider"></span></label>
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48);">短信</span>
</div>
</div>
</section>
<!-- ─── 创作默认 ─── -->
<section class="pane" id="sec-pref">
<h3>创作默认</h3>
<div class="pane-desc">// 新建项目时的预填值,可在向导中改</div>
<div class="form-row" style="grid-template-columns: 160px 1fr; align-items: flex-start;">
<div class="lbl" style="padding-top: 4px;">默认模板</div>
<div class="val" style="display: block;">
<div class="pref-choices" id="pref-template">
<div class="pref-choice selected" data-v="pain"><div class="t">痛点种草</div><div class="d">// 30s 默认档</div></div>
<div class="pref-choice" data-v="unbox"><div class="t">开箱测评</div><div class="d">// 45s 默认档</div></div>
<div class="pref-choice" data-v="compare"><div class="t">对比展示</div><div class="d">// 45s 默认档</div></div>
<div class="pref-choice" data-v="howto"><div class="t">教程演示</div><div class="d">// 60s 默认档</div></div>
<div class="pref-choice" data-v="drama"><div class="t">剧情带货</div><div class="d">// 60s 默认档</div></div>
</div>
</div>
</div>
<div class="form-row">
<div class="lbl">默认时长档</div>
<div class="val">
<div class="duration-row" id="pref-duration">
<span class="dur-chip" data-v="30">30s</span>
<span class="dur-chip" data-v="45">45s</span>
<span class="dur-chip selected" data-v="60">60s</span>
</div>
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48); margin-left: 10px;">// 60s = 4 段 × 15s</span>
</div>
</div>
<div class="form-row" style="grid-template-columns: 160px 1fr; align-items: flex-start;">
<div class="lbl" style="padding-top: 4px;">默认字幕样式</div>
<div class="val" style="display: block;">
<div class="pref-choices" id="pref-subtitle">
<div class="pref-choice selected" data-v="big-variety"><div class="t">大字综艺</div><div class="d">// 抖音热门</div></div>
<div class="pref-choice" data-v="clean-ec"><div class="t">简洁电商</div><div class="d">// 信息清晰</div></div>
<div class="pref-choice" data-v="premium"><div class="t">高级排版</div><div class="d">// 居中衬线</div></div>
<div class="pref-choice" data-v="bullet"><div class="t">弹幕轻量</div><div class="d">// 滚动出现</div></div>
<div class="pref-choice" data-v="emphasis"><div class="t">强调爆款</div><div class="d">// 高对比</div></div>
</div>
</div>
</div>
<div class="form-row">
<div class="lbl">默认 BGM 库</div>
<div class="val">
<select class="select" id="pref-bgm" data-track>
<option value="kapian">抖音 Top10 卡点曲库</option>
<option value="emotion">情绪向 · 治愈/悬念</option>
<option value="urban">都市电子 · 通勤场景</option>
<option value="none">无 BGM</option>
</select>
</div>
</div>
<div class="form-row">
<div class="lbl">默认转场</div>
<div class="val">
<select class="select" id="pref-transition" data-track>
<option>无转场</option>
<option selected>淡入淡出 · 0.3s</option>
<option>滑动 · 0.3s</option>
<option>缩放 · 0.3s</option>
</select>
</div>
</div>
<div class="form-row">
<div class="lbl">导出水印<div class="lbl-sub">// VIP 可关闭</div></div>
<div class="val">
<label class="switch"><input type="checkbox" id="opt-watermark" checked disabled><span class="slider"></span></label>
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48);">右下角 · Airshelf</span>
<a href="account.html" style="font-size: 12px; color: var(--heat); text-decoration: none; margin-left: auto;">升级 VIP →</a>
</div>
</div>
</section>
<!-- ─── 显示 ─── -->
<section class="pane" id="sec-display">
<h3>显示</h3>
<div class="pane-desc">// 界面外观与语言</div>
<div class="form-row">
<div class="lbl">外观</div>
<div class="val">
<select class="select" id="pref-theme" data-track>
<option selected>跟随系统</option>
<option>浅色</option>
<option disabled>深色(V2)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="lbl">语言</div>
<div class="val">
<select class="select" id="pref-lang" data-track>
<option selected>简体中文</option>
<option disabled>English(V2)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="lbl">表格密度</div>
<div class="val">
<select class="select" id="pref-density" data-track>
<option>紧凑</option>
<option selected>标准</option>
<option>宽松</option>
</select>
</div>
</div>
</section>
<div style="text-align: center; padding: 24px 0 8px; color: var(--black-alpha-32); font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;">
// Airshelf · v2.1 · build 20260521
</div>
</main>
</div>
<!-- ─── 退出登录确认 modal ─── -->
<div class="modal-bg" id="logout-confirm-bg" onclick="if(event.target===this)Shell.closeModal('logout-confirm-bg')">
<div class="modal logout-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="logout-confirm-title" aria-describedby="logout-confirm-desc">
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
<div class="modal-h">
<div class="ic-m">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
</div>
<div class="ti" id="logout-confirm-title">退出当前账号<span>// LOG OUT CURRENT SESSION</span></div>
</div>
<div class="modal-b" id="logout-confirm-desc">
<p class="logout-confirm-copy">确认后将退出当前设备上的 Airshelf,再次使用需要重新登录。</p>
<div class="logout-confirm-points">
<div class="li">项目、资产、团队成员与余额数据都会保留</div>
<div class="li">仅影响当前浏览器会话,不会下线其他设备</div>
</div>
<div class="logout-unsaved-note" id="logout-unsaved-note" hidden>当前有未保存的设置变更,退出后这些变更不会保存。</div>
</div>
<div class="modal-f">
<button class="btn" type="button" id="logout-confirm-cancel" onclick="Shell.closeModal('logout-confirm-bg')">取消</button>
<button class="btn btn-primary" type="button" id="logout-confirm-ok">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
确认退出
</button>
</div>
</div>
</div>
<!-- ─── 上传头像 modal ─── -->
<div class="modal-bg" id="avatar-up-bg" onclick="if(event.target===this)Shell.closeModal('avatar-up-bg')">
<div class="modal av-up-modal">
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
<div class="modal-h">
<div class="ic-m">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/></svg>
</div>
<div class="ti">上传头像<span>// 用于个人主页、评论与团队展示</span></div>
</div>
<div class="modal-b np-body">
<div class="av-up-preview-row">
<div class="av-up-preview" id="av-up-preview"></div>
<div class="av-up-preview-meta">
<div class="t" id="av-up-preview-name">当前头像 · 默认</div>
<div class="d" id="av-up-preview-info">// 系统生成 · 取姓氏首字</div>
</div>
</div>
<div class="upload-zone" id="av-up-zone" tabindex="0" role="button" aria-label="点击或拖入图片上传">
<span class="uz-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</span>
<div><strong>点击选择</strong> · 或拖入图片</div>
<span class="uz-hint">JPG / PNG / WebP · ≤ 2 MB · 推荐 256 × 256</span>
<input type="file" id="av-up-file" accept="image/jpeg,image/png,image/webp" hidden>
</div>
<div class="av-up-rules">
<div class="li">最大 2 MB · 长宽比建议 1:1 · 系统会自动裁切为圆形</div>
<div class="li">不要上传含他人肖像的图片,违规可能导致账号封停</div>
</div>
</div>
<div class="modal-f">
<button class="btn" type="button" onclick="Shell.closeModal('avatar-up-bg')">取消</button>
<button class="btn btn-primary" type="button" id="av-up-confirm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>
确认使用
</button>
</div>
</div>
</div>
</div>
<script src="assets/icons.js?v=2026052608"></script>
<script src="assets/shell.js?v=2026060101"></script>
<script>
Shell.render({
active: 'settings',
crumbs: [{ label: '工作台', href: 'index.html' }, { label: '设置' }]
});
/* ─── 配置 ─── */
const SECTIONS = ['sec-profile', 'sec-security', 'sec-notify', 'sec-pref', 'sec-display'];
const navLinks = document.querySelectorAll('.settings-nav a[data-jump]');
let suppressLeavePrompt = false;
function logoutCurrentDevice() {
const note = document.getElementById('logout-unsaved-note');
if (note) note.hidden = !(typeof dirtyFields !== 'undefined' && dirtyFields.size > 0);
Shell.openModal('logout-confirm-bg');
requestAnimationFrame(() => document.getElementById('logout-confirm-cancel')?.focus());
}
function confirmLogout() {
suppressLeavePrompt = true;
Shell.closeModal('logout-confirm-bg');
Shell.toast('已退出', '正在跳转登录页');
setTimeout(() => location.href = 'login.html', 600);
}
document.getElementById('logout-confirm-ok')?.addEventListener('click', confirmLogout);
/* ─── 1. 点击 nav → 只显示对应 section,其余隐藏 + 同步 URL hash ─── */
function showSection(id) {
if (!SECTIONS.includes(id)) id = SECTIONS[0];
SECTIONS.forEach(sid => {
const el = document.getElementById(sid);
if (el) el.style.display = (sid === id) ? '' : 'none';
});
navLinks.forEach(a => a.classList.toggle('active', a.dataset.jump === id));
if (location.hash.slice(1) !== id) {
history.replaceState(null, '', '#' + id);
}
// 切换面板后回到顶端,避免长面板留下的滚动位置错乱
window.scrollTo({ top: 0, behavior: 'instant' });
}
navLinks.forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
showSection(a.dataset.jump);
a.focus();
});
a.addEventListener('keydown', e => {
const idx = [...navLinks].indexOf(a);
if (e.key === 'ArrowDown') { e.preventDefault(); navLinks[(idx + 1) % navLinks.length].focus(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); navLinks[(idx - 1 + navLinks.length) % navLinks.length].focus(); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); a.click(); }
});
});
/* ─── 2. 初始加载:读 URL hash 显示对应 section(无 hash 时默认 sec-profile) ─── */
(function initSection() {
const hash = location.hash.slice(1);
showSection(SECTIONS.includes(hash) ? hash : 'sec-profile');
})();
/* hash 外部变化(如浏览器前进后退)也跟着切 */
window.addEventListener('hashchange', () => {
const hash = location.hash.slice(1);
if (SECTIONS.includes(hash)) showSection(hash);
});
/* ─── 4. 偏好 chip 选择 ─── */
function bindChoice(containerId) {
const ct = document.getElementById(containerId);
if (!ct) return;
ct.querySelectorAll('.pref-choice').forEach(c => {
c.addEventListener('click', () => {
ct.querySelectorAll('.pref-choice').forEach(x => x.classList.remove('selected'));
c.classList.add('selected');
markDirty(c);
});
});
}
bindChoice('pref-template');
bindChoice('pref-subtitle');
document.querySelectorAll('#pref-duration .dur-chip').forEach(c => {
c.addEventListener('click', () => {
document.querySelectorAll('#pref-duration .dur-chip').forEach(x => x.classList.remove('selected'));
c.classList.add('selected');
markDirty(c);
});
});
/* ─── 5. dirty state · 追踪改了哪几节 + save btn 显示变更条数 ─── */
const dirtyFields = new Set(); // 改过的字段 id 集合
const dirtySections = new Set(); // 涉及的 section id 集合
const saveBtn = document.getElementById('save-btn');
const cancelBtn = document.getElementById('save-cancel');
const saveBtnDefaultLabel = saveBtn.innerHTML;
function sectionOf(el) {
const sec = el.closest('section[id]');
return sec ? sec.id : null;
}
function syncSaveBtn() {
const n = dirtyFields.size;
if (n === 0) {
saveBtn.disabled = true;
cancelBtn.disabled = true;
saveBtn.style.opacity = '.5';
saveBtn.style.cursor = 'not-allowed';
cancelBtn.style.opacity = '.5';
cancelBtn.style.cursor = 'not-allowed';
saveBtn.innerHTML = saveBtnDefaultLabel;
} else {
saveBtn.disabled = false;
cancelBtn.disabled = false;
saveBtn.style.opacity = '';
saveBtn.style.cursor = '';
cancelBtn.style.opacity = '';
cancelBtn.style.cursor = '';
saveBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg> 保存所有变更 <span style="opacity:.75;font-family:var(--font-mono);font-size:11px;margin-left:4px;">· ${n} 项</span>`;
}
// nav 上加 dirty dot
navLinks.forEach(a => a.classList.toggle('has-changes', dirtySections.has(a.dataset.jump)));
}
function markDirty(el) {
const id = el.id || (el.dataset && el.dataset.v) || ('anon-' + Math.random().toString(36).slice(2, 8));
if (dirtyFields.has(id)) return;
dirtyFields.add(id);
const sec = sectionOf(el);
if (sec) dirtySections.add(sec);
syncSaveBtn();
}
function clearDirty() {
dirtyFields.clear();
dirtySections.clear();
syncSaveBtn();
}
document.querySelectorAll('[data-track], input[type="checkbox"], select').forEach(el => {
el.addEventListener('change', () => markDirty(el));
if (el.tagName === 'INPUT' && el.type !== 'checkbox') el.addEventListener('input', () => markDirty(el));
});
/* ─── 6. 表单校验:必填 + 邮箱格式 ─── */
const VALIDATORS = {
'prof-name': v => v.trim().length === 0 ? '显示名称不能为空' : (v.length > 20 ? '显示名称最多 20 字' : null),
'prof-email': v => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()) ? '邮箱格式不正确' : null,
'prof-phone': v => !/^\d{3}\*{4}\d{4}$|^1\d{10}$/.test(v.trim()) ? '手机号格式不正确' : null,
};
function validate() {
const errors = [];
Object.entries(VALIDATORS).forEach(([id, fn]) => {
const el = document.getElementById(id);
if (!el) return;
const err = fn(el.value);
el.classList.toggle('invalid', !!err);
if (err) errors.push(`${id}: ${err}`);
});
return errors;
}
/* ─── 7. 保存 / 取消 ─── */
saveBtn.addEventListener('click', () => {
if (dirtyFields.size === 0) return;
const errors = validate();
if (errors.length) {
Shell.toast('校验未通过', errors[0].split(': ')[1] + ' · 共 ' + errors.length + ' 项');
return;
}
Shell.toast('设置已保存', `${dirtyFields.size} 项变更已生效 · 涉及 ${dirtySections.size} 个分区`);
clearDirty();
});
cancelBtn.addEventListener('click', () => {
if (dirtyFields.size === 0) return;
if (confirm(`放弃 ${dirtyFields.size} 项未保存的变更?`)) location.reload();
});
/* ─── 8. 离开页面前提醒(有未保存变更时)─── */
window.addEventListener('beforeunload', e => {
if (!suppressLeavePrompt && dirtyFields.size > 0) {
e.preventDefault();
e.returnValue = '';
}
});
/* ─── 9. invalid input 视觉反馈样式 ─── */
const _invalidStyle = document.createElement('style');
_invalidStyle.textContent = `
.input.invalid { border-color: var(--accent-crimson); box-shadow: 0 0 0 3px rgba(235,52,36,.12); }
.input.invalid:focus { border-color: var(--accent-crimson); box-shadow: 0 0 0 3px rgba(235,52,36,.18); }
`;
document.head.appendChild(_invalidStyle);
/* ─── 10. nav badge 计数自动同步(从 section 内 switch 开启数 / device 数等) ─── */
function syncBadges() {
const secNotify = document.getElementById('sec-notify');
if (secNotify) {
const onCount = secNotify.querySelectorAll('input[type="checkbox"]:checked').length;
const totalCount = secNotify.querySelectorAll('input[type="checkbox"]').length;
const badge = document.querySelector('.settings-nav a[data-jump="sec-notify"] .nav-badge');
if (badge) badge.textContent = `${onCount}/${totalCount}`;
}
const secSecurity = document.getElementById('sec-security');
if (secSecurity) {
const devCount = secSecurity.querySelectorAll('.device-row').length;
const badge = document.querySelector('.settings-nav a[data-jump="sec-security"] .nav-badge');
if (badge) badge.textContent = `${devCount} 设备`;
}
}
syncBadges();
document.querySelectorAll('#sec-notify input[type="checkbox"]').forEach(cb => cb.addEventListener('change', syncBadges));
/* ─── 11. 头像上传 modal ─── */
const DEFAULT_AVATAR = { kind: 'default', label: '李', name: '默认', info: '// 系统生成 · 取姓氏首字' };
let _draftAvatar = { ...DEFAULT_AVATAR };
let _currentAvatar = { ...DEFAULT_AVATAR };
const avPreview = document.getElementById('av-up-preview');
const avPreviewName = document.getElementById('av-up-preview-name');
const avPreviewInfo = document.getElementById('av-up-preview-info');
const avProfPreview = document.getElementById('prof-avatar-preview');
const avZone = document.getElementById('av-up-zone');
const avFile = document.getElementById('av-up-file');
const avConfirm = document.getElementById('av-up-confirm');
const avResetBtn = document.getElementById('prof-avatar-reset');
function paintPreview() {
if (_draftAvatar.kind === 'image') {
avPreview.innerHTML = `<img src="${_draftAvatar.src}" alt="头像预览">`;
} else {
avPreview.innerHTML = '';
avPreview.textContent = _draftAvatar.label || '李';
}
avPreviewName.textContent = `预览 · ${_draftAvatar.name}`;
avPreviewInfo.textContent = _draftAvatar.info;
}
function applyAvatarTo(el, avatar) {
if (avatar.kind === 'image') {
el.innerHTML = `<img src="${avatar.src}" alt="头像" style="width:100%;height:100%;object-fit:cover;border-radius:50%;display:block;">`;
} else {
el.innerHTML = '';
el.textContent = avatar.label || '李';
}
}
avZone.addEventListener('click', () => avFile.click());
avZone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); avFile.click(); } });
avZone.addEventListener('dragover', e => { e.preventDefault(); avZone.classList.add('dragover'); });
avZone.addEventListener('dragleave', () => avZone.classList.remove('dragover'));
avZone.addEventListener('drop', e => {
e.preventDefault();
avZone.classList.remove('dragover');
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) handleAvatarFile(f);
});
avFile.addEventListener('change', () => { if (avFile.files[0]) handleAvatarFile(avFile.files[0]); });
function handleAvatarFile(f) {
if (!/^image\/(jpeg|png|webp)$/.test(f.type)) {
Shell.toast('格式不支持', '仅支持 JPG / PNG / WebP');
return;
}
if (f.size > 2 * 1024 * 1024) {
Shell.toast('文件过大', `${(f.size / 1024 / 1024).toFixed(2)} MB · 限 2 MB`);
return;
}
const url = URL.createObjectURL(f);
_draftAvatar = { kind: 'image', src: url, name: f.name, info: `// ${(f.size / 1024).toFixed(0)} KB · ${f.type.split('/')[1].toUpperCase()}` };
paintPreview();
}
avConfirm.addEventListener('click', () => {
_currentAvatar = { ..._draftAvatar };
applyAvatarTo(avProfPreview, _currentAvatar);
Shell.closeModal('avatar-up-bg');
Shell.toast('头像已更新', _currentAvatar.name);
markDirty(avProfPreview);
});
avResetBtn.addEventListener('click', () => {
_currentAvatar = { ...DEFAULT_AVATAR };
applyAvatarTo(avProfPreview, _currentAvatar);
_draftAvatar = { ...DEFAULT_AVATAR };
paintPreview();
Shell.toast('已恢复默认头像', _currentAvatar.name);
markDirty(avProfPreview);
});
// 打开 modal 时同步当前到 draft + preview
document.querySelector('.av-actions button.btn-sm').addEventListener('click', () => {
_draftAvatar = { ..._currentAvatar };
paintPreview();
});
paintPreview();
/* ─── 12. 实时邮箱 / 名称 input 校验反馈(blur 时显示错) ─── */
['prof-name', 'prof-email', 'prof-phone'].forEach(id => {
const el = document.getElementById(id);
if (!el || !VALIDATORS[id]) return;
el.addEventListener('blur', () => {
const err = VALIDATORS[id](el.value);
el.classList.toggle('invalid', !!err);
});
el.addEventListener('input', () => {
if (el.classList.contains('invalid')) {
const err = VALIDATORS[id](el.value);
if (!err) el.classList.remove('invalid');
}
});
});
</script>
<script src="assets/api-bridge.js?v=2026060114"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,644 @@
# Airshelf 页面流程定稿记录
> 本文件用于记录和用户逐页确认后的产品页面流程。
> PRD 只作为功能边界参考,页面布局与交互以本文件后续定稿为准。
维护日期:2026-05-28
---
## 0. 总体原则
- 创作视频是产品主线任务。
- 当前版本单条生成视频的时长上限是 15s。
- AI 图片创作是附加能力,用于辅助商品图、人物、场景等素材生产,不抢视频主线。
- 页面跳转尽量少,能在当前页面完成的操作尽量不跳转。
- 操作尽量简单,第一次使用 AI 产品的用户也应该能理解。
- 5 个 Stage 是步骤引导,不是强制锁定流程。
- 用户可以在脚本、基础资产、故事板、视频、拼接导出之间自由跳转。
- 如果某个 Stage 缺少前置内容,页面给提示和快捷补全入口,不做硬锁死。
- 暂不做复制项目、归档、审核、待审核、待我确认等复杂项目管理概念。
---
## 1. 视频项目页 `/projects`
### 页面定位
视频项目页是视频生产任务列表,帮助用户看清每个视频项目做到哪一步,以及下一步能点什么。
### 不做内容
- 不做复制项目。
- 不做归档。
- 不做审核 / 待审核 / 待我确认。
- 不做草稿分类。
- 不做复杂批量操作。
- AI 图片创作不作为顶部主按钮。
### 状态分类
只保留:
- 全部
- 进行中
- 生成中
- 已完成
- 失败
### 页面结构
1. Page Head
- 标题:视频项目
- 主按钮:新建视频项目
2. 状态概览 / Tab
- 全部
- 进行中
- 生成中
- 已完成
- 失败
3. 工具栏
- 搜索项目 / 商品名
- 商品筛选
- 状态筛选
- 最近更新排序
- 可选:列表 / 卡片视图切换
4. 项目列表
- 默认列表行,不默认大卡片。
- 每行展示:
- 9:16 缩略图
- 项目名
- 关联商品
- 当前阶段
- 5 段进度:脚本 / 资产 / 故事板 / 视频 / 导出
- 更新时间
- 状态
- 主操作:继续 / 查看进度 / 查看成片 / 重试
5. 空状态
- 没有商品:引导新建商品。
- 有商品但没有项目:引导新建视频项目。
---
## 2. 新建视频项目页 `/projects/new`
### 页面定位
新建项目页只负责创建项目壳子,并绑定商品。
脚本怎么生成、脚本风格、任务设定等全部放到 Stage 1 脚本页。
### 交互原则
- 一个页面内完成,不做多步跳转。
- 只问用户:为哪个商品创建视频。
- 创建后直接进入项目流水线的 Stage 1 脚本页。
### 字段
必选:
- 商品
可选:
- 项目名称
- 默认自动生成,例如:商品名 + 短视频 + 日期。
不展示:
- 创作方式
- 脚本风格
- 任务设定
- 商品卖点选择
- 预估消耗
### 卖点选择归属
商品卖点不放在新建项目页。
商品卖点选择放到 Stage 1 脚本页,作为“本条视频重点”配置项,从商品库自动带出。
---
## 3. Stage 1 脚本页
### 页面定位
脚本页是 AI 脚本工作台。
用户通过 AI 输入和启动卡片完成脚本来源选择、卖点选择、风格设定、任务设定,并在中间区域查看完整读秒流分镜脚本。
### 核心原则
- 脚本是用户最关注的内容,必须放在中间最大区域。
- 脚本不按 15s 大段展示。
- 脚本按完整读秒流分镜展示,适合短视频细看。
- AI 聊天不是主内容,而是脚本修改和生成记录。
- AI 输入框放页面底部固定,作为当前 Stage 的全局指令入口。
### 页面布局
顶部:
- 项目面包屑
- 商品名 / 项目名
- 5 Stage 自由切换导航:脚本 / 基础资产 / 故事板 / 视频 / 拼接导出
主体:
- 左侧窄栏:商品信息 / 卖点 / 人物 / 场景等辅助信息
- 中间主栏:读秒流分镜脚本
- 右侧窄栏:AI 对话记录 / 操作历史
底部固定栏:
- AI 输入框
- 页面操作按钮,例如重新生成全部、进入下一步
### 空状态引导
脚本来源不做 Tab。
脚本未生成时,中间主区域展示启动卡片:
- AI 帮我写
根据商品信息自动生成完整读秒流分镜。
- 我有脚本
粘贴已有口播稿,AI 帮你整理成分镜结构。
- 一句话生成
输入一句视频方向,AI 生成完整分镜。
- 复刻爆款
暂未开放或后续版本处理。
用户选择后进入对应模式。生成脚本后,中间区域切换为完整读秒流分镜。
### 读秒流分镜结构
每个分镜以卡片 / 行卡展示:
- 镜头编号
- 时间段,例如 0-5s、5-10s
- 画面描述
- 对白 / 旁白
- 可选:镜头类型、场景、人物
用户可以:
- 直接编辑某个分镜文字。
- 用底部 AI 输入框修改某个分镜。
- 重新生成全部。
- 调整整体时长、口吻、卖点重点等。
### 商品卖点
- 从商品库自动带出。
- 在脚本页展示为“本条视频重点”。
- 默认选中前 2-3 个,或默认使用全部卖点。
- 用户可点选 / 取消。
- 用户也可以直接在 AI 输入框里说重点突出哪些卖点。
---
## 4. Stage 2 基础资产页
### 页面定位
基础资产页用于为当前视频准备视觉资产。
它不是资产库,也不是完整 AI 图片创作页。
### 总体原则
- 默认 AI 自动生成,用户只在效果不好时调整。
- 脚本确定后,AI 自动识别需要的商品、人物、场景,并生成图片提示词。
- 页面优先让用户看默认生成效果。
- 不满意时再修改提示词、上传参考图、从资产库选择或重新生成。
- 商品是最重要的,优先展示商品资产。
### 页面布局
- 左侧:资产清单
- 中间:当前资产工作台 / 生成结果
- 右侧:AI 操作记录 / 生成记录
- 底部:AI 输入框
### 资产顺序
1. 商品资产
2. 人物
3. 场景
### 商品资产
商品三视图是一张 16:9 图,包含正面、侧面、背面。
它不是三张图,也不拆分成三个槽位。
不提供:
- 上传三视图
- 使用现有商品图
原因:
- 用户一般不会有适合生成视频的白底三视图。
- 如果不生成三视图,系统本来就只能默认使用商品图继续,不需要多给一个“使用现有商品图”的操作。
推荐交互:
- 展示当前商品图。
- 展示商品三视图状态:未生成 / 生成中 / 已生成。
- 说明:生成 16:9 商品三视图可以提升商品角度稳定性。
- 主操作:生成商品三视图。
- 不生成三视图也可以继续,只做弱提示。
生成规则:
- 商品三视图属于结构转换型。
- 默认一次生成 1 张。
- 不满意可重新生成或用 AI 输入框调整提示词。
- 商品三视图支持保留历史版本,用户可以在历史版本中选择满意的一版采用。
### 人物资产
命名为“人物”,不叫“模特”。
人物指脚本中需要出现的角色,用户要确定这个角色的形象。
默认流程:
1. AI 根据脚本自动识别人物。
2. AI 为每个人物自动生成图片提示词。
3. 默认生成 4 张人物立绘候选。
4. 用户选择 1 张满意的立绘。
5. AI 基于选中的立绘生成 1 张 16:9 人物三视图。
6. 用户采用此人物资产。
不满意时,用户可以:
- 修改描述。
- 上传自己的模特 / 参考图。
- 从人物库选择。
- 重新生成立绘。
- 从历史立绘里再选一张生成三视图。
- 重新生成人物三视图。
- 从人物三视图历史版本中选择满意的一版采用。
人物调整不放抽屉。
点击人物后,中间区域直接切换为人物工作台。
人物工作台需要一步到位承载:
- 人物库选择
- 上传参考图
- 描述生成
- 直接使用自己的模特
- AI 生成多张立绘
- 从立绘生成三视图
- 从历史立绘再选再生成三视图
### 场景资产
默认流程:
1. AI 根据脚本识别场景。
2. AI 自动生成场景图提示词。
3. 默认一次生成 4 张候选。
4. 用户选择 1 张。
不满意时,用户可以:
- 修改描述。
- 重新生成。
- 上传替换。
### 候选生成规则
创意选择型:一次生成 4 张。
- 人物立绘
- 场景图
结构转换型:一次生成 1 张。
- 商品三视图
- 人物三视图
三视图版本规则:
- 商品三视图和人物三视图都可以重跑。
- 每次重跑产生一个历史版本。
- 用户可以在历史版本中回选满意的一版。
- 采用某一版后,该版本成为当前项目使用版本。
### 资产复用
人物默认是项目内资产,不自动进入团队人物库,避免资产库变乱。
采用人物时可以勾选:
- 保存到人物库
保存后:
- 进入团队共享人物库。
- 后续项目可复用。
- 记录来源:项目内生成 / AI 生成 / 上传参考图等。
---
## 5. Stage 3 故事板页(已确认原则,待细化)
### 页面定位
故事板页是可选的预览步骤,不是视频生成的必经步骤。
### 已确认原则
- 故事板不是一定要生成。
- 用户可以跳过故事板,直接进入视频生成。
- 故事板的价值是提前预览视频大致画面和内容。
- 故事板有时会固定最终视频画面,反而可能让视频生成效果不如不使用故事板。
- 因此故事板不能作为强制前置条件。
- Stage 3 应该提供“生成故事板预览”和“跳过,直接生成视频”两条路径。
- 如果用户跳过故事板,Stage 4 直接基于脚本和基础资产生成视频。
- 故事板提示词直接在原提示词基础上修改。
- 暂不做独立 AI 辅助输入框或 AI 聊天式提示词修改。
- 故事板绑定资产需要展示,但只做轻量引用说明。
- 绑定资产默认不展示缩略图。
- 绑定资产用文字标签 / 资产引用标签展示。
- Hover / Popover 时展示资产详情。
- Stage 3 不直接编辑资产,修改资产需要回到 Stage 2。
- 如果绑定资产在 Stage 2 被替换,Stage 3 显示“绑定资产已更新,建议重新生成故事板”。
### 绑定资产展示
绑定资产展示的是引用关系,而不是图片预览。
示例:
- 商品:补水面膜
- 人物:女主、同事
- 场景:卫生间、书桌
或以标签形式展示:
- 商品 · 补水面膜
- 人物 · 女主
- 人物 · 同事
- 场景 · 卫生间
Hover 详情示例:
- 商品 · 补水面膜
- 商品图数量
- 商品三视图状态
- 来源:商品库
- 操作:查看 / 去基础资产修改
- 人物 · 女主
- 当前立绘状态
- 人物三视图状态
- 来源:项目内 / 人物库
- 操作:查看 / 去基础资产修改
### 提示词编辑
- 用户直接在原本提示词基础上修改。
- 提示词就是重新生成故事板的依据。
- 第一版不做 AI 辅助修改提示词。
- 可保留恢复原始提示词、重新生成等基础操作。
### 待继续确认
- 故事板是否进入页面后自动生成,还是由用户点击生成。
- 生成视频时,已生成故事板是否默认作为参考,还是由用户选择是否使用。
- 故事板历史版本如何展示。
---
## 6. 待继续确认页面
- Stage 5 拼接导出页
- 商品库 / 商品详情
- 资产库
- AI 图片创作
- 人物库
- 工作台
- 团队
- 消费
- 设置
---
## 7. Stage 5 拼接导出页
### 页面定位
拼接导出页是最终成片工作台。
它负责把 Stage 4 采用的 AI 视频片段、用户上传的视频、资产库视频等素材自由拼接,并完成字幕、BGM、转场和导出。
### 布局方向
参考剪映 / CapCut 的基础剪辑布局,但只保留轻量功能。
页面区域:
- 顶部:项目名、Stage 导航、导出状态
- 左侧:素材池
- 中间上方:成片预览播放器
- 右侧:属性设置
- 底部:时间线
### 素材池
素材池包含:
- AI 生成视频片段
- 上传视频
- 批量上传视频
- 从资产库选择视频
上传视频时支持勾选:
- 保存到资产库,供以后复用
默认不勾选,避免资产库变乱。
### 时间线
第一版只做单主轨轻剪辑。
支持:
- 自动放入 Stage 4 当前采用的 AI 视频片段,按顺序排列
- 拖拽排序
- 添加上传视频
- 删除片段
- 替换片段
- 裁剪起止点
不做:
- 多轨自由叠加
- 关键帧
- 复杂音频混音
- 速度曲线
- 画中画
- 蒙版
- 贴纸 / 复杂特效
### 裁剪
需要支持裁剪。
选中时间线片段后,右侧属性区显示:
- 开始时间
- 结束时间
- 当前片段时长
- 应用裁剪
时间线片段两侧可保留轻量拖拽手柄。
### 字幕
字幕默认自动添加。
规则:
- 进入 Stage 5 后,系统根据脚本台词 / 旁白自动生成字幕。
- 字幕默认开启。
- 用户可以关闭字幕。
- 用户可以编辑字幕文字和时间。
- 第一版不强制支持识别用户上传视频语音生成字幕。
右侧属性区默认展示:
- 字幕开关
- 字幕来源:脚本台词
- 字幕样式
- 编辑字幕入口
字幕编辑使用轻量面板,不做复杂字幕工作台。
示例:
- 00:00 - 00:03 熬夜后皮肤干到起皮?
- 00:03 - 00:06 这片补水面膜我最近一直在用。
- 00:06 - 00:10 敷完脸会明显更透亮。
### BGM / 转场 / 导出
右侧属性区包含:
- BGM 开关
- BGM 选择
- 原声 / BGM 音量
- 转场开关
- 转场类型
- 导出比例,默认 9:16
- 清晰度,默认 1080P
- 格式,默认 MP4
BGM 默认开启,用户可修改或关闭。
转场默认开启,用户可修改或关闭。
### 导出成功
导出成功后不新增独立页面。
在 Stage 5 当前页面展示导出成功状态 / 结果面板。
导出中:
- 显示导出进度。
导出成功后展示:
- 成片预览播放器
- 下载 MP4
- 复制链接
- 查看成片资产
- 继续编辑
- 返回视频项目
导出成功后:
- 项目状态标记为已完成。
- 成片自动进入项目资产。
后续如果接入平台发布能力,再增加发布入口;第一版不做独立发布页。
---
## 6. Stage 4 视频页
### 页面定位
视频页只用于 AI 生成视频片段。
用户上传视频、使用自己的视频、与 AI 视频混合拼接,都放到 Stage 5 拼接导出页处理。
### 核心原则
- 视频生成以片段为单位。
- 每段视频独立生成。
- 单个视频生成片段上限是 15s。
- 长视频由多个 15s 内的视频片段组成,后续在 Stage 5 拼接。
- 视频一次只生成 1 条候选。
- 首次进入视频页后,AI 可以自动生成视频,不需要用户手动点击生成。
- 视频有自己的生成提示词。
- 用户可以直接修改视频提示词。
- 用户可以不改提示词直接重跑。
- 每次重跑保留历史版本。
- 用户可以从历史版本中回选满意的一版。
### 视频来源
Stage 4 只展示和管理 AI 生成视频片段。
每个 AI 片段的视频来源是:
- 当前采用的 AI 生成版本
- AI 历史版本中的某一版
### 故事板使用
如果当前片段有故事板,默认使用故事板生成视频。
原因:
- 对小白用户来说,“使用 / 不使用故事板”解释成本高。
- 用户既然生成了故事板,视频结果和故事板不一致时容易产生困惑。
Stage 4 可以允许用户修改是否使用故事板,但默认不主动要求用户选择。
### 页面结构初稿
左侧:
- 片段列表
- 每段显示时间范围、来源、生成状态、当前采用版本
中间:
- 当前片段视频预览 / 生成状态
- 视频播放器
- 当前采用版本标识
右侧:
- 视频提示词编辑器
- 故事板使用状态 / 可修改
- 绑定输入资产引用
- 视频历史版本
底部:
- 重新生成
- 采用当前版本
- 进入拼接导出
### 待继续确认
- 视频历史版本如何展示。
@@ -0,0 +1,94 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = dirname(fileURLToPath(import.meta.url));
const frontendRoot = join(root, "..");
const exactRoot = join(frontendRoot, "public", "exact");
const outFile = join(frontendRoot, "src", "routes", "exact-html.ts");
const componentRoot = join(frontendRoot, "src", "routes", "exact-pages");
const pages = [
["account", "account.html"],
["assetFactory", "asset-factory.html"],
["dashboard", "index.html"],
["imageOptimize", "image-optimize.html"],
["library", "library.html"],
["login", "login.html"],
["messages", "messages.html"],
["modelPhoto", "model-photo.html"],
["modelPhotoDemoA", "model-photo-demo-a.html"],
["modelPhotoDemoB", "model-photo-demo-b.html"],
["pipeline", "pipeline.html"],
["platformCover", "platform-cover.html"],
["productCreate", "product-create.html"],
["productCreateUpload", "product-create-upload.html"],
["productDetail", "product-detail.html"],
["products", "products.html"],
["projectWizard", "projects-new.html"],
["projects", "projects.html"],
["register", "register.html"],
["settings", "settings.html"],
["team", "team.html"]
];
function normalizeDocument(html, fileName) {
const withMeta = html.replace(
/<head>/i,
`<head>\n<meta name="x-airshelf-exact-source" content="${basename(fileName)}">`
);
return withMeta
.replace(/(src|href)="assets\//g, '$1="/exact/assets/')
.replace(/url\((['"]?)assets\//g, "url($1/exact/assets/")
.replace(
"history.replaceState(null, '', '#' + id);",
"try { window.location.hash = id; } catch (e) {}"
);
}
mkdirSync(dirname(outFile), { recursive: true });
mkdirSync(componentRoot, { recursive: true });
const entries = pages
.map(([key, fileName]) => {
const html = normalizeDocument(readFileSync(join(exactRoot, fileName), "utf8"), fileName);
return ` ${JSON.stringify(key)}: ${JSON.stringify(html)}`;
})
.join(",\n");
const keys = pages.map(([key]) => JSON.stringify(key)).join(" | ");
writeFileSync(
outFile,
`/* This file is generated by scripts/generate-exact-html.mjs. Do not edit by hand. */\n` +
`export type ExactHtmlKey = ${keys};\n\n` +
`export const exactHtmlDocuments: Record<ExactHtmlKey, string> = {\n${entries}\n};\n`,
"utf8"
);
function toPascalCase(value) {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.split(/[-_]/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
for (const [key] of pages) {
const componentName = `Exact${toPascalCase(key)}Page`;
const fileName = `${key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()}.tsx`;
writeFileSync(
join(componentRoot, fileName),
`/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */\n` +
`import { ExactDocumentPage } from "../exact-document";\n` +
`import type { ExactDocumentPageProps } from "../exact-document";\n\n` +
`export function ${componentName}(props: Omit<ExactDocumentPageProps, "pageKey">) {\n` +
` return <ExactDocumentPage {...props} pageKey=${JSON.stringify(key)} />;\n` +
`}\n\n` +
`export default ${componentName};\n`,
"utf8"
);
}
console.log(`generated ${pages.length} exact documents -> ${outFile}`);
+422
View File
@@ -0,0 +1,422 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Bell, CircleDollarSign } from "lucide-react";
import { api, getToken, setToken } from "./api";
import type {
AITask,
Asset,
BillingSummary,
Ledger,
ModelConfig,
Product,
Project,
Team,
TeamMember,
User
} from "./types";
import { Decorations, Sidebar, ToastLike } from "./components/app-shell";
import {
AccountPage,
AssetFactoryPage,
AuthScreen,
Dashboard,
ImageWorkbenchPage,
LibraryPage,
MessagesPage,
ModelPhotoDemoPage,
PipelinePage,
ProductCreateUploadPage,
ProductDetailPage,
ProductsPage,
ProjectWizardPage,
ProjectsPage,
SettingsPage,
TeamPage
} from "./routes";
import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config";
import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
import { money } from "./routes/stage-config";
export function App() {
const [route, setRoute] = useState<ResolvedRoute>(() => resolveRoute());
const page = route.page;
const [authMode, setAuthMode] = useState<AuthMode>(route.authMode);
const [authed, setAuthed] = useState<boolean>(() => Boolean(getToken()));
const [booting, setBooting] = useState<boolean>(() => Boolean(getToken()));
const [user, setUser] = useState<User | null>(null);
const [team, setTeam] = useState<Team | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [assets, setAssets] = useState<Asset[]>([]);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
const [billing, setBilling] = useState<BillingSummary | null>(null);
const [ledgers, setLedgers] = useState<Ledger[]>([]);
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
const [activeProductId, setActiveProductId] = useState(route.productId || "");
const [activeProjectId, setActiveProjectId] = useState(route.projectId || "");
const [notice, setNotice] = useState<Notice>(null);
const [loading, setLoading] = useState(false);
const activeProject = useMemo(
() => projects.find((project) => project.id === activeProjectId) || projects[0],
[projects, activeProjectId]
);
const activeProduct = useMemo(
() => products.find((product) => product.id === activeProductId) || products[0],
[products, activeProductId]
);
const loadData = useCallback(async () => {
const [productData, projectData, assetData, billingData, ledgerData, memberData, modelData, taskData] =
await Promise.all([
api.products(),
api.projects(),
api.assets(),
api.billingSummary().catch(() => null),
api.ledgers().catch(() => []),
api.teamMembers().catch(() => []),
api.modelConfigs().catch(() => null),
api.aiTasks().catch(() => null)
]);
setProducts(productData.results);
setProjects(projectData.results);
setAssets(assetData.results);
setTeamMembers(memberData);
setModelConfigs(modelData?.results || []);
setAiTasks(taskData?.results || []);
if (billingData) setBilling(billingData);
setLedgers(ledgerData);
setActiveProjectId((current) => current || projectData.results[0]?.id || "");
setActiveProductId((current) => current || productData.results[0]?.id || "");
}, []);
// Boot: validate token, hydrate identity + data.
useEffect(() => {
if (!getToken()) {
setBooting(false);
return;
}
let cancelled = false;
(async () => {
try {
const identity = await api.me();
if (cancelled) return;
setUser(identity.user);
setTeam(identity.team);
await loadData();
} catch {
setToken(null);
if (!cancelled) setAuthed(false);
} finally {
if (!cancelled) setBooting(false);
}
})();
return () => {
cancelled = true;
};
}, [loadData]);
// Keep route in sync with browser navigation.
useEffect(() => {
function syncRouteFromHistory() {
const next = resolveRoute();
setRoute(next);
setAuthMode(next.authMode);
if (next.productId !== undefined) setActiveProductId(next.productId);
if (next.projectId !== undefined) setActiveProjectId(next.projectId);
}
window.addEventListener("popstate", syncRouteFromHistory);
return () => window.removeEventListener("popstate", syncRouteFromHistory);
}, []);
// Load full project detail when entering the pipeline.
useEffect(() => {
if (!authed || page !== "pipeline" || !activeProjectId) {
if (page !== "pipeline") setProjectDetail(null);
return;
}
let cancelled = false;
api
.project(activeProjectId)
.then((detail) => {
if (!cancelled) setProjectDetail(detail);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [authed, page, activeProjectId]);
function navigate(next: Page, options: NavigateOptions = {}) {
const productId = options.productId ?? activeProductId;
const projectId = options.projectId ?? activeProjectId;
if (options.productId !== undefined) setActiveProductId(options.productId);
if (options.projectId !== undefined) setActiveProjectId(options.projectId);
const hash = options.hash?.replace(/^#/, "");
setRoute({ page: next, authMode, productId, projectId, hash });
const path = `${pathForPage(next, { productId, projectId })}${hash ? `#${hash}` : ""}`;
if (`${window.location.pathname}${window.location.hash}` !== path || window.location.search) {
const method = options.replace ? "replaceState" : "pushState";
window.history[method](null, "", path);
}
window.scrollTo({ top: 0, behavior: "auto" });
}
async function refreshProjectDetail() {
if (!activeProjectId) return;
const detail = await api.project(activeProjectId).catch(() => null);
if (detail) setProjectDetail(detail);
}
async function action<T>(work: () => Promise<T>, successText: string): Promise<T | null> {
setLoading(true);
setNotice(null);
try {
const result = await work();
setNotice({ type: "success", text: successText });
await loadData();
await refreshProjectDetail();
return result;
} catch (error) {
setNotice({ type: "error", text: error instanceof Error ? error.message : "操作失败" });
return null;
} finally {
setLoading(false);
}
}
function onAuthed(payload: { token: string; user: User; team: Team }) {
setToken(payload.token);
setUser(payload.user);
setTeam(payload.team);
setAuthed(true);
setBooting(true);
loadData().finally(() => setBooting(false));
navigate("dashboard", { replace: true });
}
async function logout() {
await api.logout().catch(() => undefined);
setToken(null);
setAuthed(false);
setUser(null);
setTeam(null);
setAuthMode("login");
window.history.replaceState(null, "", "/login");
}
// ---- Auth gate ----
if (!authed) {
return (
<AuthScreen
initialMode={authMode}
onModeChange={(next) => {
setAuthMode(next);
window.history.pushState(null, "", next === "register" ? "/register" : "/login");
}}
onAuthed={onAuthed}
/>
);
}
if (booting || !user || !team) {
return (
<div className="app">
<main>
<div className="content">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 正在拉取团队数据</span>
</div>
</div>
</div>
</div>
</main>
</div>
);
}
const currentUser: User = user;
const currentTeam: Team = team;
function renderPage() {
switch (page) {
case "dashboard":
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} userName={currentUser.username} navigate={navigate} />;
case "products":
return (
<ProductsPage
products={products}
navigate={navigate}
openProduct={(productId) => navigate("productDetail", { productId })}
onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")}
/>
);
case "productCreateUpload":
return (
<ProductCreateUploadPage
onCreate={async (payload) => {
const created = await action(() => api.createProduct(payload), "商品已创建");
if (created) navigate("productDetail", { productId: created.id });
}}
onBack={() => navigate("products")}
/>
);
case "productDetail":
if (!activeProduct) return <ProductsPage products={products} navigate={navigate} openProduct={(productId) => navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} />;
return (
<ProductDetailPage
product={activeProduct}
projects={projects.filter((project) => project.product === activeProduct.id)}
navigate={navigate}
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
onDelete={async () => {
const ok = await action(() => api.deleteProduct(activeProduct.id), "商品已删除");
if (ok !== null) navigate("products");
}}
/>
);
case "projects":
return (
<ProjectsPage
products={products}
projects={projects}
navigate={navigate}
onCreate={(payload) => action(() => api.createProject(payload), "项目已创建")}
openPipeline={(projectId) => navigate("pipeline", { projectId })}
onDelete={(projectId) => action(() => api.deleteProject(projectId), "项目已删除")}
/>
);
case "projectWizard":
return (
<ProjectWizardPage
products={products}
onBack={() => navigate("projects")}
onCreate={async (payload) => {
const created = await action(() => api.createProject(payload), "项目已创建");
if (created) navigate("pipeline", { projectId: created.id });
}}
/>
);
case "pipeline": {
const project = projectDetail || activeProject;
if (!project) {
return (
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 先创建一个视频项目</span>
</div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" onClick={() => navigate("projectWizard")}>
</button>
</div>
</div>
);
}
return (
<PipelinePage
project={project}
loading={loading}
onRefresh={refreshProjectDetail}
onGenerateScript={(prompt) => action(() => api.generateScript(project.id, { prompt }), "脚本已生成")}
onAdoptScript={(scriptId) => action(() => api.adoptScript(project.id, scriptId), "脚本已采用")}
onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(project.id, { kind, prompt }), "基础资产已生成")}
onGenerateStoryboard={(prompt) => action(() => api.generateStoryboard(project.id, { prompt }), "故事板已生成")}
onSkipStoryboard={() => action(() => api.skipStoryboard(project.id), "已跳过故事板")}
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(project.id, { video_segment_id: segmentId, prompt }), "视频片段已提交")}
onPollVideo={(segmentId) => action(() => api.pollVideo(project.id, segmentId), "片段状态已刷新")}
onSubmitAllVideos={(prompt) =>
action(async () => {
const targets = project.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status));
for (const segment of targets) {
await api.submitVideo(project.id, {
video_segment_id: segment.id,
prompt: `${prompt}${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds}`
});
}
return targets.length;
}, "60s 多段视频任务已提交")
}
onPollAllVideos={() =>
action(async () => {
const targets = project.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
for (const segment of targets) {
await api.pollVideo(project.id, segment.id).catch(() => undefined);
}
return targets.length;
}, "视频片段状态已刷新")
}
onSubmitExport={() => action(() => api.submitExport(project.id), "导出任务已提交")}
/>
);
}
case "library":
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} />;
case "account":
return <AccountPage billing={billing} ledgers={ledgers} projects={projects} teamMembers={teamMembers} />;
case "team":
return <TeamPage team={currentTeam} user={currentUser} members={teamMembers} navigate={navigate} />;
case "messages":
return <MessagesPage navigate={navigate} />;
case "assetFactory":
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} />;
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
case "modelPhoto":
return <ImageWorkbenchPage mode="model" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
case "platformCover":
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
case "modelPhotoDemoA":
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} />;
case "modelPhotoDemoB":
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} />;
case "settings":
return <SettingsPage user={currentUser} team={currentTeam} />;
case "settingsNotify":
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" />;
default:
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} navigate={navigate} />;
}
}
const avatarChar = (user.username || "U").slice(0, 1).toUpperCase();
return (
<div className="app">
<Sidebar page={page} navigate={navigate} user={user} products={products} />
<main>
<Decorations />
<header className="topbar">
<div className="crumbs">
<span className="here">{routeLabels[page]}</span>
</div>
<div className="right">
<button className="balance-chip" type="button" onClick={() => navigate("account")}>
<CircleDollarSign size={13} />
<strong>{money(billing?.account.balance)}</strong>
</button>
<button className="icon-btn" type="button" onClick={() => navigate("messages")} aria-label="消息">
<Bell size={15} />
</button>
<button className="topbar-avatar" type="button" onDoubleClick={logout} title="双击退出登录">
<span>{avatarChar}</span>
</button>
</div>
</header>
<div className="content" id="page-content">
{notice && <ToastLike notice={notice} />}
{renderPage()}
</div>
</main>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
import type {
AITask,
Asset,
AuthPayload,
BillingSummary,
Ledger,
ModelConfig,
Paginated,
Product,
Project,
ScriptVersion,
Team,
TeamMember,
User
} from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
const TOKEN_KEY = "airshelf_token";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export function getToken() {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string | null) {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers = new Headers(options.headers);
if (!(options.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
if (token) headers.set("Authorization", `Token ${token}`);
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (!response.ok) {
const text = await response.text();
throw new ApiError(response.status, text || `${response.status} ${response.statusText}`);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
export const api = {
register(payload: { username: string; password: string; email?: string; team_name?: string }) {
return request<AuthPayload>("/api/auth/register/", { method: "POST", body: JSON.stringify(payload) });
},
login(payload: { username: string; password: string }) {
return request<AuthPayload>("/api/auth/login/", { method: "POST", body: JSON.stringify(payload) });
},
me() {
return request<{ user: User; team: Team }>("/api/auth/me/");
},
logout() {
return request<void>("/api/auth/logout/", { method: "POST" });
},
teamMembers() {
return request<TeamMember[]>("/api/auth/team/members/");
},
products() {
return request<Paginated<Product>>("/api/products/");
},
product(id: string) {
return request<Product>(`/api/products/${id}/`);
},
createProduct(payload: {
title?: string;
brand?: string;
category?: string;
target_audience?: string;
description?: string;
specs?: Record<string, unknown>;
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
}) {
return request<Product>("/api/products/", { method: "POST", body: JSON.stringify(payload) });
},
updateProduct(id: string, payload: Partial<Product>) {
return request<Product>(`/api/products/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteProduct(id: string) {
return request<void>(`/api/products/${id}/`, { method: "DELETE" });
},
projects() {
return request<Paginated<Project>>("/api/projects/");
},
project(id: string) {
return request<Project>(`/api/projects/${id}/`);
},
createProject(payload: { name: string; product: string }) {
return request<Project>("/api/projects/", { method: "POST", body: JSON.stringify(payload) });
},
deleteProject(id: string) {
return request<void>(`/api/projects/${id}/`, { method: "DELETE" });
},
generateScript(projectId: string, payload: { prompt: string; selling_point_ids?: string[] }) {
return request<ScriptVersion>(`/api/projects/${projectId}/generate-script/`, {
method: "POST",
body: JSON.stringify(payload)
});
},
adoptScript(projectId: string, script_version_id: string) {
return request<ScriptVersion>(`/api/projects/${projectId}/adopt-script/`, {
method: "POST",
body: JSON.stringify({ script_version_id })
});
},
generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string }) {
return request(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
},
adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
},
generateStoryboard(projectId: string, payload: { prompt: string }) {
return request(`/api/projects/${projectId}/generate-storyboard/`, { method: "POST", body: JSON.stringify(payload) });
},
skipStoryboard(projectId: string) {
return request<Project>(`/api/projects/${projectId}/skip-storyboard/`, { method: "POST" });
},
submitVideo(projectId: string, payload: { video_segment_id: string; prompt: string }) {
return request<Project>(`/api/projects/${projectId}/submit-video-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
pollVideo(projectId: string, video_segment_id: string) {
return request(`/api/projects/${projectId}/poll-video-segment/`, {
method: "POST",
body: JSON.stringify({ video_segment_id })
});
},
submitExport(projectId: string) {
return request(`/api/projects/${projectId}/submit-export/`, { method: "POST" });
},
assets() {
return request<Paginated<Asset>>("/api/assets/");
},
uploadAsset(formData: FormData) {
return request<Asset>("/api/assets/upload/", { method: "POST", body: formData });
},
billingSummary() {
return request<BillingSummary>("/api/billing/summary/");
},
ledgers() {
return request<Ledger[]>("/api/billing/ledgers/");
},
modelConfigs() {
return request<Paginated<ModelConfig>>("/api/ai/models/");
},
aiTasks() {
return request<Paginated<AITask>>("/api/ai/tasks/");
}
};
@@ -0,0 +1,46 @@
const iconPaths: Record<string, string> = {
layoutDashboard: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
package: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>',
clapperboard: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
sparkles: '<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3Z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15Z"/>',
images: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
folder: '<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2h7.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5Z"/><path d="M3 9h18"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
creditCard: '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/>',
settings: '<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.2a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.2A1.7 1.7 0 0 0 4.7 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1.1-1.5V3a2 2 0 1 1 4 0v.2a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1h.1a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/>',
chevronLeft: '<path d="m15 18-6-6 6-6"/>',
chevronRight: '<path d="m9 18 6-6-6-6"/>',
productPlus: '<path d="M12 22V12"/><path d="M16 17h6"/><path d="M19 14v6"/><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="m7.5 4.3 9 5.1"/>',
arrowUp: '<path d="M12 19V5"/><path d="m5 12 7-7 7 7"/>',
helpCircle: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4"/><path d="M12 17h.01"/>'
};
const iconAliases: Record<string, string> = {
dashboard: "layoutDashboard",
library: "folder"
};
export function IconKitSvg({ name, size = 16, strokeWidth = 1.5 }: { name: string; size?: number; strokeWidth?: number }) {
const key = iconAliases[name] || name;
return (
<svg
className="ui-icon"
data-icon={key}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
aria-hidden="true"
dangerouslySetInnerHTML={{ __html: iconPaths[key] || iconPaths.helpCircle }}
/>
);
}
@@ -0,0 +1,80 @@
import { Check, Search } from "lucide-react";
import type { Product, User } from "../types";
import type { NavItem, Notice, Page } from "../routes/route-config";
import { mainNav, parentPage, supplementNav } from "../routes/route-config";
type Navigate = (page: Page) => void;
export function Sidebar({ page, navigate, user, products }: { page: Page; navigate: Navigate; user: User; products: Product[] }) {
const activePage = parentPage(page);
return (
<aside className="sidebar">
<div className="brand">
<div className="flame">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2c1 3 4 5 4 9a4 4 0 0 1-4 4 4 4 0 0 1-4-4 5 5 0 0 1 1.5-3.5C10.5 6 11.5 4 12 2zm-1 13c0 2 1 3 1 5 1-1 3-2 3-5 0-1.5-1-2-2-3-1 1-2 2-2 3z" />
</svg>
</div>
<div><div className="name">·Studio</div></div>
</div>
<div className="search-box">
<Search size={14} />
<input id="global-search" placeholder="搜索" />
<span className="kbd">K</span>
</div>
<div className="nav-section"></div>
<nav>
{mainNav.map((item) => (
<NavButton key={item.page} item={{ ...item, badge: item.page === "products" ? String(products.length) : item.badge }} active={activePage === item.page} navigate={navigate} />
))}
</nav>
<div className="nav-section"></div>
<nav>
{supplementNav.map((item) => <NavButton key={item.page} item={item} active={activePage === item.page} navigate={navigate} />)}
</nav>
<div className="aside-foot">
<button className="user" type="button">
<div className="av">{user.username.slice(0, 1).toUpperCase()}</div>
<div className="em">{user.username}</div>
</button>
</div>
</aside>
);
}
export function NavButton({ item, active, navigate }: { item: NavItem; active: boolean; navigate: Navigate }) {
const Icon = item.icon;
return (
<button className={active ? "active" : ""} type="button" onClick={() => navigate(item.page)}>
<Icon size={14} />
<span>{item.label}</span>
{item.badge && <span className="pill-mini">{item.badge}</span>}
</button>
);
}
export function Decorations() {
// Exact 设计稿只用 grid-bg 做底纹;旧版散点/角标是绝对定位写死坐标,会压在
// topbar 和 page-head 上(重叠 bug),且不在设计稿里。品牌 mono 签名由各页面 inline
// 的 // 注释、[ ALL ] 等承载,这里只保留底纹。
return <div className="grid-bg" />;
}
export function ToastLike({ notice }: { notice: NonNullable<Notice> }) {
return (
<div className={`inline-toast ${notice.type}`}>
<div className="ic-t"><Check size={12} /></div>
<div className="txt">{notice.text}<span className="mono">[ {notice.type.toUpperCase()} ]</span></div>
</div>
);
}
export function CornerMark({ pos }: { pos: "tl" | "tr" | "bl" | "br" }) {
return (
<span className={`corner-mark ${pos}`}>
<svg viewBox="0 0 22 21" fill="none">
<path d="M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z" fill="currentColor" />
</svg>
</span>
);
}
+71
View File
@@ -0,0 +1,71 @@
import type { ReactNode } from "react";
import { Shield, X } from "lucide-react";
export function SettingRow({ title, desc, action, toggle, checked }: { title: string; desc: string; action?: string; toggle?: boolean; checked?: boolean }) {
return (
<div className="setting-row">
<div><strong>{title}</strong><span>{desc}</span></div>
{toggle ? <label className="switch"><input type="checkbox" defaultChecked={checked} /><span className="slider" /></label> : <button className="btn btn-sm" type="button">{action}</button>}
</div>
);
}
export function TeamModal({ open, title, subtitle, icon, close, children, footer }: {
open: boolean;
title: string;
subtitle: string;
icon: ReactNode;
close: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
if (!open) return null;
return (
<div className="modal-bg show" onClick={close}>
<div className="modal invite-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"><div className="ic-m">{icon}</div><div className="ti">{title}<span>{subtitle}</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
<div className="modal-b">{children}</div>
<div className="modal-f"><button className="btn" type="button" onClick={close}></button>{footer || <button className="btn btn-primary" type="button" onClick={close}></button>}</div>
</div>
</div>
);
}
export function ConfirmModal({ open, title, detail, confirmText, onCancel, onConfirm }: {
open: boolean;
title: string;
detail: string;
confirmText: string;
onCancel: () => void;
onConfirm: () => void | Promise<unknown>;
}) {
if (!open) return null;
return (
<div className="modal-bg show" onClick={onCancel}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"><div className="ic-m"><Shield size={16} /></div><div className="ti">{title}<span>// CONFIRM</span></div></div>
<div className="modal-b">{detail}</div>
<div className="modal-f"><button className="btn" type="button" onClick={onCancel}></button><button className="btn btn-primary" type="button" onClick={() => void onConfirm()}>{confirmText}</button></div>
</div>
</div>
);
}
export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) {
if (!open) return null;
return (
<>
<div className="drawer-bg show" onClick={close} />
<aside className="drawer show">
<div className="drawer-h"><h3>{title}</h3><button className="x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
<div className="drawer-b">{children}</div>
</aside>
</>
);
}
export function EmptyPanel({ title, action, onAction }: { title: string; action: string; onAction: () => void }) {
return <div className="empty-state show"><h3>{title}</h3><p>// 先创建资料后进入下一步</p><button className="btn btn-primary" type="button" onClick={onAction}>{action}</button></div>;
}
@@ -0,0 +1,29 @@
import { stageMeta, stageOrder } from "../routes/stage-config";
export function Progress({ status }: { status: string }) {
const current = Math.max(stageOrder.indexOf(status as never), 0);
return (
<div className="prog">
{stageOrder.map((stage, index) => <span key={stage} className={index < current ? "done" : index === current ? "cur" : ""} />)}
</div>
);
}
export function FragmentStageStep({ stage, done, active, lineDone, isLast, onClick }: {
stage: string;
done: boolean;
active: boolean;
lineDone: boolean;
isLast: boolean;
onClick: () => void;
}) {
const meta = stageMeta[stage];
return (
<>
<button className={`stage-step ${done ? "done" : ""} ${active ? "active" : ""}`} type="button" onClick={onClick}>
<div className="num">{done ? "✓" : meta.no}</div><div className="lbl">{meta.label}</div><div className="st">{done ? "已确认" : active ? "进行中" : "未开始"}</div>
</button>
{!isLast && <div className={`stage-line ${lineDone ? "done" : ""}`} />}
</>
);
}
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
/* Exact page-level CSS copied from 电商AI平台/index.html. */
.exact-document-route {
width: 100%;
min-height: 100vh;
background: var(--background-base);
}
.exact-document-frame {
display: block;
width: 100%;
min-height: 100vh;
border: 0;
background: var(--background-base);
}
.app .brand { gap: 0; padding: 0; }
.app .search-box .kbd { background: transparent; border: 0; border-radius: 0; padding: 0; }
.dash-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 24px; align-items: start; }
.recent-row { display: grid; grid-template-columns: 54px 1fr 110px 130px 60px; align-items: center; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border-faint); cursor: pointer; width: auto; text-align: inherit; background: transparent; }
.recent-row .prog, .recent-row .pill, .recent-row .btn { justify-self: start; }
.recent-row:last-child { border-bottom: 0; }
.recent-row:hover { background: var(--background-lighter); }
.recent-row .thumb { width: 54px; height: 70px; border-radius: var(--r-md); }
.recent-meta .name { font-weight: 600; font-size: 13.5px; color: var(--accent-black); }
.recent-meta .sub { font-size: 12px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .01em; }
.shortcuts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.shortcut { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 16px; display: flex; align-items: flex-start; gap: 12px; cursor: pointer; transition: background var(--t-base); }
.shortcut:hover { background: var(--black-alpha-4); }
.shortcut .ic { width: 32px; height: 32px; background: var(--heat-12); color: var(--heat); display: grid; place-items: center; border: 0; border-radius: var(--r-md); flex-shrink: 0; }
.shortcut .ic svg { width: 16px; height: 16px; }
.shortcut .t { font-size: 13px; font-weight: 600; }
.shortcut .d { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .01em; }
.tip { background: var(--surface); border: 1px dashed var(--border-faint); padding: 14px 16px; font-size: 12.5px; color: var(--black-alpha-56); line-height: 1.6; border-radius: var(--r-md); }
.tip strong { color: var(--accent-black); font-weight: 600; display: block; margin-bottom: 4px; }
.tip .mono { font-family: var(--font-mono); color: var(--heat); background: var(--heat-12); padding: 1px 5px; border-radius: var(--r-sm); font-size: 11.5px; }
+7
View File
@@ -0,0 +1,7 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
import "./design-restraint.css";
import "./exact-pages.css";
createRoot(document.getElementById("root")!).render(<App />);
+29
View File
@@ -0,0 +1,29 @@
import { useState } from "react";
import { CreditCard } from "lucide-react";
import type { BillingSummary, Ledger, Project, TeamMember } from "../types";
import { money, stageMeta, statusPill } from "./stage-config";
export function AccountPage({ billing, ledgers, projects, teamMembers }: {
billing: BillingSummary | null;
ledgers: Ledger[];
projects: Project[];
teamMembers: TeamMember[];
}) {
const [tab, setTab] = useState<"overview" | "projects" | "members" | "bills">("overview");
const [amount, setAmount] = useState(500);
return (
<>
<div className="page-head"><div><h1></h1><div className="sub"><span className="mono">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div></div></div>
<div className="top-grid"><div className="balance-banner"><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="balance-hero"><div className="lbl"></div><div className="v">{money(billing?.account.balance)}</div><div className="meta">// 冻结 {money(billing?.account.reserved_balance)} · 已消费 {money(billing?.charged_total)}</div></div></div><div className="pane topup-pane"><div className="topup-head"><div><h3>快速充值</h3><div className="desc">// 充值后立刻到账,可开发票</div></div><div className="topup-selected">已选 ¥{amount}</div></div><div className="recharge-row">{[100, 500, 1000, 3000].map((item) => <button className={`recharge-card ${amount === item ? "selected" : ""}`} type="button" key={item} onClick={() => setAmount(item)}><div className="amt">¥{item}</div><div className="gift">+ 赠送</div></button>)}</div><button className="btn pay-method-btn" type="button"><CreditCard size={13} />微信支付</button></div></div>
<div className="billing-tabs">{[["overview", "总览"], ["projects", "项目"], ["members", "成员"], ["bills", "账单流水"]].map(([key, label]) => <button className={`tab ${tab === key ? "active" : ""}`} type="button" key={key} onClick={() => setTab(key as typeof tab)}>{label}</button>)}</div>
{tab === "overview" && <div className="overview-grid"><div className="pane trend-pane"><h3></h3><div className="bars">{Array.from({ length: 14 }).map((_, index) => <span key={index} style={{ height: `${18 + (index % 6) * 10}%` }} />)}</div></div><div className="pane stage-pane"><h3></h3><UsageLine label="视频片段" value="¥98.40" width="60%" color="var(--orange)" /><UsageLine label="故事板" value="¥36.00" width="22%" color="var(--green)" /></div></div>}
{tab === "projects" && <table className="billing-table"><thead><tr><th></th><th></th><th></th><th></th></tr></thead><tbody>{projects.map((project) => <tr key={project.id}><td>{project.name}</td><td>{stageMeta[project.current_stage]?.label || project.current_stage}</td><td><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{project.status}</span></td><td>¥0.00</td></tr>)}</tbody></table>}
{tab === "members" && <table className="billing-table"><thead><tr><th></th><th></th><th></th><th></th></tr></thead><tbody>{teamMembers.map((member) => <tr key={member.id}><td>{member.user.username}</td><td>{member.role}</td><td>{money(member.monthly_credit_limit)}</td><td><span className={`pill ${statusPill(member.status)}`}><span className="dot" />{member.status}</span></td></tr>)}</tbody></table>}
{tab === "bills" && <table className="billing-table bills"><thead><tr><th></th><th></th><th></th><th></th></tr></thead><tbody>{ledgers.map((item) => <tr key={item.id}><td>{new Date(item.created_at).toLocaleString("zh-CN")}</td><td>{item.ledger_type}</td><td>{item.reason}</td><td>{item.amount}</td></tr>)}</tbody></table>}
</>
);
}
export function UsageLine({ label, value, width, color }: { label: string; value: string; width: string; color: string }) {
return <><div className="usage-line"><span>{label}</span><span className="v">{value}</span></div><div className="usage-bar"><span style={{ width, background: color }} /></div></>;
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { ArrowLeft, ArrowRight, Check, Grid2X2, List, RefreshCw, Search, WandSparkles } from "lucide-react";
import type { AITask, Asset, ModelConfig, Product } from "../types";
import type { Page } from "./route-config";
import { statusPill } from "./stage-config";
export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) {
const cards = [
{ page: "modelPhoto" as Page, tag: "[ MODEL · TRY-ON ]", title: "模特上身图", desc: "选择模特,AI 生成商品模特上身效果图", cost: "≈ ¥0.30 / 张" },
{ page: "platformCover" as Page, tag: "[ PLATFORM · KIT ]", title: "平台套图", desc: "选择平台模板,AI 生成电商平台套图", cost: "≈ ¥0.50 / 张" },
{ page: "imageOptimize" as Page, tag: "[ IMAGE · STUDIO ]", title: "图片创作", desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写", cost: "≈ ¥0.40 / 组" }
];
return (
<>
<div className="page-head"><div><h1></h1><div className="sub"><span className="mono">// 一键生成</span><span>·</span><span>电商视觉素材,提升内容制作效率</span></div></div></div>
<div className="factory-hero">{cards.map((card) => <article className="factory-card with-corners" key={card.title}><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="factory-body"><div className="factory-text"><span className="factory-tag">{card.tag}</span><div className="factory-title">{card.title}</div><div className="factory-desc">{card.desc}</div><div className="factory-cta"><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate(card.page)}><ArrowRight size={13} /></button><span className="cost">[ {card.cost} ]</span></div></div><div className="factory-visual"><div className="placeholder main"><span className="ph-frame">{card.title}</span></div></div></div></article>)}</div>
<div className="section-h"><h2></h2><span className="more">// {aiTasks.length} 个真实 AI 任务</span></div>
<div className="toolbar"><div className="search-inline"><Search size={14} /><input className="input" placeholder="搜索任务名" /></div><span className="spacer" /><div className="view-toggle"><button type="button" className="active"><List size={13} /></button><button type="button"><Grid2X2 size={13} /></button></div></div>
<table className="t"><thead><tr><th></th><th></th><th></th><th /></tr></thead><tbody>{aiTasks.slice(0, 8).map((task) => <tr key={task.id}><td>{task.id.slice(0, 8)}</td><td>{task.task_type}</td><td><span className={`pill ${statusPill(task.status)}`}><span className="dot" />{task.status}</span></td><td /></tr>)}</tbody></table>
</>
);
}
export function ImageWorkbenchPage({ mode, products, assets, modelConfigs, onBack, navigate }: {
mode: "image" | "model" | "cover";
products: Product[];
assets: Asset[];
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
}) {
const meta = {
image: { title: "图片创作", tag: "[ IMAGE · STUDIO ]", desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写。", ratio: "1:1" },
model: { title: "模特上身图", tag: "[ MODEL · TRY-ON ]", desc: "选择模特和商品,生成电商模特上身图。", ratio: "3:4" },
cover: { title: "平台套图", tag: "[ PLATFORM · KIT ]", desc: "选择平台模板,一键生成主图 / 封面 / 详情套图。", ratio: "4:5" }
}[mode];
const [productId, setProductId] = useState(products[0]?.id || "");
const [prompt, setPrompt] = useState(`${products[0]?.title || "商品"},电商高转化视觉,干净背景,商品主体清晰`);
const product = products.find((item) => item.id === productId) || products[0];
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
useEffect(() => {
if (product) setPrompt(`${product.title},电商高转化视觉,干净背景,商品主体清晰`);
}, [productId]);
return (
<div className="tool-shell">
<div className="tool-topbar"><button className="back-pill" type="button" onClick={onBack}><ArrowLeft size={14} /></button><div><h1>{meta.title}</h1><div className="sub"><span className="mono">{meta.tag}</span> {meta.desc}</div></div><span className="spacer" />{mode === "model" && navigate && <button className="btn" type="button" onClick={() => navigate("modelPhotoDemoA")}> A</button>}<button className="btn btn-primary" type="button"><WandSparkles size={13} /></button></div>
<div className="tool-layout"><aside className="tool-rail"><div className="rail-h"></div>{products.slice(0, 8).map((item) => <button className={`rail-product ${productId === item.id ? "active" : ""}`} type="button" key={item.id} onClick={() => setProductId(item.id)}><div className="placeholder"><span className="ph-frame">{item.title.slice(0, 4)}</span></div><span>{item.title}</span></button>)}</aside><section className="tool-canvas"><div className="tool-toolbar"><button className="chip active"></button><span className="spacer" /><span className="mono muted-2">{imageModels[0]?.display_name || "Volcano Image"}</span></div><div className="result-board">{Array.from({ length: 4 }).map((_, index) => <button className="result-tile" type="button" key={index}><div className="placeholder"><span className="ph-frame">{meta.ratio} · v{index + 1}</span></div><div className="result-meta-row"><span> {index + 1}</span><span className="pill info"><span className="dot" /></span></div></button>)}</div><div className="candidate-actions"><button className="btn" type="button"><RefreshCw size={13} /></button><button className="btn btn-primary" type="button"><Check size={13} /></button></div></section><aside className="tool-side"><div className="pane"><h3></h3><textarea className="textarea" value={prompt} onChange={(event) => setPrompt(event.target.value)} /></div><div className="pane"><h3></h3><div className="asset-mini-grid">{assets.slice(0, 6).map((asset) => <div className="placeholder" key={asset.id}><span className="ph-frame">{asset.asset_type}</span></div>)}</div></div></aside></div>
</div>
);
}
export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A" | "B"; products: Product[]; onBack: () => void }) {
return <><div className="page-head"><div><h1> {variant}</h1><div className="sub"><span className="mono">// model-photo-demo-{variant.toLowerCase()}</span> · 原型对比页补齐</div></div><div className="actions"><button className="btn" type="button" onClick={onBack}><ArrowLeft size={13} />返回模特图</button></div></div><div className={`demo-layout demo-${variant.toLowerCase()}`}><aside className="tool-rail"><div className="rail-h">商品空间</div>{products.slice(0, 6).map((product) => <button className="rail-product" type="button" key={product.id}><div className="placeholder"><span className="ph-frame">{product.title.slice(0, 4)}</span></div><span>{product.title}</span></button>)}</aside><section className="pane"><div className="pane-h"><strong>方案 {variant}</strong><span className="spacer" /><span className="mono muted-2">DEMO</span></div><div className="result-board dense">{Array.from({ length: 6 }).map((_, index) => <div className="result-tile" key={index}><div className="placeholder"><span className="ph-frame">MODEL {index + 1}</span></div></div>)}</div></section></div></>;
}
+155
View File
@@ -0,0 +1,155 @@
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { api } from "../api";
import type { Team, User } from "../types";
import type { AuthMode } from "./route-config";
export function AuthScreen({
initialMode,
onModeChange,
onAuthed
}: {
initialMode: AuthMode;
onModeChange: (mode: AuthMode) => void;
onAuthed: (payload: { token: string; user: User; team: Team }) => void;
}) {
const [mode, setMode] = useState<AuthMode>(initialMode);
const [username, setUsername] = useState("li@shop.com");
const [password, setPassword] = useState("demo-1234");
const [teamName, setTeamName] = useState("");
const [email, setEmail] = useState("");
const [registerPassword, setRegisterPassword] = useState("");
const [registerPassword2, setRegisterPassword2] = useState("");
const [agreed, setAgreed] = useState(true);
const [showPwd, setShowPwd] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => setMode(initialMode), [initialMode]);
useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []);
function showToast(title: string, sub: string) {
setToast({ title, sub });
if (toastTimer.current) clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(null), 2800);
}
function switchMode(next: AuthMode) {
setMode(next);
setError("");
onModeChange(next);
}
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
if (mode === "register") {
if (registerPassword.length < 8) throw new Error("密码至少 8 位");
if (registerPassword !== registerPassword2) throw new Error("两次密码不一致");
if (!agreed) throw new Error("请同意用户协议");
}
const payload =
mode === "login"
? await api.login({ username, password })
: await api.register({
username: email,
email,
password: registerPassword,
team_name: teamName
});
onAuthed(payload);
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
} finally {
setBusy(false);
}
}
return (
<main className={`auth-exact-page ${mode === "register" ? "auth-register-page" : "auth-login-page"}`}>
<a className="top-back" href={mode === "register" ? "/login" : "/dashboard"} onClick={(event) => { if (mode === "register") { event.preventDefault(); switchMode("login"); } }}>
{mode === "register" ? "← 返回登录" : "← 返回工作台"}
</a>
<div className="auth-wrap">
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
<aside className="auth-brand">
<div className="logo"><img className="logo-img" src="/assets/logo-dark.png" alt="Airshelf" /></div>
<div className="tag">// SHORT-VIDEO COMMERCE PLATFORM</div>
<div className="hero">
{mode === "login" ? <><h1>AI <br /><span className="h"></span></h1><p> AI Seedance 9:16 60s </p></> : <><h1>,<br /> <span className="h">AI </span> </h1><p> / 1-2 ,,</p></>}
</div>
<div className="ascii">
<div className="ln"><span className="k">// step·1</span> &nbsp; 脚本生成 &nbsp; &nbsp; <span className="v">●●●●●</span></div>
<div className="ln"><span className="k">// step·2</span> &nbsp; 基础资产 &nbsp; &nbsp; <span className="v">●●●●○</span></div>
<div className="ln"><span className="k">// step·3</span> &nbsp; 故事板 &nbsp; &nbsp; &nbsp; <span className="v">●●●○○</span></div>
<div className="ln"><span className="k">// step·4</span> &nbsp; 视频片段 &nbsp; &nbsp; <span className="v">●●○○○</span></div>
<div className="ln"><span className="k">// step·5</span> &nbsp; 拼接导出 &nbsp; &nbsp; <span className="v">●○○○○</span></div>
</div>
<div className="foot"><a href="#"></a><a href="#"></a><a href="#"></a><a href="#"></a></div>
</aside>
<main className="auth-form">
<div className="h-row"><h2>{mode === "login" ? "登录" : "注册团队"}</h2><span className="sub">{mode === "login" ? "// /auth/login" : "// /auth/register"}</span></div>
<p className="lead">{mode === "login" ? "使用团队邀请邮箱登录,接受邀请后自动加入对应团队。" : "填写团队信息开通账户,默认成为团队超管。"}</p>
<form id={mode === "login" ? "login-form" : "register-form"} autoComplete="off" onSubmit={submit}>
{mode === "login" ? (
<>
<div className="field">
<label className="field-label" htmlFor="auth-email"> <span className="req">*</span></label>
<div className="field-input-wrap">
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" /><path d="m22 6-10 7L2 6" /></svg>
<input type="email" id="auth-email" placeholder="name@company.com" value={username} onChange={(event) => setUsername(event.target.value)} required />
</div>
</div>
<div className="field">
<label className="field-label" htmlFor="auth-pwd"> <span className="req">*</span></label>
<div className="field-input-wrap">
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
<input type={showPwd ? "text" : "password"} id="auth-pwd" placeholder="••••••••" value={password} onChange={(event) => setPassword(event.target.value)} required />
<button type="button" className="toggle-pwd" aria-label="切换密码可见" onClick={() => setShowPwd((value) => !value)}>
<svg id="pwd-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z" /><circle cx="12" cy="12" r="3" /></svg>
</button>
</div>
</div>
<div className="row-between">
<label><input type="checkbox" defaultChecked /> 7 </label>
<a href="#" onClick={(event) => { event.preventDefault(); showToast("已发送重置邮件", "请到 li@shop.com 收件箱查看 · 链接 30 分钟有效"); }}>?</a>
</div>
{error && <div className="form-error">{error}</div>}
<button className="btn-cta" type="submit" disabled={busy}>
{busy ? <span className="busy-copy">// 验证中...</span> : <>登录<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></>}
</button>
<div className="divider"><span className="line"></span><span className="txt">OR</span><span className="line"></span></div>
<div className="sso-row">
<button type="button" className="sso-btn" onClick={() => showToast("微信扫码", "请在 60s 内用微信扫一扫完成授权 · 内测中,以邮箱登录为准")}>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 4C5 4 2 6.5 2 9.5c0 1.7 1 3.2 2.5 4.2L4 16l2.2-1.1c.7.2 1.5.3 2.3.3-.3-.5-.5-1.1-.5-1.7 0-2.8 2.9-5 6.5-5h.5C14.6 6 12 4 9 4zm-2 3.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zM15 9c-3.3 0-6 2.2-6 5s2.7 5 6 5c.7 0 1.4-.1 2-.3L19 20l-.4-1.7c1.4-.9 2.4-2.3 2.4-3.8 0-2.8-2.7-5-6-5zm-2 2.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1z" /></svg>
</button>
<button type="button" className="sso-btn" onClick={() => showToast("飞书 SSO", "即将打开企业飞书登录授权页 · 内测中,以邮箱登录为准")}>
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="18" height="18" rx="3" /></svg>
SSO
</button>
</div>
<div className="switch-row">? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}> </a></div>
</>
) : (
<>
<div className="field"><label className="field-label" htmlFor="reg-team"> <span className="req">*</span></label><div className="field-input-wrap"><input id="reg-team" value={teamName} onChange={(event) => setTeamName(event.target.value)} required /></div></div>
<div className="field"><label className="field-label" htmlFor="reg-email"> <span className="req">*</span></label><div className="field-input-wrap"><input type="email" id="reg-email" value={email} onChange={(event) => setEmail(event.target.value)} required /></div></div>
<div className="field-row"><div className="field"><label className="field-label" htmlFor="reg-pwd"> <span className="req">*</span></label><div className="field-input-wrap"><input type="password" id="reg-pwd" value={registerPassword} onChange={(event) => setRegisterPassword(event.target.value)} required /></div></div><div className="field"><label className="field-label" htmlFor="reg-pwd2"> <span className="req">*</span></label><div className="field-input-wrap"><input type="password" id="reg-pwd2" value={registerPassword2} onChange={(event) => setRegisterPassword2(event.target.value)} required /></div></div></div>
<label className="agree"><input type="checkbox" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} /><span> </span></label>
{error && <div className="form-error">{error}</div>}
<button className="btn-cta" type="submit" disabled={busy}>{busy ? <span className="busy-copy">// 验证中...</span> : "创建团队 · 开始使用"}</button>
<div className="switch-row">? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}> </a></div>
</>
)}
</form>
</main>
</div>
{toast && <div className="login-toast"><div>{toast.title}</div><div style={{ fontSize: "11.5px", color: "rgba(0,0,0,.56)", fontWeight: 400, letterSpacing: ".02em" }}>// {toast.sub}</div></div>}
</main>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { Film, Home, Library, Package, Plus, Wallet } from "lucide-react";
import type { Asset, BillingSummary, Product, Project } from "../types";
import type { Page } from "./route-config";
import { money, stageMeta, statusPill } from "./stage-config";
import { Progress } from "../components/pipeline-stage";
export function Dashboard({ products, projects, assets, billing, userName, navigate }: {
products: Product[];
projects: Project[];
assets: Asset[];
billing: BillingSummary | null;
userName?: string;
navigate: (page: Page) => void;
}) {
const completed = projects.filter((project) => project.status === "completed").length;
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
return (
<>
<div className="page-head"><div><h1>{userName ? `${userName}` : ""}</h1><div className="sub"><span className="mono">// {new Date().toLocaleDateString("zh-CN")}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span></div></div><div className="actions"><button className="btn" type="button" onClick={() => navigate("products")}><Plus size={13} />新建商品</button><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate("projectWizard")}><Plus size={13} />新建项目</button></div></div>
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projects.length} delta={`↑ 本月 +${Math.max(projects.length, 0)}`} /><KpiStat label="进行中" badge="WIP" value={running} delta="待处理" /><KpiStat label="成片" badge="DONE" value={completed} delta="导出完成" /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl"> <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: "38%" }} /></div><div className="sub"> {money(billing?.account.reserved_balance)}</div></button></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projects.length} ] </button></div><div className="card-hard">{projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI / 4 </div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div className="dash-side"><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut icon={Package} title="" desc={`${products.length} SKU`} onClick={() => navigate("products")} /><Shortcut icon={Library} title="" desc={`${assets.length} assets`} onClick={() => navigate("library")} /><Shortcut icon={Wallet} title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut icon={Film} title="" desc={`${projects.length} `} onClick={() => navigate("projects")} /></div></div></div>
</>
);
}
export function KpiStat({ label, badge, value, delta }: { label: string; badge: string; value: string | number; delta: string }) {
return <div className="stat"><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div><div className="delta up">{delta}</div></div>;
}
export function Shortcut({ icon: Icon, title, desc, onClick }: { icon: typeof Home; title: string; desc: string; onClick: () => void }) {
return <button className="shortcut" type="button" onClick={onClick}><div className="ic"><Icon size={15} /></div><div><div className="t">{title}</div><div className="d">{desc}</div></div></button>;
}
@@ -0,0 +1,72 @@
import { IconKitSvg } from "../components/IconKitSvg";
import { CornerMark } from "../components/app-shell";
import type { NavigateFn, Page } from "./route-config";
export function ExactDashboardApp({ navigate, logout }: { navigate: NavigateFn; logout: () => void }) {
const go = (next: Page) => (event: { preventDefault: () => void }) => {
event.preventDefault();
navigate(next);
};
return (
<div className="app">
<aside className="sidebar">
<div className="sidebar-head"><a className="brand" href="/dashboard" onClick={go("dashboard")}><span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span></a></div>
<div className="search-box"><IconKitSvg name="search" /><input placeholder="搜索" readOnly /><span className="kbd">Ctrl K</span></div>
<div className="nav-section"></div>
<nav>
<a href="/dashboard" className="active" onClick={go("dashboard")}><IconKitSvg name="dashboard" /><span></span></a>
<a href="/products" onClick={go("products")}><IconKitSvg name="package" /><span></span><span className="pill-mini">7</span></a>
<a href="/projects" onClick={go("projects")}><IconKitSvg name="clapperboard" /><span></span><span className="pill-mini">8</span></a>
<a href="/asset-factory" onClick={go("assetFactory")}><IconKitSvg name="sparkles" /><span></span></a>
<a href="/library" onClick={go("library")}><IconKitSvg name="library" /><span></span></a>
<a href="/team" onClick={go("team")}><IconKitSvg name="users" /><span></span></a>
<a href="/account" onClick={go("account")}><IconKitSvg name="creditCard" /><span></span></a>
<a href="/settings" onClick={go("settings")}><IconKitSvg name="settings" /><span></span></a>
</nav>
<div className="aside-foot"><div className="user"><div className="av"></div><div className="em"></div></div></div>
</aside>
<main>
<div className="grid-bg"></div>
<header className="topbar">
<div className="crumbs"><span className="here"></span></div>
<div className="right">
<button className="balance-chip" type="button" onClick={() => navigate("account")}><IconKitSvg name="creditCard" /> <strong>¥327.40</strong></button>
<button className="icon-btn" type="button" onClick={() => navigate("messages")}><IconKitSvg name="bell" /><span className="count-noti">12</span></button>
<button className="topbar-avatar" type="button" onDoubleClick={logout}><span></span></button>
</div>
</header>
<div className="content" id="page-content">
<CornerMark pos="tl" /><CornerMark pos="tr" /><CornerMark pos="bl" /><CornerMark pos="br" />
<div className="page-head">
<div><h1></h1><div className="sub"><span className="mono">// 05.14 · 周三</span><span>·</span><span>你有 <b>3 个项目</b> 正在进行中</span></div></div>
<div className="actions"><a className="btn btn-create" href="/products/new" onClick={go("productCreateUpload")}><IconKitSvg name="productPlus" /></a><a className="btn btn-primary btn-lg btn-create" href="/projects/new" onClick={go("projectWizard")}><IconKitSvg name="clapperboard" /></a></div>
</div>
<div className="stats with-corners">
<span className="corner-tr" /><span className="corner-bl" />
<a className="stat" href="/projects" onClick={go("projects")}><div className="lbl"> <span className="badge">ALL</span></div><div className="v">8</div><div className="delta up"><IconKitSvg name="arrowUp" size={14} /> +3</div></a>
<a className="stat" href="/projects" onClick={go("projects")}><div className="lbl"> <span className="badge">WIP</span></div><div className="v">3</div><div className="delta">2 </div></a>
<a className="stat" href="/projects" onClick={go("projects")}><div className="lbl"> <span className="badge">DONE</span></div><div className="v">3</div><div className="delta up"> +33%</div></a>
<a className="stat" href="/account" onClick={go("account")}><div className="lbl"> <span className="badge">¥</span></div><div className="v">¥327<small>.40</small></div><div className="bar"><span style={{ width: "33%" }} /></div><div className="sub"> ¥162.60 / ¥500</div></a>
</div>
<div className="dash-grid">
<div><div className="section-h"><h2></h2><a className="more" href="/projects" onClick={go("projects")}>[ ALL · 8 ]</a></div><div className="card-hard">{["补水面膜 · 痛点种草 · v3", "透真防晒 · 通勤对比", "蓝牙耳机 · 开箱测评", "春日新品 · 立体口红"].map((title) => <ExactRecentRow key={title} title={title} go={go("pipeline")} />)}</div></div>
<div className="dash-side"><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts">{[["", "products"], ["", "library"], ["", "account"], ["", "projects"]].map(([label, target]) => <a className="shortcut" href={`/${target}`} onClick={go(target as Page)} key={label}><div className="ic"><IconKitSvg name="package" /></div><div><div className="t">{label}</div><div className="d"></div></div></a>)}</div></div>
</div>
</div>
</main>
</div>
);
}
export function ExactRecentRow({ title, go }: { title: string; go: (event: { preventDefault: () => void }) => void }) {
return (
<a className="recent-row" href="/pipeline" onClick={go}>
<div className="placeholder thumb"><span className="ph-frame">9:16</span></div>
<div className="recent-meta"><div className="name">{title}</div><div className="sub">AI / 4 </div></div>
<div className="prog"><span className="done" /><span className="done" /><span className="cur" /><span /><span /></div>
<span className="pill info"><span className="dot" /></span>
<span className="btn btn-sm"></span>
</a>
);
}
+349
View File
@@ -0,0 +1,349 @@
import { useEffect, useMemo, useRef } from "react";
import type { FormEvent } from "react";
import { exactHtmlDocuments } from "./exact-html";
import type { ExactHtmlKey } from "./exact-html";
import type { AuthMode, NavigateFn, Page } from "./route-config";
const fileToPage: Record<string, Page | "login" | "register" | null> = {
"account.html": "account",
"asset-factory.html": "assetFactory",
"image-optimize.html": "imageOptimize",
"index.html": "dashboard",
"library.html": "library",
"login.html": "login",
"messages.html": "messages",
"model-photo.html": "modelPhoto",
"model-photo-demo-a.html": "modelPhotoDemoA",
"model-photo-demo-b.html": "modelPhotoDemoB",
"pipeline.html": "pipeline",
"platform-cover.html": "platformCover",
"product-create.html": "productCreateUpload",
"product-create-upload.html": "productCreateUpload",
"product-detail.html": "productDetail",
"products.html": "products",
"projects-new.html": "projectWizard",
"projects.html": "projects",
"register.html": "register",
"settings.html": "settings",
"team.html": "team"
};
const pageToExactKey: Record<Page, ExactHtmlKey> = {
dashboard: "dashboard",
products: "products",
productDetail: "productDetail",
productCreateUpload: "productCreateUpload",
projects: "projects",
projectWizard: "projectWizard",
pipeline: "pipeline",
library: "library",
account: "account",
team: "team",
messages: "messages",
assetFactory: "assetFactory",
imageOptimize: "imageOptimize",
modelPhoto: "modelPhoto",
modelPhotoDemoA: "modelPhotoDemoA",
modelPhotoDemoB: "modelPhotoDemoB",
platformCover: "platformCover",
settings: "settings",
settingsNotify: "settings"
};
const exactKeyToPage: Partial<Record<ExactHtmlKey, Page | "login" | "register">> = {
account: "account",
assetFactory: "assetFactory",
dashboard: "dashboard",
imageOptimize: "imageOptimize",
library: "library",
login: "login",
messages: "messages",
modelPhoto: "modelPhoto",
modelPhotoDemoA: "modelPhotoDemoA",
modelPhotoDemoB: "modelPhotoDemoB",
pipeline: "pipeline",
platformCover: "platformCover",
productCreate: "productCreateUpload",
productCreateUpload: "productCreateUpload",
productDetail: "productDetail",
products: "products",
projectWizard: "projectWizard",
projects: "projects",
register: "register",
settings: "settings",
team: "team"
};
const exactKeyToFile: Record<ExactHtmlKey, string> = {
account: "account.html",
assetFactory: "asset-factory.html",
dashboard: "index.html",
imageOptimize: "image-optimize.html",
library: "library.html",
login: "login.html",
messages: "messages.html",
modelPhoto: "model-photo.html",
modelPhotoDemoA: "model-photo-demo-a.html",
modelPhotoDemoB: "model-photo-demo-b.html",
pipeline: "pipeline.html",
platformCover: "platform-cover.html",
productCreate: "product-create.html",
productCreateUpload: "product-create-upload.html",
productDetail: "product-detail.html",
products: "products.html",
projectWizard: "projects-new.html",
projects: "projects.html",
register: "register.html",
settings: "settings.html",
team: "team.html"
};
const liveHydratePages = new Set<ExactHtmlKey>([
"dashboard",
"products",
"productDetail",
"projectWizard",
"projects",
"pipeline",
"library",
"account",
"settings",
"team"
]);
function routeFromHref(rawHref: string | null) {
if (!rawHref || rawHref === "#" || rawHref.startsWith("javascript:")) return null;
const url = new URL(rawHref, "https://airshelf.local/exact/");
const fileName = url.pathname.split("/").filter(Boolean).pop() || "index.html";
const page = fileToPage[fileName];
if (!page) return null;
const params = new URLSearchParams(url.search);
return {
page,
hash: url.hash || "",
search: url.search || "",
productId: params.get("product_id") || undefined,
projectId: params.get("project_id") || undefined
};
}
function routeFromInlineAction(action: string | null) {
if (!action) return null;
const hrefMatch = action.match(/location\.href\s*=\s*['"]([^'"]+)/);
if (hrefMatch) return routeFromHref(hrefMatch[1]);
const hashMatch = action.match(/location\.hash\s*=\s*['"]([^'"]+)/);
if (hashMatch) return { page: null, hash: hashMatch[1], search: "" };
return null;
}
function setFrameHeight(frame: HTMLIFrameElement) {
frame.style.height = `${Math.max(window.innerHeight, 720)}px`;
}
export type ExactDocumentPageProps = {
pageKey: ExactHtmlKey;
hash?: string;
productId?: string;
projectId?: string;
navigate?: NavigateFn;
onAuthModeChange?: (mode: AuthMode) => void;
onAuthSubmit?: (mode: AuthMode, event: FormEvent<HTMLFormElement>, form: HTMLFormElement) => void;
};
export function exactKeyForPage(page: Page): ExactHtmlKey {
return pageToExactKey[page] || "dashboard";
}
function contextSearch(pageKey: ExactHtmlKey, productId?: string, projectId?: string) {
const params = new URLSearchParams();
if (pageKey === "productDetail" && productId) params.set("product_id", productId);
if (pageKey === "pipeline" && projectId) params.set("project_id", projectId);
const text = params.toString();
return text ? `?${text}` : "";
}
export function ExactDocumentPage({
pageKey,
hash,
productId,
projectId,
navigate,
onAuthModeChange,
onAuthSubmit
}: ExactDocumentPageProps) {
const frameRef = useRef<HTMLIFrameElement | null>(null);
const html = useMemo(() => {
const context = {
page: exactKeyToFile[pageKey],
search: contextSearch(pageKey, productId, projectId),
hash: hash ? `#${hash.replace(/^#/, "")}` : "",
liveHydrate: liveHydratePages.has(pageKey)
};
return exactHtmlDocuments[pageKey].replace(
"</head>",
`<script>window.__AIR_SHELF_EXACT_CONTEXT__=${JSON.stringify(context)};</script></head>`
);
}, [hash, pageKey, productId, projectId]);
useEffect(() => {
const frame = frameRef.current;
if (!frame) return;
const currentFrame: HTMLIFrameElement = frame;
function onLoad() {
const doc = currentFrame.contentDocument;
const win = currentFrame.contentWindow;
if (!doc || !win) return;
(win as Window & { __AIR_SHELF_HOST_NAVIGATE__?: (href: string) => void }).__AIR_SHELF_HOST_NAVIGATE__ = (
href: string
) => {
const hostRoute = routeFromHref(href);
if (!hostRoute?.page) return;
if (hostRoute.page === "login" || hostRoute.page === "register") {
onAuthModeChange?.(hostRoute.page);
return;
}
navigate?.(hostRoute.page, {
hash: hostRoute.hash || undefined,
productId: hostRoute.productId || (hostRoute.page === "productDetail" ? productId : undefined),
projectId: hostRoute.projectId || (hostRoute.page === "pipeline" ? projectId : undefined)
});
};
const applyFrameHash = (nextHash: string) => {
const cleanHash = nextHash.replace(/^#/, "");
const stageMatch = cleanHash.match(/^stage-(\d+)$/);
const pipelineWindow = win as Window & { activateStage?: (stage: number) => void };
if (pageKey === "pipeline" && stageMatch && typeof pipelineWindow.activateStage === "function") {
pipelineWindow.activateStage(Number(stageMatch[1]));
return;
}
const settingsWindow = win as Window & { showSection?: (sectionId: string) => void };
if (pageKey === "settings" && cleanHash.startsWith("sec-") && typeof settingsWindow.showSection === "function") {
settingsWindow.showSection(cleanHash);
return;
}
doc.getElementById(cleanHash)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
if (hash) {
setTimeout(() => {
applyFrameHash(hash);
}, 0);
}
const clickHandler = (event: MouseEvent) => {
const target = event.target as Element | null;
if (!target) return;
const syncHashOnlyRoute = (nextHash: string) => {
const cleanHash = nextHash.replace(/^#/, "");
applyFrameHash(cleanHash);
if (exactKeyToPage[pageKey]) {
const nextUrl = `${window.location.pathname}${window.location.search}#${cleanHash}`;
window.history.replaceState(null, "", nextUrl);
}
};
const actionNode = target.closest("[onclick]") as HTMLElement | null;
const actionRoute = routeFromInlineAction(actionNode?.getAttribute("onclick") || null);
if (actionRoute?.hash && actionRoute.page === null) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
syncHashOnlyRoute(actionRoute.hash);
return;
}
if (actionRoute?.page) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (actionRoute.page === "login" || actionRoute.page === "register") {
onAuthModeChange?.(actionRoute.page);
return;
}
navigate?.(actionRoute.page, {
hash: actionRoute.hash || undefined,
productId: actionRoute.productId || (actionRoute.page === "productDetail" ? productId || "exact" : undefined),
projectId: actionRoute.projectId
});
return;
}
const anchor = target.closest("a[href]") as HTMLAnchorElement | null;
const rawAnchorHref = anchor?.getAttribute("href") || null;
if (rawAnchorHref?.startsWith("#")) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
syncHashOnlyRoute(rawAnchorHref);
return;
}
const anchorRoute = routeFromHref(rawAnchorHref);
if (!anchorRoute) return;
event.preventDefault();
event.stopPropagation();
if (anchorRoute.page === "login" || anchorRoute.page === "register") {
onAuthModeChange?.(anchorRoute.page);
return;
}
navigate?.(anchorRoute.page, {
hash: anchorRoute.hash || undefined,
productId: anchorRoute.productId || (anchorRoute.page === "productDetail" ? productId || "exact" : undefined),
projectId: anchorRoute.projectId
});
};
const submitHandler = (event: SubmitEvent) => {
const form = event.target as HTMLFormElement | null;
if (!form) return;
const isLogin = pageKey === "login" && form.id === "login-form";
const isRegister = pageKey === "register" && form.id === "register-form";
if (!isLogin && !isRegister) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
onAuthSubmit?.(isLogin ? "login" : "register", event as unknown as FormEvent<HTMLFormElement>, form);
};
doc.addEventListener("click", clickHandler, true);
doc.addEventListener("submit", submitHandler, true);
setFrameHeight(currentFrame);
const resizeFrame = () => setFrameHeight(currentFrame);
window.addEventListener("resize", resizeFrame);
const observer = new ResizeObserver(() => setFrameHeight(currentFrame));
observer.observe(doc.documentElement);
observer.observe(doc.body);
const cleanup = () => {
doc.removeEventListener("click", clickHandler, true);
doc.removeEventListener("submit", submitHandler, true);
window.removeEventListener("resize", resizeFrame);
observer.disconnect();
};
currentFrame.dataset.cleanupKey = String(Date.now());
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup?.();
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup = cleanup;
}
currentFrame.addEventListener("load", onLoad);
if (currentFrame.contentDocument?.readyState !== "loading") onLoad();
return () => {
currentFrame.removeEventListener("load", onLoad);
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup?.();
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup = undefined;
};
}, [hash, navigate, onAuthModeChange, onAuthSubmit, pageKey]);
return (
<div className="exact-document-route" data-exact-page={pageKey}>
<iframe
ref={frameRef}
title={`Airshelf ${pageKey}`}
className="exact-document-frame"
srcDoc={html}
/>
</div>
);
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
import { ExactDocumentPage } from "../exact-document";
import type { ExactDocumentPageProps } from "../exact-document";
export function ExactAccountPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
return <ExactDocumentPage {...props} pageKey="account" />;
}
export default ExactAccountPage;
@@ -0,0 +1,9 @@
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
import { ExactDocumentPage } from "../exact-document";
import type { ExactDocumentPageProps } from "../exact-document";
export function ExactAssetFactoryPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
return <ExactDocumentPage {...props} pageKey="assetFactory" />;
}
export default ExactAssetFactoryPage;

Some files were not shown because too many files have changed in this diff Show More