`)
+
+### 11.4 强调单词上色
+正文里 `3 个项目` —— 让具体数值/名词比周围文字更深一档(不是变橙)。**橙色只留给 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 档 · 0–24 用 #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)(保留作为历史)
diff --git a/core/frontend/public/exact/_archive/deprecated-pages-20260528/README.md b/core/frontend/public/exact/_archive/deprecated-pages-20260528/README.md
new file mode 100644
index 0000000..3b64fbd
--- /dev/null
+++ b/core/frontend/public/exact/_archive/deprecated-pages-20260528/README.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.
diff --git a/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-create-v2.html b/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-create-v2.html
new file mode 100644
index 0000000..ae0e184
--- /dev/null
+++ b/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-create-v2.html
@@ -0,0 +1,414 @@
+
+
+
+
+新建商品 · Airshelf
+
+
+
+
+
+
+
+
+
+
新建商品
+
// 上传原图 + 填写基本信息 · 保存后可在工作台逐步丰富素材
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-create.legacy.html b/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-create.legacy.html
new file mode 100644
index 0000000..022d1d5
--- /dev/null
+++ b/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-create.legacy.html
@@ -0,0 +1,3884 @@
+
+
+
+
+新建商品 · Airshelf
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-studio.html b/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-studio.html
new file mode 100644
index 0000000..7f9e640
--- /dev/null
+++ b/core/frontend/public/exact/_archive/deprecated-pages-20260528/product-studio.html
@@ -0,0 +1,1085 @@
+
+
+
+
+商品工作台 · Airshelf
+
+
+
+
+
+
+
+
+
+
透真补水面膜
+
// 商品工作台 · 已生成 11 张资产 · 创建于 5/19
+
+
+
+
+
+
+
+
+
商品已建档 · 推荐先生成白底三视图
+
AI 以「主图」为基准生成正/侧/背 3 张白底图,Seedance 视频里商品的清晰度和稳定性 +60%。约 18 秒、¥1.6 · 失败不扣费。
+
+
+
+
+
+
+
+
+
+
+
透真玻尿酸补水面膜
+
[ 美妆个护 ] · ¥39.9 · 22-32 岁女性
+
+
+
+
+ 熬夜党
+ 敏感肌
+ 补水
+ 玻尿酸
+ 通勤
+
+
+ // 卖点
+ 玻尿酸双效保湿 · 4 小时持久水润 · 敏感肌可用 · 通勤补水 · 平价代替
+
+
+
+
+
+ 原图册 · 3 / 5
+ JPG / PNG · ≤ 5MB
+
+
+
+
+
+
+
+
AI 工具箱
+ // 按需调用 · 生成结果自动入资产库 · 失败不扣费
+
+
+
+
+
+
+
已生成资产
+ // 自动入「资产库 / 跨项目共享 / 商品图」
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
选择模特
// 系统已有 8 位模特 · 每次生成 4 张上身图
+
+
+
+
+
+
+
+
+ 室内自拍
+ 梳妆台
+ 户外清晨
+ 纯色背景
+
+
+
+
+
+ 半身
+ 特写
+ 全身
+
+
+
+
+ 4 张 · 不满意可原地重跑
+
+
+
+
+
// 预计耗时 35 秒 · ¥3.2 · 失败不扣费
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
生成白底三视图
// 单张 16:9 · 正 / 侧 / 背 合一
+
+
+
+
+ AI 会以「主图」为基准,自动去白底 + 重打光 + 推算另外两个视角。商品形态尽量保持稳定。
+
+
+
+
+
+ 正面
+ 侧面
+ 背面
+
+
+
+
+
+ 纯白
+ 浅灰
+ 柔和渐变
+
+
+
+
+
+
// 预计 18 秒 · ¥1.6
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/frontend/public/exact/_archive/deprecated-pages-20260528/studio-v2.html b/core/frontend/public/exact/_archive/deprecated-pages-20260528/studio-v2.html
new file mode 100644
index 0000000..f7d0d08
--- /dev/null
+++ b/core/frontend/public/exact/_archive/deprecated-pages-20260528/studio-v2.html
@@ -0,0 +1,1403 @@
+
+
+
+
+商品工作台 V2 · Airshelf
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// ~35 秒 · ¥3.2
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/frontend/public/exact/_archive/deprecated-pages-20260528/studio.html b/core/frontend/public/exact/_archive/deprecated-pages-20260528/studio.html
new file mode 100644
index 0000000..7490fb7
--- /dev/null
+++ b/core/frontend/public/exact/_archive/deprecated-pages-20260528/studio.html
@@ -0,0 +1,1635 @@
+
+
+
+
+商品工作台 · Airshelf
+
+
+
+
+
+
+
+
+
+
+
+
+
+ AI 会以「主图」为基准,自动去白底 + 重打光 + 推算另外两个视角。
+
+
+
+
+
+ 正面
+ 侧面
+ 背面
+
+
+
+
+
+ 纯白
+ 浅灰
+ 柔和渐变
+
+
+
+
+
+ // 预计 18 秒 · ¥1.6
+
+
+
+
+
+
+
+
+
+
+
+ // 预计 35 秒 · ¥3.2 · 失败不扣费
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/frontend/public/exact/_archive/design-system.html b/core/frontend/public/exact/_archive/design-system.html
new file mode 100644
index 0000000..37fd368
--- /dev/null
+++ b/core/frontend/public/exact/_archive/design-system.html
@@ -0,0 +1,1730 @@
+
+
+
+
+
+流·Studio 设计系统 V2 · Interactive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// V2 · INTERACTIVE
+
流·Studio 设计系统
+
所有 token、组件、状态的可交互参照。基于 Firecrawl Playground 实测规范校准。 hover / 点击下方组件可看到真实交互反馈。
+
+ [ Restraint · v2.0 ]
+ ·
+ // based on DESIGN_SPEC_V2.md
+
+
+
+
+
+
+
+
+
// §1 · COLOR TOKENS · V2.1 · FIRECRAWL-ALIGNED
+
色彩系统
+
V2.1 全面对齐 Firecrawl Playground 实测色板:**冷灰底**(非米白)· **#FA5D19 主橙**(更亮一档)· **20 档 black-alpha 阶梯**(替代 11 档 ink-alpha)· **5 色 accent 多彩点**(amethyst / bluetron / crimson / forest / honey)。点击任何色块复制值。
+
+
+
+
表面 / 背景 // 4 档 · 冷灰无色相
+
告别 V2 的暖米白(#FAF9F5),全面切换 Firecrawl 的纯冷灰。--bg/--bg-soft/--card 作为 legacy 别名仍可用。
+
+
+
+
+
边框 // 3 档 · 冷灰 · 差距极小靠语义
+
3 档相差只 1–2 个色阶,肉眼几乎看不出。**用语义,不用视觉对比**——80% 场景用 --border-faint。
+
+
+
+
+
主橙 Heat // 单 hue #FA5D19 + 8 档 alpha
+
从 V2 砖红 #E55B26 调亮到 Firecrawl 实测 #FA5D19(更红更饱和)。**全靠 alpha 叠加,绝不换 hue。** hover 不再切换到更深的橙,而是用 90% / 16% 这些档位组合。
+
+
+
+
+
Accent 多彩点 // 5 色信号 · 限定语义场景
+
**新增章节 · 取自 Firecrawl 实测**。这 5 色只用于**语义信号**(代码高亮 / info / 状态色),**禁止做大面积装饰**——全场依然只有橙色一个 accent。--accent-black 替代 V2 的 --ink #15140F(更柔和的灰黑)。
+
+
+
+
+
Black-Alpha 阶梯 // 20 档 · 替代 V2 的 11 档 ink-alpha
+
**核心工具尺。** 0–24% 用 rgba(0,0,0,...) 纯黑透明;**32% 起换 rgba(38,38,38,...)**(即 accent-black 作底)避免叠出"灰中带蓝"——这是 Firecrawl 的细节技巧,我们 1:1 复刻。dark mode 时这套 token 自动翻转为 white-alpha。原 --ink-alpha-* 全部作为 legacy 别名映射到对应 black-alpha。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
状态色 // 用 accent-forest / crimson 作语义
+
V2 的 --green #3F6B3F(深森林绿)与 --red #B33A2A(暗砖红)替换为 Firecrawl 的 --accent-forest #42c366 与 --accent-crimson #eb3424——更明亮、更接近真实"信号灯"颜色,但仍保持非荧光。
+
+
+
+
+
Selection 选中色 // Firecrawl 签名细节
+
页面内任何文字选中时,底色 --heat-20 + 字色 --heat。**试着选中下面这行字看效果:**
+
本月营收 ¥327,400 较上月增长 33%,有 5 个项目处于"生成中"状态。其中 2 个需要重新调整模特模板。
+
+
+
+
+
+
+
+
+
// §2 · TYPE · V2.1 MIXED STRATEGY
+
字体系统 · 中英协作
+
V2.1 改为**中英双字体协作** —— Inter 处理英文/数字,Alibaba PuHuiTi 处理中文,装饰用 JetBrains Mono。浏览器按字符级 fallthrough,Inter 不含 CJK 字形 → 中文自动跳到普惠体。**不需要 JS,中英自然分工。**
+
+
+
+
字体族原理 // browser-level character fallthrough
+
+
+
// 01 · ENGLISH
+
Inter
+
The quick brown fox jumps. 0123456789 — Vercel / Linear / Stripe 御用,屏幕 UI 优化,数字同宽。
+
+
+
// 02 · 中文
+
阿里普惠体
+
为中英混排专门设计的笔画粗细配比,与 Inter 视觉重量贴近,中文版面舒适匀质。
+
+
+
// 03 · 装饰 MONO
+
JetBrains
+
[ 200 OK ] · // 05.14 · /v2 — 仅用于装饰标签,不参与正文。
+
+
+
+
// 混排实测 · MIXED RENDERING
+
本月营收 ¥327,400 较上月 +33%,共 5 个 AI 项目处于 "生成中" 状态
+
↑ 这一行里:中文走 PuHuiTi · "¥" "327,400" "+33%" "5" "AI" 走 Inter · 字重视觉匀质,无错位
+
+
+
+
+
字号 / 字重 / 行高阶梯 // 11 档
+
+
H1 / Hero36 / 500 / -0.024em / 1.2
+
早上好,大莱
+
+
+
区块 H228 / 500 / -0.02em / 1.25
+
设计系统总览
+
+
+
KPI 数值32 / 500 / -0.02em / tabular-nums
+
¥327.40 K
+
+
+
子区 H316 / 500 / -0.01em / 1.4
+
最近项目
+
+
+
卡片标题14 / 500 / normal / 1.4
+
夏季新款蕾丝连衣裙 · 蓝色 / M
+
+
+
正文 body14 / 400 / normal / **1.65**
+
商家可能没有现成的商品图,需要新增一种「AI 生成」模式 —— 商家上传一张随手拍的原图,AI 生成 4 张满意的头图,选 1 张。
+
+
+
Label · 按钮 / Tab13 / 500 / normal
+
查看 Demo
+
+
+
描述次级13 / 400 / --ink-alpha-64 / 1.8
+
本月营收较上月增长 33%,5 个项目处于"生成中"状态,2 个需要重新调整模特模板
+
+
+
Pill 文字11.5 / 500 / normal
+
生成中
+
+
+
Inter Bold · 徽标11.5 / 700 / Inter only
+
Ctrl K · ESC · Enter · ⇧+Tab
+
+
+
Mono 标签11 / 400 / 0.04em · JetBrains
+
[ 200 OK ] [ .MP4 · 9:16 ] [ /v2 ]
+
+
+
Mono 散点装饰8.5 / 400 / 0.04em
+
· · + · +XX+ +XXXX· +X·
+
+
+
+
+
字重档位 // 仅 400 / 500 / 600 / 700
+
+
+
// REGULAR · 400
+
流·Studio · Aa Bb 123
+
+
+
// MEDIUM · 500 ★ 默认强调
+
流·Studio · Aa Bb 123
+
+
+
// SEMIBOLD · 600
+
流·Studio · Aa Bb 123
+
+
+
// BOLD · 700 · 限徽标
+
流·Studio · Aa Bb 123
+
+
+
// Bold 700 仅用于 Ctrl K 这种纯英文徽标场景,**正文严禁 700**(中英字重错位会暴露)
+
+
+
+
+
+
+
+
+
// §3 · RADIUS
+
圆角阶梯
+
V2 核心变化:统一 8 px 作为默认主圆角。完全圆 999 px 仅用于 pill 和 dot。微元素降到 4–6 px。
+
+
+
+
+
+
+
+
+
+
+
// §4 · ICONS
+
Icon 系统
+
统一 SVG line icon · stroke 1.5 · linecap round · 颜色通过 currentColor 继承。禁用 emoji / filled icon。
+
+
+
+
+
+
颜色场景 // 通过 currentColor 继承
+
+
+
+
+
Icon Box // 快捷入口/Modal 头部用
+
+
+
+
+
32×32 · 8 px 圆角 · --heat-12 底 · 16 px line icon
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// §6 · PILLS
+
胶囊 · 严格 3 级分层
+
同级别尺寸必须完全一致。不允许混用。pill 永远是 999 px(完全圆),靠 dot 体现状态。
+
+
+
+
L1 · 大胶囊 // h:28 / fs:13 / dot:8
+
用于项目状态、列表行主标签。
+
+ 生成中
+ 已完成
+ 失败
+
+
+
+
+
L2 · 中胶囊 // h:22 / fs:11.5 / dot:6 ★ 默认
+
最常用尺寸。卡片内 / 表格内默认。
+
+ 生成中
+ 200 OK
+ 超时
+
+
+
+
+
L3 · 小胶囊 // h:18 / fs:10.5 / dot:5
+
KPI 角标 / 行内 Mono 标签场景。
+
+ NEW
+ +33%
+ -1.2%
+
+
+
+
+
对比展示 // 同色 3 级并列看大小差距
+
+ L1 · 生成中
+ L2 · 生成中
+ L3 · 生成中
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// §9 · TABS
+
Tab · 双层结构
+
主 Tab 在区块顶部,带橙色下划线指示;副 Tab 用于过滤,带灰度→彩色 icon 反馈。点击切换。
+
+
+
+
主 Tab
+
+
+
+
+
+
+
当前显示:全部
+
+
+
+
副 Tab · 灰度→彩色
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// §10 · CARDS
+
卡片 / 快捷入口
+
所有卡片统一 8 px 圆角、1 px 边框、无阴影。快捷入口含 hover / active 状态。
+
+
+
+
+
+
+
+
+
+
+ // §11 · KPI
+
统计行 · 4 格 stats
+
+
+
+
+
本月营收+33%
+
¥327.40 K
+
↑ 较上月 +33%
+
+
+
+
+
资产总数
+
847
+
·MP4·JPG·PNG
+
+
+
+
+
+
+
+
+
+
// §12 · LIST
+
列表行
+
hover 我看整行底色变化。
+
+
+
+
+
+
+
+
+
+
+ // §13 · TIP
+
提示框 / 进度
+
+
+
+
+
小提示
+
使用 Ctrl+K 快速搜索任意项目、商品或资产。Tab 切换不同维度,Enter 直达。
+
+
+
+
+
进度条段位 // 5 段 · 流水线专用
+
+
+
+
+
+
+
+
+
+
// §14 · MODAL
+
弹窗 Modal
+
点击下方按钮打开。ESC / 点击遮罩关闭。带 4 角 SVG 准星 + 8 px 圆角 + 半透明遮罩 + 弹性入场。
+
+
+
+
+
+
+
+
+
+
+
// §15 · TOAST
+
Toast 通知
+
右下角浮出。300ms 弹性入场,2400ms 自动消失。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// §16 · SIGNATURE
+
主容器装订线
+
整个工作区被左右两条 1 px 边线包夹,四角放圆弧内凹的 SVG 准星。这是流·Studio 视觉的"图纸"签名。Modal 内不必加。
+
+
+
+
+
+
+
+
+ 这块容器左右贯穿两条 1 px 边线,四角带圆弧内凹的 SVG 准星
+ // container-demo · max-width: 720 · border-x only
+
+
+
+
+
+
+
+
+
+
// §17 · DECOR
+
Mono 装饰元素 · 品牌签名
+
方括号标签 / 双斜杠注释 / 中点连接 —— 这些是流·Studio 独有的"调试视图感",Firecrawl 没有,绝对保留。
+
+
+
+
方括号标签
+
+ [ 200 OK ]
+ [ /v2 ]
+ [ .MP4 · 9:16 ]
+ [ STUDIO ]
+ [ ALL · 12 ] →
+
+
+
+
+
注释样式时间戳
+
// 05.14 · 周五 · 14:32
+
+
+
+
命令路径
+
/sidebar collapse · /toast dismiss · /modal open
+
+
+
+
ASCII 散点(背景装饰)
+
+· · +
+· +XX+
+ +XXXX·
+ +X·
+
+
+
+
+
强调单词上色
+
+ 本月营收较上月增长 +33%,有 5 个项目处于"生成中"状态。其中 2 个需要重新调整模特模板。
+
+
// 关键名词加深一档(不变橙),橙色只留给 CTA
+
+
+
+
+
+
+
+
+
// §18 · GUARDRAILS
+
Don't List · 绝对禁止
+
任何 mockup / 代码 review 时,对照此清单。每一条违反都判错。
+
+
+
+
+
DO
+
用 8 px 统一圆角 + 准星 + 装订线 + mono 装饰做"图纸感"
+
+
+
DON'T
+
用 0 px 硬切角的卡片 —— V1 的做法,V2 起判错
+
+
+
DO
+
橙色 hover 用 --heat-90 等 alpha 阶梯
+
+
+
DON'T
+
hover 时切换到更深的橙 hex(如 #D04E1F)
+
+
+
DO
+
主 CTA 用多层橙色阴影(4 层)制造发光感
+
+
+
DON'T
+
用灰色阴影 / 文字阴影 / 通用 box-shadow 装饰
+
+
+
+
+
×渐变背景 —— 只有 hero 区可考虑,首选纯色。绝禁多色渐变。
+
×玻璃拟态 —— backdrop-filter 只用于 modal 遮罩。
+
×彩色 emoji —— 所有图标必须 SVG line(stroke 1.5)。
+
×多 accent 色 —— 全场只有橙色一个 accent。
+
×大圆角容器(>12 px)—— 直接判错。
+
×鲜艳荧光状态色 —— 避免霓虹绿、电光蓝、霓虹粉。
+
×居中对齐大段正文 —— 全部左对齐。
+
×装饰当主角 —— 场记板 / 丝绒 / 霓虹灯都不要。
+
×无意义微动效 —— hover 旋转、缩放、彩虹流光,禁。
+
×同行混用直角+圆角 —— 用户原话:"不要有些是直角,胶囊又是圆角"。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
确认删除此商品?
+
// /products/dlt · IRREVERSIBLE
+
+
+
+ 该操作不可撤销。商品「夏季新款蕾丝连衣裙」及其关联的 4 张候选图、2 段短视频将一并删除。
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/frontend/public/exact/account.html b/core/frontend/public/exact/account.html
new file mode 100644
index 0000000..066afe8
--- /dev/null
+++ b/core/frontend/public/exact/account.html
@@ -0,0 +1,881 @@
+
+
+
+
+消费 · Airshelf
+
+
+
+
+
+
+
+
+
+
消费
+
// 余额 · 充值 · 4 维消费视图 + 账单流水
+
+
+
+
+
+
+
+
+
+
团队余额
+
¥327.40
+
// 充值累加 · 不重置
+
+
+
+
本月限额
+
¥3,000.00
+
// 按自然月重置
+
+
+
当月已用
+
¥162.60
+
// 占比 5.4% · 健康
+
+
+
+
+
+
+
+
+
+
快速充值
+
// 充值后立刻到账,可开发票 · 仅超管可操作
+
+
已选 ¥500
+
+
+
+
自定义金额
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
消费趋势
+ // 近 14 天 · 单位 ¥
+
+
+
+
+
+
+
+
+
+
+
本月按阶段分布
+
// PRD §5.3.5 扣费规则 · 仅确认后扣
+
视频片段(Seedance)¥98.40
+
+
故事板(image-2)¥36.00
+
+
基础资产¥21.00
+
+
脚本 LLM¥7.20
+
+
合计¥162.60
+
+
+
+
+
扣费 + 四层额度预检规则
+
// PRD §5.3.5 + §10.3 · 对接团队请以此页为准
+
+ ① 失败不扣:模型超时 / 内容审核拦截 / 生成异常一律不扣费。
+ ② 用户重跑不扣首次:第一次重跑保留原扣费,第二次起按次结算。
+ ③ 仅在你点击 [ 确认通过 ] 时入账。
+ ④ 导出不再扣费,所有 token 已在过程中结算。
+
+
+
// 任务确认前 · 四层额度预检(任一不通过即拦截)
+
1个人日剩余 ≥ 任务预估 × 1.2
+
2个人月剩余 ≥ 同上
+
3团队月剩余 ≥ 同上
+
4团队总余额 ≥ 同上
+
+
+
+
+
+
+
+
+
+
+
+ 共 0 个项目 · 消耗 ¥0.00
+
+
+
+
+ | 项目 |
+ 商品 |
+ 所属成员 |
+ 当前阶段 |
+ 状态 |
+ 消耗 |
+
+
+
+
+
+
+
+ // 当前筛选条件下没有项目 · 试试调整状态 / 时间范围
+
+
+
+
+
+
+
+
+
+
+ 共 0 人 · 合计 ¥0.00
+
+
+
+
+ | 成员 |
+ 角色 |
+ 已完成项目 |
+ 已用 / 月度额度 |
+ 最近活跃 |
+
+
+
+
+
+
+
+ // 当前筛选条件下没有成员 · 试试调整角色 / 时间范围
+
+
+
+
+
+
+
+
+
+
+
+ 共 0 条
+
+
+
+
+ | 时间 |
+ 项目 / 类型 |
+ 详情 |
+ 成员 |
+ 状态 |
+ 金额 |
+
+
+
+
+
+
+
+ // 当前筛选条件下没有账单 · 试试调整阶段 / 成员 / 时间范围
+
+
+
+
+
+
+
+
+
+
支付金额
+
¥500.00
+
// 含 ¥30 赠送 · 实到账 ¥530
+
+
// 5 分钟内有效 · 到账后自动关闭
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/frontend/public/exact/asset-factory.html b/core/frontend/public/exact/asset-factory.html
new file mode 100644
index 0000000..d0110cb
--- /dev/null
+++ b/core/frontend/public/exact/asset-factory.html
@@ -0,0 +1,947 @@
+
+
+
+
+图片生成 · Airshelf
+
+
+
+
+
+
+
+
+
+
图片生成
+
+ // 一键生成
+ ·
+ 电商视觉素材,提升内容制作效率
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
[ MODEL · TRY-ON ]
+
模特上身图
+
选择模特,AI 生成商品模特上身效果图
+
+
+ -
+
+
+
+ 支持多模特选择
+
+ -
+
+
+
+ 一次生成 4 张
+
+ -
+
+
+
+ 支持多商品并行
+
+
+
+
+
+
+
+
Ava · 9:16
+
+
变体 01
+
变体 02
+
变体 03
+
+
+
+
+
+
+
+
+
+
+
+
+
[ PLATFORM · KIT ]
+
平台套图
+
选择平台模板,AI 生成电商平台套图
+
+
+ -
+
+
+
+ 覆盖主流电商平台
+
+ -
+
+
+
+ 一键生成 4 张套图
+
+ -
+
+
+
+ 智能排版设计
+
+
+
+
+
+
+
+
套图 / TB
+
套图 / DY
+
套图 / XHS
+
套图 / PDD
+
+
+
+
+
+
+
+
+
+
+
+
[ IMAGE · STUDIO ]
+
图片创作
+
自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写
+
+
+ -
+
+
+
+ 人物 · 商品 全支持
+
+ -
+
+
+
+ 正面 / 侧面 / 背面 一次输出
+
+ -
+
+
+
+ 多镜头一致性保证
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
任务中心
+ // 0 个 · 0 生成中 · 0 已完成 · 0 失败
+
+
+
+
+
全部 0
+
生成中 0
+
已完成 0
+
失败 0
+
+
+
+
+
+
// 显示 0 / 0 个任务
+
+
+
+
+
+
+ | 任务 |
+ 进度 |
+ 状态 |
+ 创建于 |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
// NO TASKS YET
+
还没有任务,去上方选一个工序开始生成吧
+
+
+
+
+
+
+
+
+
+
+
+
确认删除任务// CONFIRM DELETE
+
+
即将删除任务记录。
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/frontend/public/exact/assets/api-bridge.js b/core/frontend/public/exact/assets/api-bridge.js
new file mode 100644
index 0000000..043bbfa
--- /dev/null
+++ b/core/frontend/public/exact/assets/api-bridge.js
@@ -0,0 +1,3120 @@
+(function () {
+ "use strict";
+
+ const TOKEN_KEY = "airshelf_token";
+ const LIVE_KEY = "airshelf_live";
+ const USER_KEY = "airshelf_user";
+ const TEAM_KEY = "airshelf_team";
+ const BRIDGE_VERSION = "20260601-live-api-bridge-notifications";
+ const CACHE_VERSION = "20260601";
+ const CACHE_PREFIX = "airshelf:live-cache:";
+ const requestMemo = {};
+ const context = window.__AIR_SHELF_EXACT_CONTEXT__ || {};
+ const sourceMeta = document.querySelector('meta[name="x-airshelf-exact-source"]');
+ const page = (context.page || (sourceMeta && sourceMeta.content) || location.pathname.split("/").pop() || "index.html").toLowerCase();
+ const query = new URLSearchParams(context.search || location.search);
+ const liveDataPages = {
+ "index.html": true,
+ "products.html": true,
+ "product-detail.html": true,
+ "projects.html": true,
+ "projects-new.html": true,
+ "pipeline.html": true,
+ "library.html": true,
+ "account.html": true,
+ "settings.html": true,
+ "team.html": true,
+ "messages.html": true,
+ };
+ const needsLiveHydration = Boolean(context.liveHydrate || liveDataPages[page]);
+
+ function token() {
+ return localStorage.getItem(TOKEN_KEY) || "";
+ }
+
+ function isLive() {
+ return query.get("live") === "1" || localStorage.getItem(LIVE_KEY) === "1";
+ }
+
+ function canUseApi() {
+ return !!token();
+ }
+
+ function cacheScope() {
+ try {
+ const team = JSON.parse(localStorage.getItem(TEAM_KEY) || "null");
+ if (team?.id) return "team:" + team.id;
+ const user = JSON.parse(localStorage.getItem(USER_KEY) || "null");
+ if (user?.id) return "user:" + user.id;
+ } catch (error) {
+ // Fall through to anonymous scope.
+ }
+ return "anonymous";
+ }
+
+ function cacheKey(name) {
+ return CACHE_PREFIX + CACHE_VERSION + ":" + cacheScope() + ":" + name;
+ }
+
+ function readCache(name) {
+ try {
+ const raw = localStorage.getItem(cacheKey(name));
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ return parsed && parsed.data !== undefined ? parsed.data : null;
+ } catch (error) {
+ return null;
+ }
+ }
+
+ function writeCache(name, data) {
+ try {
+ localStorage.setItem(cacheKey(name), JSON.stringify({ ts: Date.now(), data: data }));
+ renderShellFromCache();
+ } catch (error) {
+ // localStorage may be full or unavailable; live API rendering still works.
+ }
+ }
+
+ function forgetCache(name) {
+ try {
+ localStorage.removeItem(cacheKey(name));
+ delete requestMemo[name];
+ renderShellFromCache();
+ } catch (error) {
+ // Best effort only.
+ }
+ }
+
+ function apiGet(cacheName, path) {
+ if (!requestMemo[cacheName]) {
+ requestMemo[cacheName] = api(path)
+ .then(function (data) {
+ writeCache(cacheName, data);
+ return data;
+ })
+ .catch(function (error) {
+ delete requestMemo[cacheName];
+ throw error;
+ });
+ }
+ return requestMemo[cacheName];
+ }
+
+ function rememberAuthShape(meData) {
+ if (!meData) return;
+ try {
+ if (meData.user) localStorage.setItem(USER_KEY, JSON.stringify(meData.user));
+ if (meData.team) localStorage.setItem(TEAM_KEY, JSON.stringify(meData.team));
+ } catch (error) {
+ // Best effort only.
+ }
+ }
+
+ function cachedAuthShape() {
+ const cached = readCache("auth:me");
+ if (cached) return cached;
+ try {
+ const user = JSON.parse(localStorage.getItem(USER_KEY) || "null");
+ const team = JSON.parse(localStorage.getItem(TEAM_KEY) || "null");
+ return user || team ? { user: user || {}, team: team || {} } : null;
+ } catch (error) {
+ return null;
+ }
+ }
+
+ async function loadWithCache(cacheName, fetcher, render, failTitle) {
+ const cached = readCache(cacheName);
+ if (cached) {
+ render(cached, true);
+ markHydrationDone();
+ }
+ try {
+ const fresh = await fetcher();
+ writeCache(cacheName, fresh);
+ render(fresh, false);
+ return fresh;
+ } catch (error) {
+ if (cached) {
+ toast(failTitle || "数据刷新失败", (error && error.message ? error.message : "接口暂不可用") + " · 已显示本地缓存");
+ return cached;
+ }
+ toast(failTitle || "数据加载失败", error.message);
+ throw error;
+ }
+ }
+
+ function markHydrationLoading() {
+ if (!needsLiveHydration || !canUseApi()) return;
+ document.documentElement.removeAttribute("data-live-error");
+ document.documentElement.setAttribute("data-live-hydrating", "1");
+ }
+
+ function markHydrationDone() {
+ if (!needsLiveHydration) return;
+ document.documentElement.removeAttribute("data-live-hydrating");
+ document.documentElement.removeAttribute("data-live-error");
+ document.documentElement.setAttribute("data-live-ready", "1");
+ }
+
+ function markHydrationError(message) {
+ if (!needsLiveHydration) return;
+ document.documentElement.removeAttribute("data-live-hydrating");
+ document.documentElement.setAttribute("data-live-error", "1");
+ document.documentElement.dataset.liveErrorMessage = message || "真实数据加载失败";
+ }
+
+ function applyContextHash() {
+ const cleanHash = String(context.hash || location.hash || "").replace(/^#/, "");
+ if (!cleanHash) return;
+ if (page === "settings.html" && cleanHash.indexOf("sec-") === 0 && typeof window.showSection === "function") {
+ window.showSection(cleanHash);
+ return;
+ }
+ const stageMatch = cleanHash.match(/^stage-(\d+)$/);
+ if (page === "pipeline.html" && stageMatch && typeof window.activateStage === "function") {
+ window.activateStage(Number(stageMatch[1]));
+ }
+ }
+
+ function go(href) {
+ if (typeof window.__AIR_SHELF_HOST_NAVIGATE__ === "function") {
+ window.__AIR_SHELF_HOST_NAVIGATE__(href);
+ return;
+ }
+ location.href = href;
+ }
+
+ function ready(fn) {
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn);
+ else fn();
+ }
+
+ function esc(value) {
+ return String(value == null ? "" : value).replace(/[&<>"']/g, function (char) {
+ return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char];
+ });
+ }
+
+ function dateOnly(value) {
+ if (!value) return new Date().toISOString().slice(0, 10);
+ return String(value).slice(0, 10);
+ }
+
+ function shortLabel(value) {
+ const text = String(value || "商品").replace(/[·||]/g, " ").trim();
+ return text.length > 9 ? text.slice(0, 9) : text;
+ }
+
+ function parseError(text) {
+ try {
+ const data = JSON.parse(text);
+ if (typeof data.detail === "string") return data.detail;
+ if (Array.isArray(data.non_field_errors)) return data.non_field_errors.join(" / ");
+ const parts = [];
+ Object.keys(data).forEach(function (key) {
+ const value = data[key];
+ if (Array.isArray(value)) parts.push(key + ": " + value.join(" / "));
+ else if (typeof value === "string") parts.push(key + ": " + value);
+ });
+ return parts.join(";") || text;
+ } catch (error) {
+ return text || "请求失败";
+ }
+ }
+
+ async function api(path, options) {
+ const opts = options || {};
+ const headers = new Headers(opts.headers || {});
+ if (!(opts.body instanceof FormData)) headers.set("Content-Type", "application/json");
+ if (token()) headers.set("Authorization", "Token " + token());
+ const response = await fetch(path, Object.assign({}, opts, { headers: headers }));
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(parseError(text));
+ }
+ if (response.status === 204) return null;
+ return response.json();
+ }
+
+ function toast(title, sub) {
+ if (window.Shell && typeof window.Shell.toast === "function") {
+ window.Shell.toast(title, sub || "");
+ return;
+ }
+ if (typeof window._loginToast === "function") {
+ window._loginToast(title, sub || "");
+ return;
+ }
+ if (typeof window._regToast === "function") {
+ window._regToast(title, sub || "");
+ return;
+ }
+ let el = document.getElementById("__api_bridge_toast");
+ if (!el) {
+ el = document.createElement("div");
+ el.id = "__api_bridge_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 =
+ '' +
+ esc(title) +
+ "
" +
+ (sub ? '// ' + esc(sub) + "
" : "");
+ requestAnimationFrame(function () {
+ el.style.opacity = "1";
+ el.style.transform = "translateX(-50%) translateY(0)";
+ });
+ clearTimeout(el._timer);
+ el._timer = setTimeout(function () {
+ el.style.opacity = "0";
+ el.style.transform = "translateX(-50%) translateY(20px)";
+ }, 3000);
+ }
+
+ function setAuthPayload(payload) {
+ localStorage.setItem(TOKEN_KEY, payload.token);
+ localStorage.setItem(LIVE_KEY, "1");
+ localStorage.setItem(USER_KEY, JSON.stringify(payload.user || {}));
+ localStorage.setItem(TEAM_KEY, JSON.stringify(payload.team || {}));
+ }
+
+ function renderShellIdentity(meData, billingData) {
+ if (meData) rememberAuthShape(meData);
+ const teamName = meData?.team?.name || "团队";
+ const userName = meData?.user ? displayName(meData.user) : "成员";
+ const email = meData?.user?.email || "";
+ const avatar = initial(userName);
+
+ document.querySelectorAll(".aside-foot .user .em, .shell-account-head .nm").forEach(function (el) {
+ el.textContent = teamName;
+ });
+ document.querySelectorAll(".aside-foot .user .av, .topbar-avatar span, .shell-account-head .av").forEach(function (el) {
+ el.textContent = avatar;
+ });
+ document.querySelectorAll(".shell-account-head .mail").forEach(function (el) {
+ el.textContent = email;
+ });
+ syncShellBalance(billingData);
+ }
+
+ function syncShellBalance(billingData) {
+ const balance = billingData?.account?.balance;
+ document.querySelectorAll(".balance-chip strong").forEach(function (el) {
+ el.textContent = balance === undefined || balance === null ? "--" : money(balance);
+ });
+ }
+
+ function firstPresent(values) {
+ for (let i = 0; i < values.length; i += 1) {
+ if (values[i] !== null && values[i] !== undefined) return values[i];
+ }
+ return null;
+ }
+
+ function collectionCount(data) {
+ if (!data) return null;
+ if (typeof data.count === "number") return data.count;
+ if (typeof data.count === "string" && data.count.trim() !== "" && !Number.isNaN(Number(data.count))) return Number(data.count);
+ if (Array.isArray(data.results)) return data.results.length;
+ if (Array.isArray(data)) return data.length;
+ return null;
+ }
+
+ function listResults(data) {
+ if (Array.isArray(data)) return data;
+ if (data && Array.isArray(data.results)) return data.results;
+ return [];
+ }
+
+ function unreadNotificationCount(data) {
+ if (!data) return null;
+ if (typeof data.unread_count === "number") return data.unread_count;
+ if (typeof data.unread_count === "string" && data.unread_count.trim() !== "" && !Number.isNaN(Number(data.unread_count))) {
+ return Number(data.unread_count);
+ }
+ const rows = listResults(data);
+ if (!rows.length && collectionCount(data) === 0) return 0;
+ return rows.filter(function (item) {
+ if (item.unread !== undefined) return Boolean(item.unread);
+ return item.is_read === false;
+ }).length;
+ }
+
+ function emptyList() {
+ return { count: 0, next: null, previous: null, results: [] };
+ }
+
+ function listCacheOrEmpty(name) {
+ return readCache(name) || emptyList();
+ }
+
+ function cacheNested(name, key) {
+ const data = readCache(name);
+ return data && data[key] ? data[key] : null;
+ }
+
+ function setShellBadge(href, count) {
+ document.querySelectorAll('aside.sidebar a[href="' + href + '"] .pill-mini').forEach(function (badge) {
+ if (count === null || count === undefined) {
+ badge.textContent = "";
+ badge.hidden = true;
+ return;
+ }
+ badge.hidden = false;
+ badge.textContent = String(count);
+ });
+ }
+
+ function syncNotificationBadge() {
+ document.querySelectorAll(".count-noti").forEach(function (badge) {
+ const count = firstPresent([
+ readCache("notifications:summary")?.unread_count,
+ unreadNotificationCount(readCache("notifications:list")),
+ ]);
+ if (count === null || count === undefined || Number(count) <= 0) {
+ badge.textContent = "";
+ badge.hidden = true;
+ return;
+ }
+ badge.hidden = false;
+ badge.textContent = String(count);
+ });
+ }
+
+ function shellSummaryFromCache() {
+ return firstPresent([
+ readCache("billing:summary"),
+ cacheNested("dashboard", "summary"),
+ cacheNested("account:bundle", "summary"),
+ cacheNested("team:bundle", "summary"),
+ cacheNested("settings:profile", "summary"),
+ ]);
+ }
+
+ function shellProductDataFromCache() {
+ return firstPresent([
+ readCache("products:list"),
+ cacheNested("dashboard", "products"),
+ cacheNested("account:bundle", "products"),
+ cacheNested("projects:bundle", "products"),
+ cacheNested("pipeline:latest", "products"),
+ ]);
+ }
+
+ function shellProjectDataFromCache() {
+ return firstPresent([
+ readCache("projects:list"),
+ cacheNested("dashboard", "projects"),
+ cacheNested("account:bundle", "projects"),
+ cacheNested("projects:bundle", "projects"),
+ ]);
+ }
+
+ function renderShellFromCache() {
+ if (!document.body) return;
+ const meData = cachedAuthShape();
+ const summaryData = shellSummaryFromCache();
+ if (canUseApi() || meData || summaryData) renderShellIdentity(meData, summaryData);
+ setShellBadge("products.html", collectionCount(shellProductDataFromCache()));
+ setShellBadge("projects.html", collectionCount(shellProjectDataFromCache()));
+ syncNotificationBadge();
+ }
+
+ function seedProjectsBundleFromCache() {
+ if (readCache("projects:bundle")) return;
+ const products = shellProductDataFromCache();
+ const projects = shellProjectDataFromCache();
+ if (!products && !projects) return;
+ writeCache("projects:bundle", {
+ products: products || emptyList(),
+ projects: projects || emptyList(),
+ });
+ }
+
+ function seedAccountBundleFromCache() {
+ if (readCache("account:bundle")) return;
+ const summary = shellSummaryFromCache();
+ const ledgers = readCache("billing:ledgers");
+ const members = readCache("team:members");
+ const products = shellProductDataFromCache();
+ const projects = shellProjectDataFromCache();
+ if (!summary && !ledgers && !members && !products && !projects) return;
+ writeCache("account:bundle", {
+ summary: summary || { account: { balance: 0 }, charged_total: 0 },
+ ledgers: ledgers || [],
+ members: members || [],
+ products: products || emptyList(),
+ projects: projects || emptyList(),
+ });
+ }
+
+ function seedTeamBundleFromCache() {
+ if (readCache("team:bundle")) return;
+ const me = cachedAuthShape();
+ const summary = readCache("billing:summary");
+ const members = readCache("team:members");
+ if (!me && !summary && !members) return;
+ writeCache("team:bundle", {
+ me: me || {},
+ summary: summary || { account: { balance: 0 }, charged_total: 0 },
+ members: members || [],
+ });
+ }
+
+ async function hydrateShellIdentity() {
+ if (!canUseApi()) return;
+ const cachedMe = cachedAuthShape();
+ const cachedBilling = readCache("billing:summary");
+ renderShellFromCache();
+ if (cachedMe) renderShellIdentity(cachedMe, cachedBilling);
+ try {
+ const meData = await apiGet("auth:me", "/api/auth/me/");
+ writeCache("auth:me", meData);
+ const billingData = await apiGet("billing:summary", "/api/billing/summary/").catch(function () {
+ return null;
+ });
+ if (billingData) writeCache("billing:summary", billingData);
+ renderShellIdentity(meData, billingData || cachedBilling);
+ renderShellFromCache();
+ } catch (error) {
+ // Shell identity is a convenience layer; page-specific API errors are handled below.
+ }
+ }
+
+ function setButtonBusy(button, text) {
+ if (!button) return function () {};
+ const html = button.innerHTML;
+ button.disabled = true;
+ button.innerHTML =
+ '// ' +
+ esc(text) +
+ "";
+ return function () {
+ button.disabled = false;
+ button.innerHTML = html;
+ };
+ }
+
+ function wireAuth() {
+ if (page === "login.html") {
+ window.doLogin = async function () {
+ const email = document.getElementById("auth-email").value.trim();
+ const password = document.getElementById("auth-pwd").value;
+ const restore = setButtonBusy(document.querySelector(".btn-cta"), "验证中...");
+ try {
+ const payload = await api("/api/auth/login/", {
+ method: "POST",
+ body: JSON.stringify({ username: email, password: password }),
+ });
+ setAuthPayload(payload);
+ toast("登录成功", "已接入 Django 真实会话");
+ go("index.html");
+ } catch (error) {
+ restore();
+ toast("登录失败", error.message || "请检查账号密码");
+ }
+ };
+ }
+
+ if (page === "register.html") {
+ window.doRegister = async function () {
+ const team = document.getElementById("reg-team").value.trim();
+ const email = document.getElementById("reg-email").value.trim();
+ const password = document.getElementById("reg-pwd").value;
+ const confirm = document.getElementById("reg-pwd2").value;
+ const agree = document.getElementById("reg-agree").checked;
+ if (!team || !email) return alert("请补全团队名 + 邮箱");
+ if (password.length < 8) return alert("密码至少 8 位");
+ if (password !== confirm) return alert("两次密码不一致");
+ if (!agree) return alert("请同意用户协议");
+
+ const restore = setButtonBusy(document.getElementById("reg-submit"), "创建团队中...");
+ try {
+ const payload = await api("/api/auth/register/", {
+ method: "POST",
+ body: JSON.stringify({
+ username: email,
+ email: email,
+ password: password,
+ team_name: team,
+ }),
+ });
+ setAuthPayload(payload);
+ toast("注册成功", "团队与试用额度已创建");
+ go("index.html");
+ } catch (error) {
+ restore();
+ toast("注册失败", error.message || "请稍后重试");
+ }
+ };
+ }
+ }
+
+ function sellingPointsFromDrawer() {
+ const points = Array.from(document.querySelectorAll("#pf-bullets .bl-item .bl-text"))
+ .map(function (el) {
+ return el.textContent.trim();
+ })
+ .filter(Boolean);
+ const pending = document.querySelector("#pf-bullets .bl-add .bl-input");
+ if (pending && pending.value.trim()) points.push(pending.value.trim());
+ return points;
+ }
+
+ let productCreateInFlight = false;
+
+ async function submitLiveProduct(saveBtn) {
+ if (!canUseApi()) return false;
+ if (productCreateInFlight) return true;
+
+ const nameEl = document.getElementById("pf-name");
+ const catEl = document.getElementById("pf-cat");
+ const targetEl = document.getElementById("pf-target");
+ const name = (nameEl && nameEl.value ? nameEl.value : "").trim();
+ const category = catEl ? catEl.value : "";
+ const target = targetEl ? targetEl.value.trim() : "";
+ const points = sellingPointsFromDrawer();
+
+ if (!name) {
+ toast("请填写商品名称", "必填项");
+ if (nameEl) nameEl.focus();
+ return true;
+ }
+ if (points.length === 0) {
+ toast("请填写核心卖点", "至少 1 条");
+ return true;
+ }
+
+ productCreateInFlight = true;
+ const restore = setButtonBusy(saveBtn, "保存商品中...");
+ try {
+ const product = await api("/api/products/", {
+ method: "POST",
+ body: JSON.stringify({
+ title: name,
+ category: category,
+ target_audience: target,
+ selling_points: points.map(function (title, index) {
+ return { title: title, detail: "", sort_order: index };
+ }),
+ }),
+ });
+ rememberPrototypeProduct(product, {
+ title: name,
+ category: category,
+ target: target,
+ points: points,
+ });
+ forgetCache("products:list");
+ forgetCache("projects:bundle");
+ forgetCache("dashboard");
+ localStorage.setItem("airshelf_current_product_id", product.id);
+ toast("商品已创建", "已写入 Django 数据库");
+ go("product-detail.html?product_id=" + encodeURIComponent(product.id) + "&product=" + encodeURIComponent(product.title || name));
+ } catch (error) {
+ productCreateInFlight = false;
+ restore();
+ toast("创建失败", error.message);
+ }
+ return true;
+ }
+
+ function rememberPrototypeProduct(product, draft) {
+ try {
+ const key = "fs-extra-products";
+ const list = JSON.parse(sessionStorage.getItem(key) || "[]");
+ list.push({
+ id: product.id,
+ name: product.title || draft.title,
+ cat: product.category || draft.category || "未分类",
+ target: product.target_audience || draft.target || "",
+ assets: 0,
+ videos: 0,
+ bullets: draft.points || [],
+ date: dateOnly(product.created_at),
+ createdAt: Date.now(),
+ });
+ sessionStorage.setItem(key, JSON.stringify(list));
+ } catch (error) {
+ // Prototype compatibility only; API persistence is already complete.
+ }
+ }
+
+ function productCardHTML(product, index) {
+ const title = product.title || "未命名商品";
+ const cat = product.category || "未分类";
+ const date = dateOnly(product.created_at);
+ const assets = (product.images && product.images.length) || 0;
+ const videos = product.metadata && product.metadata.videos_count ? product.metadata.videos_count : 0;
+ return (
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ esc(shortLabel(title)) +
+ " · 1200×800
" +
+ '
' +
+ esc(title) +
+ '
' +
+ esc(cat) +
+ '
' +
+ esc(date) +
+ " 创建
" +
+ '
"
+ );
+ }
+
+ function syncProductCount() {
+ const cards = Array.from(document.querySelectorAll("#product-grid .product-card"));
+ const visible = cards.filter(function (card) {
+ return card.style.display !== "none";
+ }).length;
+ const total = cards.length;
+ const sku = document.getElementById("sku-count");
+ if (sku) sku.textContent = String(total);
+ const meta = document.getElementById("result-meta");
+ if (meta) meta.innerHTML = '// 显示 ' + visible + " / " + total + " 个商品";
+ setShellBadge("products.html", total);
+ const empty = document.getElementById("empty");
+ if (empty) empty.hidden = visible !== 0;
+ }
+
+ function applyLiveProductFilter() {
+ const q = (document.getElementById("search-input") || {}).value || "";
+ const needle = q.trim().toLowerCase();
+ document.querySelectorAll("#product-grid .product-card").forEach(function (card) {
+ const hay = ((card.dataset.name || "") + " " + (card.dataset.cat || "") + " " + (card.dataset.tags || "")).toLowerCase();
+ card.style.display = !needle || hay.indexOf(needle) >= 0 ? "" : "none";
+ });
+ syncProductCount();
+ }
+
+ function bindLiveProductCards() {
+ const grid = document.getElementById("product-grid");
+ if (!grid) return;
+ grid.querySelectorAll(".product-card").forEach(function (card) {
+ card.addEventListener("click", function (event) {
+ if (document.body.classList.contains("edit-mode")) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ card.classList.toggle("selected");
+ return;
+ }
+ const id = card.dataset.productId;
+ const name = card.dataset.name || "";
+ if (id) go("product-detail.html?product_id=" + encodeURIComponent(id) + "&product=" + encodeURIComponent(name));
+ });
+ });
+
+ grid.querySelectorAll('[data-action="delete-product"][data-product-id]').forEach(function (button) {
+ button.addEventListener("click", async function (event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ const card = button.closest(".product-card");
+ const id = button.dataset.productId;
+ if (!id || !confirm("确认删除商品「" + (card && card.dataset.name ? card.dataset.name : "") + "」?")) return;
+ try {
+ await api("/api/products/" + encodeURIComponent(id) + "/", { method: "DELETE" });
+ if (card) card.remove();
+ forgetCache("products:list");
+ forgetCache("projects:bundle");
+ forgetCache("dashboard");
+ syncProductCount();
+ toast("已删除", "商品已从 Django 数据库移除");
+ } catch (error) {
+ toast("删除失败", error.message);
+ }
+ });
+ });
+ }
+
+ function renderLiveProductsPayload(data) {
+ const grid = document.getElementById("product-grid");
+ if (!grid) return;
+ const products = data.results || [];
+ grid.innerHTML = products.length
+ ? products.map(productCardHTML).join("")
+ : '// 当前团队还没有真实商品
';
+ bindLiveProductCards();
+ applyLiveProductFilter();
+ }
+
+ async function loadLiveProducts() {
+ if (!canUseApi()) return;
+ const grid = document.getElementById("product-grid");
+ if (!grid) return;
+ return loadWithCache(
+ "products:list",
+ function () {
+ return apiGet("products:list", "/api/products/");
+ },
+ renderLiveProductsPayload,
+ "商品加载失败"
+ );
+ }
+
+ function wireProductCreate() {
+ const saveBtn = document.getElementById("pc-save-btn");
+ if (!saveBtn) return;
+
+ const submitHandler = function (event) {
+ const button = event.target && event.target.closest ? event.target.closest("#pc-save-btn") : null;
+ if (!button || !canUseApi()) return;
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+ submitLiveProduct(button);
+ };
+
+ document.addEventListener("click", submitHandler, true);
+ saveBtn.addEventListener("click", submitHandler, true);
+
+ const search = document.getElementById("search-input");
+ if (search) {
+ search.addEventListener(
+ "input",
+ function (event) {
+ if (!canUseApi()) return;
+ event.stopImmediatePropagation();
+ applyLiveProductFilter();
+ },
+ true
+ );
+ }
+ }
+
+ function renderProductDetail(product) {
+ if (!product) return;
+ const title = product.title || "";
+ const category = product.category || "";
+ const target = product.target_audience || "";
+ const h1 = document.getElementById("pd-name");
+ if (h1 && title) h1.textContent = title;
+ setField("name", title);
+ setField("cat", category);
+ setField("target", target);
+ const bulletBox = document.querySelector('[data-field="bullets"] .v-static');
+ if (bulletBox && product.selling_points && product.selling_points.length) {
+ bulletBox.innerHTML = product.selling_points
+ .map(function (point) {
+ return '' + esc(point.title) + "";
+ })
+ .join("");
+ }
+ }
+
+ async function hydrateProductDetail() {
+ if (!canUseApi()) return;
+ if (page !== "product-detail.html") return;
+ const id = query.get("product_id") || localStorage.getItem("airshelf_current_product_id");
+ if (!id) return;
+ return loadWithCache(
+ "product:" + id,
+ function () {
+ return apiGet("product:" + id, "/api/products/" + encodeURIComponent(id) + "/");
+ },
+ renderProductDetail,
+ "商品详情加载失败"
+ );
+ }
+
+ function setField(field, value) {
+ const row = document.querySelector('[data-field="' + field + '"]');
+ if (!row) return;
+ const stat = row.querySelector(".v-static");
+ const input = row.querySelector(".v-input, .v-select");
+ if (stat && value) stat.textContent = value;
+ if (input && value) {
+ if (input.tagName === "SELECT" && !Array.from(input.options).some(function (option) { return option.value === value; })) {
+ const option = document.createElement("option");
+ option.textContent = value;
+ option.value = value;
+ input.insertBefore(option, input.firstChild);
+ }
+ input.value = value;
+ }
+ }
+
+ function stageNo(project) {
+ const map = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 5 };
+ if (project.status === "completed") return 5;
+ return map[project.current_stage] || 1;
+ }
+
+ function statusBucket(project) {
+ if (project.status === "completed") return "done";
+ if (project.status === "failed") return "fail";
+ return "wip";
+ }
+
+ function projectStatusLabel(project) {
+ return {
+ draft: "脚本待生成",
+ scripting: "脚本生成中",
+ asseting: "基础资产生成中",
+ storyboarding: "故事板生成中",
+ videoing: "视频片段生成中",
+ exporting: "导出中",
+ completed: "已完成",
+ failed: "失败",
+ }[project.status] || "进行中";
+ }
+
+ function pillClass(project) {
+ if (project.status === "completed") return "ok";
+ if (project.status === "failed") return "fail";
+ return "info";
+ }
+
+ function progressHTML(project) {
+ const no = stageNo(project);
+ let html = "";
+ for (let i = 1; i <= 5; i += 1) {
+ let cls = "";
+ if (project.status === "completed" || i < no) cls = "done";
+ else if (i === no) cls = "cur";
+ html += '';
+ }
+ return html;
+ }
+
+ function projectHref(project) {
+ return "pipeline.html?project_id=" + encodeURIComponent(project.id) + "#stage-" + stageNo(project);
+ }
+
+ function liveProjectRowHTML(project, productName) {
+ const href = projectHref(project);
+ const status = statusBucket(project);
+ const label = projectStatusLabel(project);
+ const shots = (project.video_segments && project.video_segments.length) || 4;
+ const no = stageNo(project);
+ return (
+ '' +
+ '9:16 ' +
+ esc(project.name) +
+ ' ' +
+ esc(shots) +
+ " 镜 · 0-60s | " +
+ "" +
+ esc(productName || "未命名商品") +
+ ' | AI 全生 | ' +
+ progressHTML(project) +
+ ' ' +
+ esc(no) +
+ '/5 | ' +
+ esc(label) +
+ ' | ' +
+ esc(dateOnly(project.updated_at)) +
+ ' | |
'
+ );
+ }
+
+ function liveProjectCardHTML(project, productName) {
+ const href = projectHref(project);
+ const status = statusBucket(project);
+ const label = projectStatusLabel(project);
+ const shots = (project.video_segments && project.video_segments.length) || 4;
+ const no = stageNo(project);
+ return (
+ '' +
+ '
' +
+ '
' +
+ '
9:16 · 阶段 ' +
+ esc(no) +
+ '/5
' +
+ esc(project.name) +
+ '
' +
+ esc(productName || "未命名商品") +
+ " · " +
+ esc(shots) +
+ ' 镜
' +
+ progressHTML(project) +
+ '
' +
+ esc(no) +
+ '/5 "
+ );
+ }
+
+ function syncProjectCount() {
+ const rows = Array.from(document.querySelectorAll("#list-tbody tr"));
+ const visible = rows.filter(function (row) {
+ return row.style.display !== "none";
+ }).length;
+ const counts = { total: rows.length, wip: 0, done: 0, fail: 0 };
+ rows.forEach(function (row) {
+ const s = row.dataset.status;
+ if (counts[s] !== undefined) counts[s] += 1;
+ });
+ const total = document.getElementById("sub-total");
+ const wip = document.getElementById("sub-wip");
+ const done = document.getElementById("sub-done");
+ const fail = document.getElementById("sub-fail");
+ if (total) total.textContent = counts.total;
+ if (wip) wip.textContent = counts.wip;
+ if (done) done.textContent = counts.done;
+ if (fail) fail.textContent = counts.fail;
+ document.querySelectorAll("#status-tabs .tab").forEach(function (tab) {
+ const filter = tab.dataset.filter;
+ const n = filter === "all" ? counts.total : counts[filter] || 0;
+ const el = tab.querySelector(".count");
+ if (el) el.textContent = n;
+ });
+ const meta = document.getElementById("result-meta");
+ if (meta) meta.innerHTML = '// 显示 ' + visible + " / " + counts.total + " 个项目";
+ setShellBadge("projects.html", counts.total);
+ const empty = document.getElementById("empty");
+ if (empty) empty.hidden = visible !== 0;
+ }
+
+ function applyLiveProjectFilter() {
+ const active = document.querySelector("#status-tabs .tab.active");
+ const filter = active ? active.dataset.filter : "all";
+ const needle = ((document.getElementById("search-input") || {}).value || "").trim().toLowerCase();
+ const test = function (el) {
+ const statusOk = filter === "all" || el.dataset.status === filter;
+ const textOk = !needle || (el.dataset.name || "").toLowerCase().indexOf(needle) >= 0;
+ return statusOk && textOk;
+ };
+ document.querySelectorAll("#list-tbody tr, #grid-body .proj-card").forEach(function (el) {
+ el.style.display = test(el) ? "" : "none";
+ });
+ syncProjectCount();
+ }
+
+ function bindLiveProjects() {
+ document.querySelectorAll("#list-tbody tr[data-project-id], #grid-body .proj-card[data-project-id]").forEach(function (el) {
+ el.addEventListener("click", function (event) {
+ if (event.target.closest("button, a, .row-more")) return;
+ if (document.body.classList.contains("edit-mode")) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ el.classList.toggle("selected");
+ return;
+ }
+ go(el.dataset.href || projectHref({ id: el.dataset.projectId, current_stage: "script", status: "" }));
+ });
+ });
+
+ document.querySelectorAll('[data-action="delete-project"][data-project-id]').forEach(function (button) {
+ button.addEventListener("click", async function (event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ const id = button.dataset.projectId;
+ const el = button.closest(".proj-card, tr");
+ const name = el ? el.dataset.name : "";
+ if (!id || !confirm("确认删除项目「" + name + "」?")) return;
+ try {
+ await api("/api/projects/" + encodeURIComponent(id) + "/", { method: "DELETE" });
+ document.querySelectorAll('[data-project-id="' + CSS.escape(id) + '"]').forEach(function (node) {
+ node.remove();
+ });
+ forgetCache("projects:bundle");
+ forgetCache("projects:list");
+ forgetCache("dashboard");
+ applyLiveProjectFilter();
+ toast("已删除", "项目已从 Django 数据库移除");
+ } catch (error) {
+ toast("删除失败", error.message);
+ }
+ });
+ });
+
+ const search = document.getElementById("search-input");
+ if (search) {
+ search.addEventListener(
+ "input",
+ function (event) {
+ if (!canUseApi()) return;
+ event.stopImmediatePropagation();
+ applyLiveProjectFilter();
+ },
+ true
+ );
+ }
+ document.querySelectorAll("#status-tabs .tab").forEach(function (tab) {
+ tab.addEventListener(
+ "click",
+ function (event) {
+ if (!canUseApi()) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ document.querySelectorAll("#status-tabs .tab").forEach(function (x) {
+ x.classList.remove("active");
+ });
+ tab.classList.add("active");
+ applyLiveProjectFilter();
+ },
+ true
+ );
+ });
+ }
+
+ function renderLiveProjectsPayload(payload) {
+ const tbody = document.getElementById("list-tbody");
+ const grid = document.getElementById("grid-body");
+ if (!tbody || !grid) return;
+ const productData = payload.products || {};
+ const projectData = payload.projects || {};
+ const productMap = {};
+ (productData.results || []).forEach(function (product) {
+ productMap[product.id] = product.title;
+ });
+ const projects = projectData.results || [];
+ tbody.innerHTML = projects.length
+ ? projects
+ .map(function (project) {
+ return liveProjectRowHTML(project, productMap[project.product]);
+ })
+ .join("")
+ : '| // 当前团队还没有真实项目 |
';
+ grid.innerHTML = projects.length
+ ? projects
+ .map(function (project) {
+ return liveProjectCardHTML(project, productMap[project.product]);
+ })
+ .join("")
+ : '// 当前团队还没有真实项目
';
+ bindLiveProjects();
+ applyLiveProjectFilter();
+ }
+
+ async function loadLiveProjects() {
+ if (!canUseApi()) return;
+ const tbody = document.getElementById("list-tbody");
+ const grid = document.getElementById("grid-body");
+ if (!tbody || !grid) return;
+ seedProjectsBundleFromCache();
+ return loadWithCache(
+ "projects:bundle",
+ async function () {
+ const productData = await apiGet("products:list", "/api/products/");
+ writeCache("products:list", productData);
+ const projectData = await apiGet("projects:list", "/api/projects/");
+ return { products: productData, projects: projectData };
+ },
+ renderLiveProjectsPayload,
+ "项目加载失败"
+ );
+ }
+
+ async function ensureProduct(title, category, id) {
+ if (id) return api("/api/products/" + encodeURIComponent(id) + "/");
+ const data = await api("/api/products/?search=" + encodeURIComponent(title));
+ const found = (data.results || []).find(function (product) {
+ return product.title === title;
+ });
+ if (found) return found;
+ return api("/api/products/", {
+ method: "POST",
+ body: JSON.stringify({ title: title, category: category || "" }),
+ });
+ }
+
+ function selectedWizardProduct() {
+ const card = document.querySelector("#step-pane-1 .product-card.selected, #step-pane-1 .product-pick.selected");
+ if (!card) return null;
+ return {
+ id: card.dataset.productId || card.dataset.id || "",
+ title: (card.querySelector(".product-name, .name") || {}).textContent || "",
+ category: (card.querySelector(".product-cat, .meta") || {}).textContent || "",
+ };
+ }
+
+ function projectNameFromWizard(productTitle) {
+ const inputs = Array.from(document.querySelectorAll("#step-pane-2 input.input"));
+ const named = inputs.find(function (input) {
+ return input.value && input.value.trim().length >= 2;
+ });
+ const value = named ? named.value.trim() : "";
+ return value || (productTitle ? productTitle + " · AI 视频" : "未命名项目");
+ }
+
+ function liveWizardProductHTML(product, index) {
+ const title = product.title || "未命名商品";
+ const category = product.category || "未分类";
+ const date = dateOnly(product.created_at);
+ return (
+ '' +
+ '
' +
+ esc(shortLabel(title)) +
+ " · 1200×800
" +
+ '
' +
+ esc(title) +
+ '
' +
+ esc(category) +
+ '
' +
+ esc(date) +
+ " 创建
"
+ );
+ }
+
+ function renderLiveWizardProductsPayload(data) {
+ const grid = document.querySelector("#step-pane-1 .pp-grid");
+ if (!grid) return;
+ const products = data.results || [];
+ const createCard =
+ '';
+ grid.innerHTML =
+ createCard +
+ (products.length
+ ? products.map(liveWizardProductHTML).join("")
+ : '// 当前团队还没有商品 · 请先新建商品
');
+ grid.querySelector('[data-live-create="1"]')?.addEventListener("click", function () {
+ go("products.html");
+ });
+ grid.querySelectorAll(".product-card[data-product-id]").forEach(function (card) {
+ card.addEventListener("click", function () {
+ grid.querySelectorAll(".product-card").forEach(function (node) {
+ node.classList.remove("selected");
+ });
+ card.classList.add("selected");
+ const title = (card.querySelector(".product-name") || {}).textContent || "";
+ const projectInputs = Array.from(document.querySelectorAll("#step-pane-2 input.input"));
+ const target = projectInputs.find(function (input) {
+ return input.placeholder && input.placeholder.indexOf("项目") >= 0;
+ });
+ if (target && !target.value.trim()) target.value = title + " · 痛点种草 · v1";
+ });
+ });
+ const meta = document.querySelector("#step-pane-1 .pp-result-meta");
+ if (meta) meta.textContent = "// 显示 " + products.length + " / " + products.length + " 个真实商品";
+ document.querySelectorAll(".btn-start.disabled").forEach(function (button) {
+ button.classList.remove("disabled");
+ });
+ }
+
+ async function loadLiveWizardProducts() {
+ if (page !== "projects-new.html" || !canUseApi()) return;
+ const grid = document.querySelector("#step-pane-1 .pp-grid");
+ if (!grid) return;
+ return loadWithCache(
+ "products:list",
+ function () {
+ return apiGet("products:list", "/api/products/");
+ },
+ renderLiveWizardProductsPayload,
+ "商品加载失败"
+ );
+ }
+
+ function wireProjectWizard() {
+ if (page !== "projects-new.html") return;
+ if (!window._wiz || typeof window._wiz.startGenerate !== "function") return;
+ const original = window._wiz.startGenerate;
+ window._wiz.startGenerate = async function () {
+ if (!canUseApi()) return original();
+ const picked = selectedWizardProduct();
+ if (!picked || !picked.title.trim()) {
+ toast("请选择商品", "项目必须绑定一个商品");
+ return;
+ }
+ const startBtn = document.querySelector(".btn-start");
+ const restore = setButtonBusy(startBtn, "创建项目中...");
+ try {
+ const product = await ensureProduct(picked.title.trim(), picked.category.trim(), picked.id);
+ const project = await api("/api/projects/", {
+ method: "POST",
+ body: JSON.stringify({
+ name: projectNameFromWizard(product.title),
+ product: product.id,
+ }),
+ });
+ forgetCache("projects:bundle");
+ forgetCache("projects:list");
+ forgetCache("dashboard");
+ localStorage.setItem("airshelf_current_project_id", project.id);
+ toast("项目已创建", "已写入 Django,进入生产管线");
+ go(
+ "pipeline.html?project_id=" +
+ encodeURIComponent(project.id) +
+ "&product=" +
+ encodeURIComponent(product.title || picked.title) +
+ "#stage-1"
+ );
+ } catch (error) {
+ restore();
+ toast("创建项目失败", error.message);
+ }
+ };
+ }
+
+ function money(value) {
+ const n = Number(value || 0);
+ return "¥" + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+ }
+
+ function plainMoney(value) {
+ const n = Number(value || 0);
+ return "¥" + n.toFixed(2);
+ }
+
+ function roleUi(role) {
+ if (role === "owner" || role === "super") return { key: "super", label: "超管" };
+ if (role === "admin") return { key: "admin", label: "团管" };
+ if (role === "viewer") return { key: "member", label: "访客" };
+ return { key: "member", label: "成员" };
+ }
+
+ function displayName(user) {
+ const raw = String(user?.username || user?.email || "成员").trim();
+ if (raw.indexOf("@") > 0) return raw.split("@")[0];
+ return raw || "成员";
+ }
+
+ function userPublicId(user) {
+ const id = String(user?.id || "").replace(/-/g, "").toUpperCase();
+ return id ? "USR-" + id.slice(0, 12) : "USR-UNKNOWN";
+ }
+
+ function initial(name) {
+ return String(name || "U").trim().slice(0, 1).toUpperCase() || "U";
+ }
+
+ function stageLabel(project) {
+ return {
+ script: "Stage 1 脚本",
+ base_assets: "Stage 2 基础资产",
+ storyboard: "Stage 3 故事板",
+ video: "Stage 4 视频",
+ export: "Stage 5 导出",
+ }[project.current_stage] || "Stage 1 脚本";
+ }
+
+ function assetTab(asset) {
+ const category = asset.category || "";
+ if (category === "person") return "people";
+ if (category === "scene") return "scenes";
+ if (category === "product_image") return "products";
+ if (category === "final_video" || category === "video_clip") return "finals";
+ if (category === "upload") return "uploads";
+ return "unclassified";
+ }
+
+ function assetMeta(asset, tab) {
+ const source = asset.source === "ai_generated" ? "AI 生成" : asset.source === "exported" ? "导出" : "手动上传";
+ const type = asset.asset_type === "video" ? "视频" : "图片";
+ if (tab === "people") return "人物 · " + source + " · 用过 0 次";
+ if (tab === "scenes") return "场景 · " + source + " · 用过 0 次";
+ if (tab === "products") return "商品图 · " + source + " · 用过 0 次";
+ if (tab === "finals") return "成片 · " + type + " · " + dateOnly(asset.created_at);
+ return type + " · " + source + " · " + dateOnly(asset.created_at);
+ }
+
+ function liveAssetCardHTML(asset, tab) {
+ const file = asset.files && asset.files[0];
+ const isVideo = asset.asset_type === "video";
+ const preview = file && file.preview_url;
+ const thumb = preview
+ ? isVideo
+ ? ''
+ : '
'
+ : '' + esc(shortLabel(asset.name)) + "";
+ return (
+ '' +
+ '
' +
+ '
' +
+ thumb +
+ '
' +
+ esc(asset.name || "未命名资产") +
+ '
' +
+ esc(assetMeta(asset, tab)) +
+ "
"
+ );
+ }
+
+ function applyLiveAssetTab(tab) {
+ const assets = window.__airshelfLiveAssets || [];
+ document.querySelectorAll("#asset-tabs .tab").forEach(function (node) {
+ node.classList.toggle("active", node.dataset.tab === tab);
+ });
+ document.querySelectorAll(".asset-grid[data-tab]").forEach(function (grid) {
+ grid.hidden = grid.dataset.tab !== tab;
+ });
+ const grid = document.getElementById("grid-" + tab);
+ const queryText = ((document.getElementById("search-input") || {}).value || "").trim().toLowerCase();
+ let visible = 0;
+ if (grid) {
+ grid.querySelectorAll(".asset-card").forEach(function (card) {
+ const ok = !queryText || (card.dataset.name || "").toLowerCase().indexOf(queryText) >= 0;
+ card.style.display = ok ? "" : "none";
+ if (ok) visible += 1;
+ });
+ }
+ const total = assets.filter(function (asset) {
+ return assetTab(asset) === tab;
+ }).length;
+ const meta = document.getElementById("result-meta");
+ if (meta) meta.innerHTML = '// 显示 ' + visible + " / " + total + " 个资产";
+ }
+
+ function renderLiveAssetsPayload(data) {
+ const assets = data.results || [];
+ window.__airshelfLiveAssets = assets;
+ const tabs = ["people", "scenes", "products", "finals", "uploads", "unclassified"];
+ tabs.forEach(function (tab) {
+ const grid = document.getElementById("grid-" + tab);
+ if (!grid) return;
+ const list = assets.filter(function (asset) {
+ return assetTab(asset) === tab;
+ });
+ grid.innerHTML = list.length
+ ? list
+ .map(function (asset) {
+ return liveAssetCardHTML(asset, tab);
+ })
+ .join("")
+ : '// 当前分类暂无真实资产
';
+ const count = document.querySelector('#asset-tabs .tab[data-tab="' + tab + '"] .count');
+ if (count) count.textContent = String(list.length);
+ });
+ const counts = tabs.reduce(function (acc, tab) {
+ acc[tab] = assets.filter(function (asset) {
+ return assetTab(asset) === tab;
+ }).length;
+ return acc;
+ }, {});
+ const people = document.getElementById("sub-people");
+ const scenes = document.getElementById("sub-scenes");
+ const products = document.getElementById("sub-products");
+ const finals = document.getElementById("sub-finals");
+ if (people) people.textContent = String(counts.people || 0);
+ if (scenes) scenes.textContent = String(counts.scenes || 0);
+ if (products) products.textContent = String(counts.products || 0);
+ if (finals) finals.textContent = String(counts.finals || 0);
+ const active = document.querySelector("#asset-tabs .tab.active");
+ applyLiveAssetTab(active ? active.dataset.tab : "people");
+ }
+
+ async function loadLiveAssets() {
+ if (page !== "library.html" || !canUseApi()) return;
+ return loadWithCache(
+ "assets:list",
+ function () {
+ return apiGet("assets:list", "/api/assets/");
+ },
+ renderLiveAssetsPayload,
+ "资产加载失败"
+ );
+ }
+
+ let assetUploadInFlight = false;
+
+ async function submitLiveAsset(submit) {
+ if (!canUseApi()) return false;
+ if (assetUploadInFlight) return true;
+
+ const file = document.getElementById("upload-file")?.files?.[0];
+ const name = (document.getElementById("upload-name")?.value || "").trim();
+ const kind = document.getElementById("upload-kind")?.value || "uploads";
+ if (!file) {
+ toast("请选择文件", "资产上传需要真实文件");
+ return true;
+ }
+ if (!name) {
+ toast("请填写资产名称", "必填项");
+ return true;
+ }
+ const categoryMap = {
+ people: "person",
+ scenes: "scene",
+ products: "product_image",
+ finals: "final_video",
+ uploads: "upload",
+ unclassified: "uncategorized",
+ };
+ const form = new FormData();
+ form.append("file", file);
+ form.append("name", name);
+ form.append("asset_type", kind === "finals" ? "video" : "image");
+ form.append("category", categoryMap[kind] || "upload");
+ assetUploadInFlight = true;
+ const restore = setButtonBusy(submit, "上传中...");
+ try {
+ await api("/api/assets/upload/", { method: "POST", body: form });
+ forgetCache("assets:list");
+ forgetCache("dashboard");
+ if (window.Shell) Shell.closeModal("upload-modal-bg");
+ toast("资产已上传", "已写入 TOS 与资产表");
+ await loadLiveAssets();
+ } catch (error) {
+ toast("上传失败", error.message);
+ } finally {
+ assetUploadInFlight = false;
+ restore();
+ }
+ return true;
+ }
+
+ function wireLiveAssets() {
+ if (page !== "library.html") return;
+ document.querySelectorAll("#asset-tabs .tab").forEach(function (tab) {
+ tab.addEventListener(
+ "click",
+ function (event) {
+ if (!canUseApi()) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ applyLiveAssetTab(tab.dataset.tab);
+ },
+ true
+ );
+ });
+ const search = document.getElementById("search-input");
+ if (search) {
+ search.addEventListener(
+ "input",
+ function (event) {
+ if (!canUseApi()) return;
+ event.stopImmediatePropagation();
+ const active = document.querySelector("#asset-tabs .tab.active");
+ applyLiveAssetTab(active ? active.dataset.tab : "people");
+ },
+ true
+ );
+ }
+ const submit = document.getElementById("upload-submit");
+ if (!submit) return;
+ const submitHandler = function (event) {
+ const button = event.target && event.target.closest ? event.target.closest("#upload-submit") : null;
+ if (!button || !canUseApi()) return;
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+ submitLiveAsset(button);
+ };
+ document.addEventListener("click", submitHandler, true);
+ submit.addEventListener("click", submitHandler, true);
+ }
+
+ function ledgerStageKey(ledger) {
+ const text = String(ledger.reason || ledger.ledger_type || ledger.metadata?.stage || ledger.metadata?.task_type || "").toLowerCase();
+ if (text.indexOf("video") >= 0 || text.indexOf("seedance") >= 0 || text.indexOf("视频") >= 0) return "video";
+ if (text.indexOf("story") >= 0 || text.indexOf("image-2") >= 0 || text.indexOf("故事板") >= 0) return "storyboard";
+ if (text.indexOf("asset") >= 0 || text.indexOf("image") >= 0 || text.indexOf("基础资产") >= 0) return "asset";
+ if (text.indexOf("script") >= 0 || text.indexOf("llm") >= 0 || text.indexOf("脚本") >= 0) return "script";
+ return "other";
+ }
+
+ function renderLiveAccountOverview(ledgers, used) {
+ const spendByStage = { video: 0, storyboard: 0, asset: 0, script: 0 };
+ const daily = {};
+ ledgers.forEach(function (ledger) {
+ const amount = Math.abs(Math.min(0, Number(ledger.amount || 0)));
+ if (!amount) return;
+ const key = ledgerStageKey(ledger);
+ if (spendByStage[key] !== undefined) spendByStage[key] += amount;
+ const day = dateOnly(ledger.created_at);
+ daily[day] = (daily[day] || 0) + amount;
+ });
+ const stageRows = [
+ ["video", "视频片段(Seedance)"],
+ ["storyboard", "故事板(image-2)"],
+ ["asset", "基础资产"],
+ ["script", "脚本 LLM"],
+ ];
+ const stageTotal = stageRows.reduce(function (sum, row) {
+ return sum + spendByStage[row[0]];
+ }, 0);
+ const denominator = Math.max(stageTotal, used, 1);
+ const lines = document.querySelectorAll(".stage-pane .usage-line");
+ const bars = document.querySelectorAll(".stage-pane .usage-bar > span");
+ stageRows.forEach(function (row, index) {
+ const value = spendByStage[row[0]];
+ const valueEl = lines[index]?.querySelector(".v");
+ if (valueEl) valueEl.textContent = money(value);
+ if (bars[index]) bars[index].style.width = Math.min(100, (value / denominator) * 100).toFixed(1) + "%";
+ });
+ const totalEl = document.querySelector(".stage-pane .total .v");
+ if (totalEl) totalEl.textContent = money(used || stageTotal);
+
+ const days = Object.keys(daily).sort().slice(-14);
+ const values = days.map(function (day) {
+ return daily[day] || 0;
+ });
+ const sum = values.reduce(function (acc, value) {
+ return acc + value;
+ }, 0);
+ const peak = values.reduce(function (max, value) {
+ return Math.max(max, value);
+ }, 0);
+ const trendSum = document.getElementById("trend-sum");
+ const trendAvg = document.getElementById("trend-avg");
+ const trendPeak = document.getElementById("trend-peak");
+ if (trendSum) trendSum.textContent = money(sum);
+ if (trendAvg) trendAvg.textContent = money(values.length ? sum / values.length : 0);
+ if (trendPeak) trendPeak.textContent = money(peak);
+ }
+
+ function renderLiveAccountPayload(payload) {
+ const summaryData = payload.summary || {};
+ const ledgers = payload.ledgers || [];
+ const members = payload.members || [];
+ const projectsData = payload.projects || {};
+ const productsData = payload.products || {};
+ const projects = projectsData.results || [];
+ const productMap = {};
+ (productsData.results || []).forEach(function (product) {
+ productMap[product.id] = product.title;
+ });
+ const account = summaryData.account || {};
+ const used = Math.abs(Number(summaryData.charged_total || 0));
+ const memberLimit = members.reduce(function (sum, member) {
+ return sum + Math.max(0, Number(member.monthly_credit_limit || 0));
+ }, 0);
+ const limit = memberLimit || Number(account.balance || 0);
+ const left = Math.max(0, limit - used);
+ const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
+ const hero = document.querySelector(".balance-hero .v");
+ if (hero) hero.textContent = money(account.balance || 0);
+ const subValues = document.querySelectorAll(".balance-sub .col .v");
+ if (subValues[0]) subValues[0].textContent = money(limit);
+ if (subValues[1]) subValues[1].textContent = money(used);
+ const subMeta = document.querySelectorAll(".balance-sub .col .meta");
+ if (subMeta[1]) subMeta[1].textContent = "// 占比 " + pct.toFixed(1) + "% · " + (pct >= 80 ? "注意" : "健康");
+ const meter = document.querySelector(".balance-meter > span");
+ if (meter) meter.style.width = pct.toFixed(1) + "%";
+ const foot = document.querySelectorAll(".balance-foot-meta span");
+ if (foot[0]) foot[0].textContent = "团队月剩余 " + money(left);
+ if (foot[1]) foot[1].textContent = "使用率 " + pct.toFixed(1) + "%";
+ renderLiveAccountOverview(ledgers, used);
+ const billsBody = document.getElementById("bills-body");
+ if (billsBody) {
+ billsBody.innerHTML = ledgers.length
+ ? ledgers
+ .map(function (ledger) {
+ const n = Number(ledger.amount || 0);
+ const cls = n > 0 ? "pos" : n < 0 ? "neg" : "zero";
+ return (
+ "| " +
+ esc(dateOnly(ledger.created_at)) +
+ ' | ' +
+ esc(ledger.reason || ledger.ledger_type) +
+ ' ' +
+ esc(ledger.ledger_type) +
+ ' | ' +
+ esc(ledger.id) +
+ ' | U成员 | 真实 | ' +
+ plainMoney(n) +
+ " |
"
+ );
+ })
+ .join("")
+ : '| // 暂无真实账单流水 |
';
+ }
+ const billsCount = document.getElementById("bills-count");
+ if (billsCount) billsCount.textContent = String(ledgers.length);
+ const projectBody = document.getElementById("proj-body");
+ if (projectBody) {
+ projectBody.innerHTML = projects.length
+ ? projects
+ .map(function (project) {
+ const status = statusBucket(project);
+ return (
+ '| ' +
+ esc(project.name) +
+ ' | ' +
+ esc(productMap[project.product] || "未命名商品") +
+ ' | ' +
+ initial(members[0]?.user?.username) +
+ "" +
+ esc(members[0]?.user?.username || "成员") +
+ ' | ' +
+ esc(stageLabel(project)) +
+ ' | ' +
+ esc(projectStatusLabel(project)) +
+ ' | ¥0.00 |
'
+ );
+ })
+ .join("")
+ : '| // 暂无真实项目 |
';
+ }
+ const projCount = document.getElementById("proj-count");
+ if (projCount) projCount.innerHTML = '共 ' + projects.length + " 个项目 · 消耗 " + money(used);
+ const memberBody = document.getElementById("member-body");
+ if (memberBody) {
+ memberBody.innerHTML = members.length
+ ? members
+ .map(function (member) {
+ const role = roleUi(member.role);
+ const name = member.user?.username || member.user?.email || "成员";
+ const limitText = Number(member.monthly_credit_limit || 0) > 0 ? money(member.monthly_credit_limit) : "不限";
+ return (
+ '| ' +
+ initial(name) +
+ "" +
+ esc(name) +
+ ' | ' +
+ role.label +
+ " | 0 | " +
+ money(0) +
+ " / " +
+ limitText +
+ ' | 真实成员表 |
'
+ );
+ })
+ .join("")
+ : '| // 暂无成员 |
';
+ }
+ const memCount = document.getElementById("mem-count");
+ if (memCount) memCount.innerHTML = '共 ' + members.length + " 人 · 合计 " + money(used);
+ document.querySelectorAll(".billing-tabs .tab .count").forEach(function (count) {
+ const tab = count.closest(".tab")?.dataset.tab;
+ if (tab === "by-project") count.textContent = String(projects.length);
+ if (tab === "by-member") count.textContent = String(members.length);
+ if (tab === "bills") count.textContent = String(ledgers.length);
+ });
+ }
+
+ async function loadLiveAccount() {
+ if (page !== "account.html" || !canUseApi()) return;
+ seedAccountBundleFromCache();
+ return loadWithCache(
+ "account:bundle",
+ async function () {
+ const summaryData = await apiGet("billing:summary", "/api/billing/summary/");
+ writeCache("billing:summary", summaryData);
+ const ledgers = await apiGet("billing:ledgers", "/api/billing/ledgers/");
+ const members = await apiGet("team:members", "/api/auth/team/members/");
+ writeCache("team:members", members);
+ const projectsData = await apiGet("projects:list", "/api/projects/");
+ const productsData = await apiGet("products:list", "/api/products/");
+ writeCache("products:list", productsData);
+ return { summary: summaryData, ledgers: ledgers, members: members, projects: projectsData, products: productsData };
+ },
+ renderLiveAccountPayload,
+ "账户数据加载失败"
+ );
+ }
+
+ function parseMoneyText(text) {
+ const n = Number(String(text || "").replace(/[^\d.-]/g, ""));
+ return Number.isFinite(n) ? n : 0;
+ }
+
+ function liveTopupPayload() {
+ const amount = parseMoneyText(document.getElementById("topup-amt")?.textContent || "");
+ const bonusText = document.getElementById("topup-bonus")?.textContent || "";
+ const bonusMatch = bonusText.match(/含\s*¥?([\d,.]+)/);
+ const channelText = document.getElementById("topup-channel-label")?.textContent || "";
+ return {
+ amount: amount,
+ bonus: bonusMatch ? parseMoneyText(bonusMatch[1]) : 0,
+ channel: channelText.indexOf("支付宝") >= 0 ? "alipay" : "wechat",
+ };
+ }
+
+ async function submitLiveTopup() {
+ const payload = liveTopupPayload();
+ if (payload.amount <= 0) {
+ toast("充值金额不正确", "请重新选择金额");
+ return;
+ }
+ try {
+ await api("/api/billing/recharge/", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+ if (window.Shell) Shell.closeModal("topup-bg");
+ forgetCache("billing:summary");
+ forgetCache("billing:ledgers");
+ forgetCache("account:bundle");
+ forgetCache("dashboard");
+ toast("充值成功", "已写入 Django 账本");
+ await loadLiveAccount();
+ } catch (error) {
+ toast("充值确认失败", error.message);
+ }
+ }
+
+ function wireLiveAccount() {
+ if (page !== "account.html" || window.__airshelfLiveAccountWired) return;
+ window.__airshelfLiveAccountWired = true;
+ const fallbackTopupDone = window.topupDone;
+ window.topupDone = function () {
+ if (!canUseApi()) {
+ if (typeof fallbackTopupDone === "function") fallbackTopupDone();
+ return;
+ }
+ submitLiveTopup();
+ };
+ }
+
+ function liveTeamMemberRowHTML(member) {
+ const name = member.user?.first_name || member.user?.username || member.user?.email || "成员";
+ const role = roleUi(member.role);
+ const monthly = Number(member.monthly_credit_limit || 0);
+ const usedPct = monthly > 0 ? Math.min(100, (0 / monthly) * 100) : 0;
+ const isOwner = member.role === "owner";
+ const actions = isOwner
+ ? '不可编辑'
+ : '';
+ return (
+ '| ' +
+ initial(name) +
+ '' +
+ esc(name) +
+ '' +
+ esc(member.user?.email || "") +
+ ' | ' +
+ role.label +
+ ' | 不限 | ' +
+ (monthly > 0 ? money(monthly) : "不限") +
+ ' | ¥0.00 / ' +
+ usedPct.toFixed(0) +
+ '%
| ' +
+ actions +
+ " |
"
+ );
+ }
+
+ function renderLiveTeamMembers(filterText) {
+ const tbody = document.getElementById("members-tbody");
+ if (!tbody) return;
+ const members = window.__airshelfLiveTeamMembers || [];
+ const needle = String(filterText || "").trim().toLowerCase();
+ const list = members.filter(function (member) {
+ const name = member.user?.first_name || member.user?.username || "";
+ const email = member.user?.email || "";
+ return !needle || (name + " " + email).toLowerCase().indexOf(needle) >= 0;
+ });
+ tbody.innerHTML = list.length
+ ? list.map(liveTeamMemberRowHTML).join("")
+ : '| // 没有匹配的真实成员 |
';
+ const headingCount = document.querySelector(".members-table")?.closest(".pane")?.querySelector("h3 .ct");
+ if (headingCount) headingCount.textContent = "// " + list.length + " / " + members.length + " 人 · 真实团队表";
+ }
+
+ function liveMemberById(id) {
+ return (window.__airshelfLiveTeamMembers || []).find(function (member) {
+ return String(member.id) === String(id);
+ });
+ }
+
+ function selectedRoleValue(selector, fallback) {
+ const selected = document.querySelector(selector + " .role-choice.selected");
+ return selected?.dataset.role || selected?.dataset.editRole || fallback || "member";
+ }
+
+ async function refreshLiveTeamAfterMutation() {
+ forgetCache("team:members");
+ forgetCache("team:bundle");
+ forgetCache("account:bundle");
+ await loadLiveTeam();
+ }
+
+ async function submitLiveTeamMember(button) {
+ const username = (document.getElementById("inv-username")?.value || "").trim();
+ const password = (document.getElementById("inv-password")?.value || "").trim();
+ const name = (document.getElementById("inv-name")?.value || "").trim() || username;
+ const monthly = Number(document.getElementById("inv-monthly")?.value || 0);
+ if (!username || !password) {
+ toast("请填写用户名和密码", "成员未创建");
+ return;
+ }
+ const restore = setButtonBusy(button, "创建中...");
+ try {
+ const member = await api("/api/auth/team/members/", {
+ method: "POST",
+ body: JSON.stringify({
+ username: username,
+ password: password,
+ name: name,
+ role: selectedRoleValue("#invite-bg", "member"),
+ monthly_credit_limit: Number.isFinite(monthly) ? monthly : 0,
+ }),
+ });
+ if (window.Shell) Shell.closeModal("invite-bg");
+ const shareUser = document.getElementById("share-username");
+ const sharePassword = document.getElementById("share-password");
+ if (shareUser) shareUser.textContent = username;
+ if (sharePassword) sharePassword.textContent = password;
+ if (window.Shell) Shell.openModal("invite-share-bg");
+ toast("账户已创建", (member.user?.username || username) + " · 已写入团队表");
+ await refreshLiveTeamAfterMutation();
+ } catch (error) {
+ toast("创建成员失败", error.message);
+ } finally {
+ restore();
+ }
+ }
+
+ function openLiveTeamEdit(memberId) {
+ const member = liveMemberById(memberId);
+ if (!member) return;
+ window.__airshelfEditingMemberId = memberId;
+ const name = member.user?.first_name || member.user?.username || "";
+ const title = document.getElementById("edit-username");
+ const input = document.getElementById("edit-name-readonly");
+ if (title) title.textContent = name;
+ if (input) input.value = name;
+ document.querySelectorAll("#edit-role-choices .role-choice").forEach(function (choice) {
+ choice.classList.toggle("selected", choice.dataset.editRole === member.role || (member.role === "owner" && choice.dataset.editRole === "super"));
+ });
+ const daily = document.getElementById("edit-daily");
+ const monthly = document.getElementById("edit-monthly");
+ const total = document.getElementById("edit-total");
+ if (daily) daily.value = "-1";
+ if (monthly) monthly.value = Number(member.monthly_credit_limit || 0);
+ if (total) total.value = "-1";
+ if (window.Shell) Shell.openModal("edit-member-bg");
+ }
+
+ async function submitLiveTeamMemberEdit(button) {
+ const memberId = window.__airshelfEditingMemberId;
+ const monthly = Number(document.getElementById("edit-monthly")?.value || 0);
+ const restore = setButtonBusy(button, "保存中...");
+ try {
+ await api("/api/auth/team/members/" + encodeURIComponent(memberId) + "/", {
+ method: "PATCH",
+ body: JSON.stringify({
+ name: (document.getElementById("edit-name-readonly")?.value || "").trim(),
+ role: selectedRoleValue("#edit-role-choices", "member"),
+ monthly_credit_limit: Number.isFinite(monthly) ? monthly : 0,
+ }),
+ });
+ window.__airshelfEditingMemberId = null;
+ if (window.Shell) Shell.closeModal("edit-member-bg");
+ toast("成员已保存", "已写入 Django 团队表");
+ await refreshLiveTeamAfterMutation();
+ } catch (error) {
+ toast("保存成员失败", error.message);
+ } finally {
+ restore();
+ }
+ }
+
+ function openLiveTeamPassword(memberId) {
+ const member = liveMemberById(memberId);
+ if (!member) return;
+ window.__airshelfPasswordMemberId = memberId;
+ const name = member.user?.first_name || member.user?.username || "成员";
+ const title = document.getElementById("reset-pwd-name");
+ if (title) title.textContent = name;
+ const input = document.getElementById("reset-pwd-input");
+ if (input && !input.value.trim()) input.value = "Airshelf" + Math.floor(100000 + Math.random() * 900000);
+ if (window.Shell) Shell.openModal("reset-pwd-bg");
+ }
+
+ async function submitLiveTeamPassword(button) {
+ const memberId = window.__airshelfPasswordMemberId;
+ const password = (document.getElementById("reset-pwd-input")?.value || "").trim();
+ const restore = setButtonBusy(button, "重置中...");
+ try {
+ await api("/api/auth/team/members/" + encodeURIComponent(memberId) + "/password/", {
+ method: "POST",
+ body: JSON.stringify({ password: password }),
+ });
+ window.__airshelfPasswordMemberId = null;
+ if (window.Shell) Shell.closeModal("reset-pwd-bg");
+ toast("密码已重置", "成员旧会话已失效");
+ } catch (error) {
+ toast("重置密码失败", error.message);
+ } finally {
+ restore();
+ }
+ }
+
+ async function removeLiveTeamMember(memberId) {
+ const member = liveMemberById(memberId);
+ const name = member?.user?.first_name || member?.user?.username || "成员";
+ if (!confirm("确定将「" + name + "」移出团队?")) return;
+ try {
+ await api("/api/auth/team/members/" + encodeURIComponent(memberId) + "/", { method: "DELETE" });
+ toast("成员已移出", name);
+ await refreshLiveTeamAfterMutation();
+ } catch (error) {
+ toast("移出失败", error.message);
+ }
+ }
+
+ function runLiveTeamAction(action, memberId) {
+ if (action === "edit") openLiveTeamEdit(memberId);
+ if (action === "password") openLiveTeamPassword(memberId);
+ if (action === "remove") removeLiveTeamMember(memberId);
+ }
+
+ function wireLiveTeam() {
+ if (page !== "team.html" || window.__airshelfLiveTeamWired) return;
+ window.__airshelfLiveTeamWired = true;
+ const search = document.getElementById("member-search");
+ if (search) {
+ search.addEventListener(
+ "input",
+ function (event) {
+ if (!canUseApi()) return;
+ event.stopImmediatePropagation();
+ renderLiveTeamMembers(search.value);
+ },
+ true
+ );
+ }
+ document.addEventListener(
+ "click",
+ function (event) {
+ if (!canUseApi()) return;
+ const actionButton = event.target && event.target.closest ? event.target.closest("[data-live-team-action]") : null;
+ if (actionButton) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ runLiveTeamAction(actionButton.dataset.liveTeamAction, actionButton.dataset.memberId);
+ return;
+ }
+ const createButton = event.target && event.target.closest ? event.target.closest("#inv-send") : null;
+ if (createButton) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ submitLiveTeamMember(createButton);
+ return;
+ }
+ const editButton = event.target && event.target.closest ? event.target.closest("#edit-save") : null;
+ if (editButton && window.__airshelfEditingMemberId) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ submitLiveTeamMemberEdit(editButton);
+ return;
+ }
+ const pwdButton = event.target && event.target.closest ? event.target.closest("#reset-pwd-confirm") : null;
+ if (pwdButton && window.__airshelfPasswordMemberId) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ submitLiveTeamPassword(pwdButton);
+ }
+ },
+ true
+ );
+ }
+
+ function renderLiveTeamActivity(members) {
+ const feed = document.querySelector(".team-feed .feed-list");
+ if (feed) {
+ const count = members.length;
+ feed.innerHTML =
+ 'Q
真实团队表已同步' +
+ esc(count) +
+ ' 名成员
local cache
';
+ }
+ const feedCount = document.querySelector(".team-feed .h .ct");
+ if (feedCount) feedCount.textContent = "// 真实动态接口待接入";
+ const more = document.getElementById("open-feed-all");
+ if (more) more.hidden = true;
+ const allList = document.getElementById("feed-all-list");
+ if (allList) allList.innerHTML = '// 暂无真实团队动态
';
+ const allCount = document.getElementById("feed-all-count");
+ if (allCount) allCount.textContent = "// 共 0 条";
+ const allMeta = document.getElementById("feed-all-meta");
+ if (allMeta) allMeta.textContent = "// 共 0 条";
+ }
+
+ function renderLiveTeamPayload(payload) {
+ const meData = payload.me || {};
+ const summaryData = payload.summary || {};
+ const members = payload.members || [];
+ rememberAuthShape(meData);
+ window.__airshelfLiveTeamMembers = members;
+ const account = summaryData.account || {};
+ const used = Number(summaryData.charged_total || 0);
+ const limit = members.reduce(function (sum, member) {
+ return sum + Math.max(0, Number(member.monthly_credit_limit || 0));
+ }, 0) || Number(account.balance || 0);
+ const left = Math.max(0, limit - used);
+ const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
+ const nameEl = document.querySelector(".banner-id .nm");
+ if (nameEl) nameEl.innerHTML = esc(meData.team?.name || "团队") + ' 企业';
+ const metaEl = document.querySelector(".banner-id .meta");
+ if (metaEl) metaEl.textContent = "// 团队 ID: " + (meData.team?.id || "-") + " · " + members.length + " 名成员";
+ const statValues = document.querySelectorAll(".banner-stats .stat .v");
+ if (statValues[0]) statValues[0].textContent = money(account.balance || 0);
+ if (statValues[1]) statValues[1].textContent = money(limit);
+ if (statValues[2]) statValues[2].textContent = money(used);
+ if (statValues[3]) statValues[3].textContent = money(left);
+ const usedSub = document.getElementById("stat-used-sub");
+ const leftSub = document.getElementById("stat-left-sub");
+ if (usedSub) usedSub.textContent = "// 占月限 " + pct.toFixed(1) + "%";
+ if (leftSub) leftSub.textContent = "// 还可生成约 " + Math.max(0, Math.round(left / 10)) + " 个项目";
+ renderLiveTeamActivity(members);
+ renderLiveTeamMembers((document.getElementById("member-search") || {}).value || "");
+ }
+
+ async function loadLiveTeam() {
+ if (page !== "team.html" || !canUseApi()) return;
+ seedTeamBundleFromCache();
+ return loadWithCache(
+ "team:bundle",
+ async function () {
+ const meData = await apiGet("auth:me", "/api/auth/me/");
+ writeCache("auth:me", meData);
+ const summaryData = await apiGet("billing:summary", "/api/billing/summary/");
+ writeCache("billing:summary", summaryData);
+ const members = await apiGet("team:members", "/api/auth/team/members/");
+ writeCache("team:members", members);
+ return { me: meData, summary: summaryData, members: members };
+ },
+ renderLiveTeamPayload,
+ "团队数据加载失败"
+ );
+ }
+
+ function renderLivePipelinePayload(payload) {
+ const project = payload.project;
+ const productsData = payload.products || {};
+ if (!project) return;
+ const productMap = {};
+ (productsData.results || []).forEach(function (product) {
+ productMap[product.id] = product;
+ });
+ localStorage.setItem("airshelf_current_project_id", project.id);
+ const product = productMap[project.product] || {};
+ const productName = product.title || "未命名商品";
+ const title = document.querySelector(".pipeline-topbar-title");
+ if (title) {
+ title.innerHTML =
+ '' +
+ esc(project.name) +
+ '
// ' +
+ esc(productName) +
+ " · " +
+ esc(projectStatusLabel(project)) +
+ "
";
+ }
+ ["asset-prod-name", "asset-prod-card-name"].forEach(function (id) {
+ const el = document.getElementById(id);
+ if (el) el.textContent = productName;
+ });
+ const thumb = document.getElementById("asset-prod-thumb-label");
+ if (thumb) thumb.textContent = productName + " · 主图";
+ const cat = document.querySelector("#asset-prod-card .prod-cat");
+ if (cat) cat.textContent = product.category || "未分类";
+ const date = document.querySelector("#asset-prod-card .prod-date");
+ if (date) date.textContent = dateOnly(product.created_at) + " 创建";
+ const activeNo = stageNo(project);
+ document.querySelectorAll("#stage-pill .sp-dot").forEach(function (dot) {
+ const no = Number(dot.dataset.stage);
+ dot.classList.toggle("done", project.status === "completed" || no < activeNo);
+ dot.classList.toggle("active", no === activeNo && project.status !== "completed");
+ dot.classList.toggle("fail", project.status === "failed" && no === activeNo);
+ });
+ document.querySelectorAll("#stage-pill .sp-line").forEach(function (line, index) {
+ line.classList.toggle("done", index + 1 < activeNo || project.status === "completed");
+ });
+ }
+
+ async function loadLivePipeline() {
+ if (page !== "pipeline.html" || !canUseApi()) return;
+ const projectId = query.get("project_id") || localStorage.getItem("airshelf_current_project_id");
+ return loadWithCache(
+ "pipeline:" + (projectId || "latest"),
+ async function () {
+ const productsData = await apiGet("products:list", "/api/products/");
+ writeCache("products:list", productsData);
+ let project = null;
+ if (projectId) project = await apiGet("project:" + projectId, "/api/projects/" + encodeURIComponent(projectId) + "/");
+ if (!project) {
+ const data = await apiGet("projects:list", "/api/projects/");
+ project = (data.results || [])[0];
+ }
+ return new Promise(function (resolve) {
+ setTimeout(function () {
+ resolve({ products: productsData, project: project });
+ }, 120);
+ });
+ },
+ renderLivePipelinePayload,
+ "Pipeline 数据加载失败"
+ );
+ }
+
+ function monthStart() {
+ const now = new Date();
+ return new Date(now.getFullYear(), now.getMonth(), 1);
+ }
+
+ function isThisMonth(value) {
+ if (!value) return false;
+ const date = new Date(value);
+ return !Number.isNaN(date.getTime()) && date >= monthStart();
+ }
+
+ function moneyHTML(value) {
+ const text = money(value).replace("¥", "");
+ const parts = text.split(".");
+ return "¥" + esc(parts[0]) + (parts[1] ? "." + esc(parts[1]) + "" : "");
+ }
+
+ function dashboardRecentRowHTML(project, productMap) {
+ const product = productMap[project.product] || {};
+ const productName = product.title || "未命名商品";
+ const href = projectHref(project);
+ const shots = (project.video_segments && project.video_segments.length) || 4;
+ return (
+ '9:16
' +
+ progressHTML(project) +
+ '
' +
+ esc(projectStatusLabel(project)) +
+ '' +
+ (project.status === "completed" ? "打开" : "继续") +
+ ""
+ );
+ }
+
+ function renderLiveDashboardPayload(payload) {
+ const meData = payload.me || {};
+ const summaryData = payload.summary || {};
+ const productsData = payload.products || {};
+ const projectsData = payload.projects || {};
+ const assetsData = payload.assets || {};
+ rememberAuthShape(meData);
+
+ const projects = projectsData.results || [];
+ const products = productsData.results || [];
+ const assets = assetsData.results || [];
+ const productMap = {};
+ products.forEach(function (product) {
+ productMap[product.id] = product;
+ });
+ const wip = projects.filter(function (project) {
+ return project.status !== "completed" && project.status !== "failed";
+ }).length;
+ const done = projects.filter(function (project) {
+ return project.status === "completed";
+ }).length;
+ const monthDone = projects.filter(function (project) {
+ return project.status === "completed" && isThisMonth(project.updated_at || project.created_at);
+ }).length;
+ const failed = projects.filter(function (project) {
+ return project.status === "failed";
+ }).length;
+ const balance = Number(summaryData.account?.balance || 0);
+ const charged = Number(summaryData.charged_total || 0);
+ const budget = Math.max(balance + charged, 1);
+ const budgetPct = Math.min(100, (charged / budget) * 100);
+
+ const h1 = document.querySelector(".page-head h1");
+ if (h1) h1.textContent = "欢迎回来," + displayName(meData.user);
+ const sub = document.querySelector(".page-head .sub");
+ if (sub) {
+ const now = new Date();
+ const day = new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", weekday: "short" }).format(now);
+ sub.innerHTML =
+ '// ' +
+ esc(day.replace(/\//g, ".")) +
+ '·你有 ' +
+ esc(wip) +
+ " 个项目 正在进行中";
+ }
+
+ const stats = document.querySelectorAll(".stats .stat");
+ if (stats[0]) {
+ const v = stats[0].querySelector(".v");
+ const d = stats[0].querySelector(".delta");
+ if (v) v.textContent = String(projects.length);
+ if (d) d.textContent = "本月完成 " + monthDone;
+ }
+ if (stats[1]) {
+ const v = stats[1].querySelector(".v");
+ const d = stats[1].querySelector(".delta");
+ if (v) v.textContent = String(wip);
+ if (d) d.textContent = failed ? failed + " 个失败需处理" : "全部正常推进";
+ }
+ if (stats[2]) {
+ const v = stats[2].querySelector(".v");
+ const d = stats[2].querySelector(".delta");
+ if (v) v.textContent = String(monthDone || done);
+ if (d) d.textContent = "累计完成 " + done;
+ }
+ if (stats[3]) {
+ const v = stats[3].querySelector(".v");
+ const bar = stats[3].querySelector(".bar span");
+ const subText = stats[3].querySelector(".sub");
+ if (v) v.innerHTML = moneyHTML(balance);
+ if (bar) bar.style.width = budgetPct.toFixed(0) + "%";
+ if (subText) subText.textContent = "已用 " + money(charged) + " / " + money(budget);
+ }
+
+ const more = document.querySelector(".dash-grid .section-h .more[href='projects.html']");
+ if (more) more.textContent = "[ ALL · " + projects.length + " ]";
+ const recentBox = document.querySelector(".card-hard");
+ if (recentBox) {
+ const recent = projects.slice(0, 5);
+ recentBox.innerHTML = recent.length
+ ? recent
+ .map(function (project) {
+ return dashboardRecentRowHTML(project, productMap);
+ })
+ .join("")
+ : '// 当前团队还没有真实项目
';
+ }
+ const shortcutData = [
+ products.length + " SKU",
+ "资产 " + assets.length + " 个",
+ money(balance),
+ projects.length + " 个",
+ ];
+ document.querySelectorAll(".shortcut .d").forEach(function (el, index) {
+ if (shortcutData[index]) el.textContent = shortcutData[index];
+ });
+ }
+
+ async function loadLiveDashboard() {
+ if (page !== "index.html" || !canUseApi()) return;
+ return loadWithCache(
+ "dashboard",
+ async function () {
+ const meData = await apiGet("auth:me", "/api/auth/me/");
+ writeCache("auth:me", meData);
+ const summaryData = await apiGet("billing:summary", "/api/billing/summary/");
+ writeCache("billing:summary", summaryData);
+ const productsData = await apiGet("products:list", "/api/products/");
+ writeCache("products:list", productsData);
+ const projectsData = await apiGet("projects:list", "/api/projects/");
+ const assetsData = await apiGet("assets:list", "/api/assets/");
+ writeCache("assets:list", assetsData);
+ return { me: meData, summary: summaryData, products: productsData, projects: projectsData, assets: assetsData };
+ },
+ renderLiveDashboardPayload,
+ "工作台数据加载失败"
+ );
+ }
+
+ function settingsRowByLabel(label) {
+ return Array.from(document.querySelectorAll("#sec-profile .form-row")).find(function (row) {
+ const lbl = row.querySelector(".lbl");
+ return lbl && lbl.textContent.indexOf(label) >= 0;
+ });
+ }
+
+ function collectSettingsPrefs() {
+ const fields = {};
+ document.querySelectorAll('[data-track], input[type="checkbox"], select').forEach(function (el) {
+ if (!el.id) return;
+ fields[el.id] = el.type === "checkbox" ? Boolean(el.checked) : el.value;
+ });
+ const choices = {};
+ ["pref-template", "pref-duration", "pref-subtitle"].forEach(function (id) {
+ const selected = document.querySelector("#" + id + " .selected");
+ if (selected) choices[id] = selected.dataset.v || selected.textContent.trim();
+ });
+ const avatar = document.getElementById("prof-avatar-preview");
+ return {
+ fields: fields,
+ choices: choices,
+ avatarText: avatar ? avatar.textContent.trim() : "",
+ };
+ }
+
+ function applySettingsPrefs(prefs) {
+ if (!prefs) return;
+ Object.keys(prefs.fields || {}).forEach(function (id) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ if (el.type === "checkbox") el.checked = Boolean(prefs.fields[id]);
+ else el.value = prefs.fields[id];
+ });
+ Object.keys(prefs.choices || {}).forEach(function (id) {
+ const value = prefs.choices[id];
+ document.querySelectorAll("#" + id + " .pref-choice, #" + id + " .dur-chip").forEach(function (node) {
+ const nodeValue = node.dataset.v || node.textContent.trim();
+ node.classList.toggle("selected", nodeValue === value);
+ });
+ });
+ if (prefs.avatarText) {
+ ["prof-avatar-preview", "av-up-preview"].forEach(function (id) {
+ const el = document.getElementById(id);
+ if (el) el.textContent = prefs.avatarText;
+ });
+ }
+ }
+
+ function renderLiveSettingsPayload(payload) {
+ const meData = payload.me || {};
+ const members = payload.members || [];
+ rememberAuthShape(meData);
+ const user = meData.user || {};
+ const team = meData.team || {};
+ const name = displayName(user);
+ const email = user.email || "";
+ const phone = user.phone || "";
+ const avatar = initial(name);
+ const member =
+ members.find(function (item) {
+ return item.user?.id === user.id || item.user?.email === email;
+ }) || members[0] || {};
+ const role = roleUi(member.role);
+
+ const avatarEl = document.getElementById("prof-avatar-preview");
+ if (avatarEl) avatarEl.textContent = avatar;
+ const avatarUp = document.getElementById("av-up-preview");
+ if (avatarUp) avatarUp.textContent = avatar;
+ const avatarName = document.getElementById("av-up-preview-name");
+ if (avatarName) avatarName.textContent = "当前头像 · 默认";
+ const avatarInfo = document.getElementById("av-up-preview-info");
+ if (avatarInfo) avatarInfo.textContent = "// 系统生成 · 取显示名称首字";
+
+ const nameInput = document.getElementById("prof-name");
+ const emailInput = document.getElementById("prof-email");
+ const phoneInput = document.getElementById("prof-phone");
+ if (nameInput) nameInput.value = name;
+ if (emailInput) emailInput.value = email;
+ if (phoneInput) phoneInput.value = phone;
+
+ const emailBtn = emailInput?.closest(".val")?.querySelector("button");
+ if (emailBtn) emailBtn.onclick = function () { Shell.toast("已发送验证邮件", email || "当前邮箱"); };
+ const phoneBtn = phoneInput?.closest(".val")?.querySelector("button");
+ if (phoneBtn) phoneBtn.onclick = function () { Shell.toast("已发送短信验证码", phone || "未绑定手机号"); };
+
+ const teamRow = settingsRowByLabel("所属团队");
+ if (teamRow) {
+ const staticEl = teamRow.querySelector(".static");
+ const roleEl = teamRow.querySelector(".role-tag");
+ if (staticEl) staticEl.textContent = team.name || "团队";
+ if (roleEl) roleEl.innerHTML = '' + esc(role.label) + (member.role === "owner" ? " · 创建者" : "");
+ }
+ const idRow = settingsRowByLabel("用户 ID");
+ if (idRow) {
+ const idEl = idRow.querySelector(".static");
+ if (idEl) idEl.textContent = userPublicId(user);
+ }
+
+ applySettingsPrefs(readCache("settings:prefs"));
+ }
+
+ function wireLiveSettings() {
+ if (page !== "settings.html") return;
+ const save = document.getElementById("save-btn");
+ if (save) {
+ save.addEventListener("click", function () {
+ setTimeout(function () {
+ const invalid = document.querySelector("#sec-profile .input.invalid");
+ if (!invalid) {
+ writeCache("settings:prefs", collectSettingsPrefs());
+ toast("本地设置已更新", "已写入 localStorage, 下次访问会先显示本地数据");
+ }
+ }, 0);
+ });
+ }
+ const reset = document.getElementById("prof-avatar-reset");
+ if (reset) {
+ reset.addEventListener("click", function () {
+ setTimeout(function () {
+ const current = readCache("settings:profile");
+ if (current) renderLiveSettingsPayload(current);
+ }, 0);
+ });
+ }
+ }
+
+ async function loadLiveSettings() {
+ if (page !== "settings.html" || !canUseApi()) return;
+ if (!readCache("settings:profile")) {
+ const me = cachedAuthShape();
+ if (me) {
+ writeCache("settings:profile", {
+ me: me,
+ summary: readCache("billing:summary"),
+ members: readCache("team:members") || [],
+ });
+ }
+ }
+ return loadWithCache(
+ "settings:profile",
+ async function () {
+ const meData = await apiGet("auth:me", "/api/auth/me/");
+ writeCache("auth:me", meData);
+ const summaryData = await apiGet("billing:summary", "/api/billing/summary/").catch(function () {
+ return null;
+ });
+ if (summaryData) writeCache("billing:summary", summaryData);
+ const members = await apiGet("team:members", "/api/auth/team/members/").catch(function () {
+ return [];
+ });
+ writeCache("team:members", members);
+ return { me: meData, summary: summaryData, members: members };
+ },
+ renderLiveSettingsPayload,
+ "设置数据加载失败"
+ );
+ }
+
+ const messageTypeLabel = { all: "全部", unread: "未读", task: "任务", team: "团队", billing: "计费", system: "系统" };
+ const messageTypeIcon = { task: "clapperboard", team: "users", billing: "creditCard", system: "info" };
+ const messagePriorityLabel = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" };
+
+ function messageIcon(name) {
+ return window.IconKit && typeof window.IconKit.svg === "function" ? window.IconKit.svg(name) : "";
+ }
+
+ function messageDate(value) {
+ const date = value ? new Date(value) : new Date();
+ return Number.isNaN(date.getTime()) ? new Date() : date;
+ }
+
+ function messageTimeAgo(value) {
+ const diff = Math.max(0, Date.now() - messageDate(value).getTime());
+ const minutes = Math.floor(diff / 60000);
+ if (minutes < 1) return "刚刚";
+ if (minutes < 60) return minutes + "m";
+ if (minutes < 1440) return Math.floor(minutes / 60) + "h";
+ return Math.floor(minutes / 1440) + "d";
+ }
+
+ function messageFullTime(value) {
+ const date = messageDate(value);
+ const z = function (n) {
+ return String(n).padStart(2, "0");
+ };
+ return date.getFullYear() + "-" + z(date.getMonth() + 1) + "-" + z(date.getDate()) + " " + z(date.getHours()) + ":" + z(date.getMinutes());
+ }
+
+ function notificationType(item) {
+ return item.type || item.notification_type || "system";
+ }
+
+ function notificationUnread(item) {
+ if (item.unread !== undefined) return Boolean(item.unread);
+ return item.is_read !== true;
+ }
+
+ function normalizeNotification(item) {
+ const metadata = item.metadata || {};
+ const type = notificationType(item);
+ return {
+ id: String(item.id || item.dedupe_key || item.title || ""),
+ type: type,
+ priority: item.priority || metadata.priority || "info",
+ unread: notificationUnread(item),
+ title: item.title || "系统消息",
+ brief: item.brief || metadata.brief || "",
+ body: item.body || item.brief || metadata.body || "暂无详情。",
+ source: item.source || metadata.source || "Airshelf",
+ project: item.project_name || metadata.project_name || metadata.project || "系统",
+ stage: item.stage || metadata.stage || "通知",
+ owner: item.owner_label || metadata.owner || metadata.owner_label || "系统",
+ cost: item.cost_label || metadata.cost || metadata.cost_label || "-",
+ href: item.related_url || metadata.href || metadata.related_url || "",
+ time: item.created_at || item.updated_at || item.time || new Date().toISOString(),
+ metadata: metadata,
+ raw: item,
+ };
+ }
+
+ function normalizeNotificationsPayload(data) {
+ const rows = listResults(data).map(normalizeNotification).filter(function (item) {
+ return item.id;
+ });
+ const unread = unreadNotificationCount(data);
+ return {
+ count: collectionCount(data) ?? rows.length,
+ unread_count: unread === null || unread === undefined ? rows.filter(function (item) { return item.unread; }).length : unread,
+ results: rows,
+ };
+ }
+
+ function notificationCachePayload(messages) {
+ const rows = (messages || []).map(function (message) {
+ return {
+ id: message.id,
+ type: message.type,
+ notification_type: message.type,
+ priority: message.priority,
+ title: message.title,
+ brief: message.brief,
+ body: message.body,
+ source: message.source,
+ project_name: message.project,
+ stage: message.stage,
+ owner_label: message.owner,
+ cost_label: message.cost,
+ related_url: message.href,
+ is_read: !message.unread,
+ unread: message.unread,
+ created_at: message.time,
+ metadata: message.metadata || {},
+ };
+ });
+ return {
+ count: rows.length,
+ next: null,
+ previous: null,
+ unread_count: rows.filter(function (item) { return item.unread; }).length,
+ results: rows,
+ };
+ }
+
+ function persistLiveMessages(messages) {
+ const payload = notificationCachePayload(messages || []);
+ delete requestMemo["notifications:list"];
+ writeCache("notifications:list", payload);
+ writeCache("notifications:summary", { count: payload.count, unread_count: payload.unread_count });
+ return payload;
+ }
+
+ function liveMessageState() {
+ if (!window.__airshelfLiveMessageState) {
+ window.__airshelfLiveMessageState = { tab: "all", q: "", selectedId: null, showLogId: null };
+ }
+ return window.__airshelfLiveMessageState;
+ }
+
+ function liveMessages() {
+ return Array.isArray(window.__airshelfLiveMessages) ? window.__airshelfLiveMessages : [];
+ }
+
+ function liveVisibleMessages() {
+ const state = liveMessageState();
+ const q = String(state.q || "").trim().toLowerCase();
+ return liveMessages().filter(function (message) {
+ if (state.tab === "unread" && !message.unread) return false;
+ if (!["all", "unread"].includes(state.tab) && message.type !== state.tab) return false;
+ if (!q) return true;
+ return [message.title, message.brief, message.body, message.source, message.project, message.stage]
+ .join(" ")
+ .toLowerCase()
+ .indexOf(q) >= 0;
+ });
+ }
+
+ function liveMessageCounts() {
+ const rows = liveMessages();
+ return {
+ all: rows.length,
+ unread: rows.filter(function (message) { return message.unread; }).length,
+ task: rows.filter(function (message) { return message.type === "task"; }).length,
+ team: rows.filter(function (message) { return message.type === "team"; }).length,
+ billing: rows.filter(function (message) { return message.type === "billing"; }).length,
+ system: rows.filter(function (message) { return message.type === "system"; }).length,
+ };
+ }
+
+ function liveMessageTimeline(message) {
+ if (Array.isArray(message.metadata?.timeline)) return message.metadata.timeline;
+ const created = messageFullTime(message.time);
+ const rows = [[created, "通知写入团队消息中心"]];
+ if (!message.unread) rows.push([messageFullTime(message.raw?.read_at || message.time), "当前用户已读"]);
+ return rows;
+ }
+
+ function renderLiveMessageFilters() {
+ const box = document.getElementById("msg-filters");
+ if (!box) return;
+ const state = liveMessageState();
+ const c = liveMessageCounts();
+ const filters = [
+ ["all", "全部", c.all],
+ ["unread", "未读", c.unread],
+ ["task", "任务", c.task],
+ ["team", "团队", c.team],
+ ["billing", "计费", c.billing],
+ ["system", "系统", c.system],
+ ];
+ box.innerHTML = filters
+ .map(function (filter) {
+ return (
+ '"
+ );
+ })
+ .join("");
+ box.querySelectorAll("[data-live-tab]").forEach(function (button) {
+ button.addEventListener("click", function (event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ state.tab = button.dataset.liveTab || "all";
+ renderLiveMessages();
+ });
+ });
+ }
+
+ function renderLiveMessageList() {
+ const listEl = document.getElementById("msg-list");
+ const countEl = document.getElementById("msg-list-count");
+ if (!listEl) return;
+ const state = liveMessageState();
+ const list = liveVisibleMessages();
+ if (countEl) countEl.textContent = "// 显示 " + list.length + " 条";
+ if (!list.length) {
+ listEl.innerHTML = '' + messageIcon("search") + "没有符合条件的消息
";
+ return;
+ }
+ listEl.innerHTML = list
+ .map(function (message) {
+ return (
+ '"
+ );
+ })
+ .join("");
+ listEl.querySelectorAll("[data-live-message-id]").forEach(function (button) {
+ button.addEventListener("click", function (event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ selectLiveMessage(button.dataset.liveMessageId);
+ });
+ });
+ }
+
+ function renderLiveMessageDetail() {
+ const detail = document.getElementById("msg-detail");
+ if (!detail) return;
+ const state = liveMessageState();
+ const list = liveMessages();
+ let message = list.find(function (item) {
+ return item.id === state.selectedId;
+ });
+ if (!message) message = liveVisibleMessages()[0] || list[0];
+ if (!message) {
+ state.selectedId = null;
+ detail.innerHTML = '' + messageIcon("bell") + "
暂无消息
";
+ return;
+ }
+ state.selectedId = message.id;
+ const props = [
+ ["来源", message.source],
+ ["类别", messageTypeLabel[message.type] || "系统"],
+ ["项目", message.project],
+ ["阶段", message.stage],
+ ["负责人", message.owner],
+ ["费用", message.cost],
+ ["时间", messageFullTime(message.time)],
+ ["关联资源", message.href ? '' + esc(message.project) + " →" : "无"],
+ ]
+ .map(function (row) {
+ return '' + esc(row[0]) + '' + row[1] + "";
+ })
+ .join("");
+ const timeline = liveMessageTimeline(message)
+ .map(function (row) {
+ return '' + esc(row[0]) + '' + esc(row[1]) + "
";
+ })
+ .join("");
+ const primaryAction = message.href
+ ? ''
+ : '';
+ const readAction = message.unread
+ ? ''
+ : '';
+ detail.innerHTML =
+ '' +
+ messageIcon(messageTypeIcon[message.type] || "info") +
+ '' +
+ esc(message.title) +
+ '
' +
+ esc(message.source) +
+ "// " +
+ esc(messageTypeLabel[message.type] || "系统") +
+ "" +
+ esc(messageFullTime(message.time)) +
+ '
' +
+ esc(messagePriorityLabel[message.priority] || "更新") +
+ ' ' +
+ esc(message.body) +
+ '
' +
+ props +
+ '
' +
+ readAction +
+ primaryAction +
+ "
";
+ detail.querySelectorAll("[data-live-action]").forEach(function (button) {
+ button.addEventListener("click", function (event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ runLiveMessageAction(message.id, button.dataset.liveAction, button);
+ });
+ });
+ }
+
+ function renderLiveMessages() {
+ const head = document.getElementById("msg-head-sub");
+ const counts = liveMessageCounts();
+ if (head) head.textContent = "// " + counts.unread + " 条未读 · " + counts.all + " 条总计";
+ renderLiveMessageFilters();
+ renderLiveMessageList();
+ renderLiveMessageDetail();
+ persistLiveMessages(liveMessages());
+ }
+
+ function setLiveMessageReadState(id, unread, response) {
+ const rows = liveMessages();
+ const next = response ? normalizeNotification(response) : null;
+ const index = rows.findIndex(function (item) {
+ return item.id === id;
+ });
+ if (index < 0) return;
+ if (next) rows[index] = next;
+ else rows[index].unread = unread;
+ window.__airshelfLiveMessages = rows;
+ renderLiveMessages();
+ }
+
+ async function markLiveMessageRead(id) {
+ const message = liveMessages().find(function (item) {
+ return item.id === id;
+ });
+ if (!message || !message.unread) return;
+ setLiveMessageReadState(id, false);
+ try {
+ const response = await api("/api/ops/notifications/" + encodeURIComponent(id) + "/mark-read/", {
+ method: "POST",
+ body: JSON.stringify({}),
+ });
+ setLiveMessageReadState(id, false, response);
+ } catch (error) {
+ setLiveMessageReadState(id, true);
+ toast("已读状态保存失败", error.message);
+ }
+ }
+
+ async function markLiveMessageUnread(id) {
+ setLiveMessageReadState(id, true);
+ try {
+ const response = await api("/api/ops/notifications/" + encodeURIComponent(id) + "/mark-unread/", {
+ method: "POST",
+ body: JSON.stringify({}),
+ });
+ setLiveMessageReadState(id, true, response);
+ } catch (error) {
+ setLiveMessageReadState(id, false);
+ toast("未读状态保存失败", error.message);
+ }
+ }
+
+ function selectLiveMessage(id) {
+ const state = liveMessageState();
+ state.selectedId = id;
+ renderLiveMessages();
+ markLiveMessageRead(id);
+ }
+
+ async function archiveLiveMessage(id, button) {
+ const message = liveMessages().find(function (item) {
+ return item.id === id;
+ });
+ if (!message) return;
+ const restore = setButtonBusy(button, "归档中...");
+ try {
+ await api("/api/ops/notifications/" + encodeURIComponent(id) + "/archive/", {
+ method: "POST",
+ body: JSON.stringify({}),
+ });
+ window.__airshelfLiveMessages = liveMessages().filter(function (item) {
+ return item.id !== id;
+ });
+ const state = liveMessageState();
+ state.selectedId = liveMessages()[0]?.id || null;
+ renderLiveMessages();
+ toast("已归档", message.title);
+ } catch (error) {
+ toast("归档失败", error.message);
+ } finally {
+ restore();
+ }
+ }
+
+ async function markAllLiveMessagesRead(button) {
+ const rows = liveMessages();
+ if (!rows.length) return;
+ const restore = setButtonBusy(button, "保存中...");
+ try {
+ await api("/api/ops/notifications/mark-all-read/", {
+ method: "POST",
+ body: JSON.stringify({}),
+ });
+ rows.forEach(function (message) {
+ message.unread = false;
+ });
+ window.__airshelfLiveMessages = rows;
+ renderLiveMessages();
+ toast("已全部标为已读", rows.length + " 条消息已写入数据库");
+ } catch (error) {
+ toast("批量更新失败", error.message);
+ } finally {
+ restore();
+ }
+ }
+
+ function runLiveMessageAction(id, action, button) {
+ const message = liveMessages().find(function (item) {
+ return item.id === id;
+ });
+ if (!message) return;
+ if (action === "goto") {
+ go(message.href || "settings.html#sec-notify");
+ return;
+ }
+ if (action === "settings") {
+ go("settings.html#sec-notify");
+ return;
+ }
+ if (action === "ack") {
+ markLiveMessageRead(id);
+ return;
+ }
+ if (action === "unread") {
+ markLiveMessageUnread(id);
+ return;
+ }
+ if (action === "archive") {
+ archiveLiveMessage(id, button);
+ return;
+ }
+ if (action === "mute") {
+ toast("已记录静音偏好", (messageTypeLabel[message.type] || "系统") + " 类提醒设置入口在通知设置");
+ }
+ }
+
+ function renderLiveMessagesPayload(data) {
+ const payload = normalizeNotificationsPayload(data);
+ window.__airshelfLiveMessages = payload.results;
+ const state = liveMessageState();
+ if (!payload.results.some(function (message) { return message.id === state.selectedId; })) {
+ state.selectedId = payload.results[0]?.id || null;
+ }
+ renderLiveMessages();
+ }
+
+ function wireLiveMessages() {
+ if (page !== "messages.html" || window.__airshelfLiveMessagesWired) return;
+ window.__airshelfLiveMessagesWired = true;
+ const search = document.getElementById("msg-search");
+ if (search) {
+ search.addEventListener(
+ "input",
+ function (event) {
+ if (!canUseApi()) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ liveMessageState().q = search.value.trim();
+ renderLiveMessages();
+ },
+ true
+ );
+ }
+ const markAll = document.getElementById("msg-mark-all");
+ if (markAll) {
+ markAll.addEventListener(
+ "click",
+ function (event) {
+ if (!canUseApi()) return;
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+ markAllLiveMessagesRead(markAll);
+ },
+ true
+ );
+ }
+ const settings = document.getElementById("msg-settings");
+ if (settings) {
+ settings.addEventListener(
+ "click",
+ function (event) {
+ if (!canUseApi()) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ go("settings.html#sec-notify");
+ },
+ true
+ );
+ }
+ }
+
+ async function loadLiveMessages() {
+ if (page !== "messages.html" || !canUseApi()) return;
+ return loadWithCache(
+ "notifications:list",
+ function () {
+ return apiGet("notifications:list", "/api/ops/notifications/");
+ },
+ renderLiveMessagesPayload,
+ "消息加载失败"
+ );
+ }
+
+ function init() {
+ wireAuth();
+ ready(function () {
+ applyContextHash();
+ markHydrationLoading();
+ const work = (async function () {
+ hydrateShellIdentity();
+ if (page === "index.html") await loadLiveDashboard();
+ if (page === "products.html") {
+ wireProductCreate();
+ await loadLiveProducts();
+ }
+ if (page === "product-detail.html") await hydrateProductDetail();
+ if (page === "projects.html") await loadLiveProjects();
+ if (page === "projects-new.html") {
+ wireProjectWizard();
+ await loadLiveWizardProducts();
+ }
+ if (page === "library.html") {
+ wireLiveAssets();
+ await loadLiveAssets();
+ }
+ if (page === "account.html") {
+ wireLiveAccount();
+ await loadLiveAccount();
+ }
+ if (page === "settings.html") {
+ wireLiveSettings();
+ await loadLiveSettings();
+ }
+ if (page === "team.html") {
+ wireLiveTeam();
+ await loadLiveTeam();
+ }
+ if (page === "messages.html") {
+ wireLiveMessages();
+ await loadLiveMessages();
+ }
+ if (page === "pipeline.html") await loadLivePipeline();
+ })();
+
+ work
+ .then(function () {
+ applyContextHash();
+ markHydrationDone();
+ })
+ .catch(function (error) {
+ markHydrationError(error && error.message);
+ });
+ });
+ }
+
+ window.AirShelfBridge = {
+ version: BRIDGE_VERSION,
+ api: api,
+ isLive: isLive,
+ canUseApi: canUseApi,
+ loadLiveProducts: loadLiveProducts,
+ loadLiveProjects: loadLiveProjects,
+ loadLiveDashboard: loadLiveDashboard,
+ loadLiveSettings: loadLiveSettings,
+ loadLiveMessages: loadLiveMessages,
+ };
+
+ init();
+})();
diff --git a/core/frontend/public/exact/assets/icons.js b/core/frontend/public/exact/assets/icons.js
new file mode 100644
index 0000000..eda9f3d
--- /dev/null
+++ b/core/frontend/public/exact/assets/icons.js
@@ -0,0 +1,94 @@
+(function () {
+ const PATHS = {
+ home: '',
+ layoutDashboard: '',
+ package: '',
+ boxes: '',
+ clapperboard: '',
+ film: '',
+ video: '',
+ sparkles: '',
+ images: '',
+ folder: '',
+ library: '',
+ users: '',
+ wallet: '',
+ creditCard: '',
+ settings: '',
+ airshelf: '',
+ flame: '',
+ search: '',
+ bell: '',
+ list: '',
+ chevronLeft: '',
+ chevronRight: '',
+ check: '',
+ x: '',
+ plus: '',
+ productPlus: '',
+ arrowRight: '',
+ arrowLeft: '',
+ arrowUp: '',
+ chevronDown: '',
+ rotateCcw: '',
+ download: '',
+ upload: '',
+ moreHorizontal: '',
+ trash: '',
+ edit: '',
+ play: '',
+ clock: '',
+ alertCircle: '',
+ info: '',
+ shieldCheck: '',
+ messageCircle: '',
+ mail: '',
+ lock: '',
+ eye: '',
+ image: '',
+ grid: '',
+ copy: '',
+ save: '',
+ userPlus: '',
+ helpCircle: ''
+ };
+
+ 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 ``;
+ }
+
+ function escapeAttr(value) {
+ return String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ }
+
+ window.IconKit = {
+ svg,
+ names: () => Object.keys(PATHS),
+ has: name => !!PATHS[ALIASES[name] || name]
+ };
+})();
diff --git a/core/frontend/public/exact/assets/logo-dark.png b/core/frontend/public/exact/assets/logo-dark.png
new file mode 100644
index 0000000..42f1fdb
Binary files /dev/null and b/core/frontend/public/exact/assets/logo-dark.png differ
diff --git a/core/frontend/public/exact/assets/logo.png b/core/frontend/public/exact/assets/logo.png
new file mode 100644
index 0000000..94121b1
Binary files /dev/null and b/core/frontend/public/exact/assets/logo.png differ
diff --git a/core/frontend/public/exact/assets/mock-media.js b/core/frontend/public/exact/assets/mock-media.js
new file mode 100644
index 0000000..2f3253c
--- /dev/null
+++ b/core/frontend/public/exact/assets/mock-media.js
@@ -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();
+ }
+})();
diff --git a/core/frontend/public/exact/assets/mock/cover-air-fryer.png b/core/frontend/public/exact/assets/mock/cover-air-fryer.png
new file mode 100644
index 0000000..ba8ba94
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-air-fryer.png differ
diff --git a/core/frontend/public/exact/assets/mock/cover-coffee.png b/core/frontend/public/exact/assets/mock/cover-coffee.png
new file mode 100644
index 0000000..c21d895
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-coffee.png differ
diff --git a/core/frontend/public/exact/assets/mock/cover-earbuds.png b/core/frontend/public/exact/assets/mock/cover-earbuds.png
new file mode 100644
index 0000000..3c11af7
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-earbuds.png differ
diff --git a/core/frontend/public/exact/assets/mock/cover-mask-final.png b/core/frontend/public/exact/assets/mock/cover-mask-final.png
new file mode 100644
index 0000000..e44cf5b
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-mask-final.png differ
diff --git a/core/frontend/public/exact/assets/mock/cover-mask-v3.png b/core/frontend/public/exact/assets/mock/cover-mask-v3.png
new file mode 100644
index 0000000..f309950
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-mask-v3.png differ
diff --git a/core/frontend/public/exact/assets/mock/cover-noodle.png b/core/frontend/public/exact/assets/mock/cover-noodle.png
new file mode 100644
index 0000000..b57055f
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-noodle.png differ
diff --git a/core/frontend/public/exact/assets/mock/cover-sunscreen.png b/core/frontend/public/exact/assets/mock/cover-sunscreen.png
new file mode 100644
index 0000000..43e9d6c
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-sunscreen.png differ
diff --git a/core/frontend/public/exact/assets/mock/cover-yoga.png b/core/frontend/public/exact/assets/mock/cover-yoga.png
new file mode 100644
index 0000000..1e7ee75
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/cover-yoga.png differ
diff --git a/core/frontend/public/exact/assets/mock/person-ajie.png b/core/frontend/public/exact/assets/mock/person-ajie.png
new file mode 100644
index 0000000..8350809
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/person-ajie.png differ
diff --git a/core/frontend/public/exact/assets/mock/person-aqiang.png b/core/frontend/public/exact/assets/mock/person-aqiang.png
new file mode 100644
index 0000000..10d84af
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/person-aqiang.png differ
diff --git a/core/frontend/public/exact/assets/mock/person-linxi.png b/core/frontend/public/exact/assets/mock/person-linxi.png
new file mode 100644
index 0000000..6b1a056
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/person-linxi.png differ
diff --git a/core/frontend/public/exact/assets/mock/person-xiaosu.png b/core/frontend/public/exact/assets/mock/person-xiaosu.png
new file mode 100644
index 0000000..d27adfd
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/person-xiaosu.png differ
diff --git a/core/frontend/public/exact/assets/mock/product-air-fryer.png b/core/frontend/public/exact/assets/mock/product-air-fryer.png
new file mode 100644
index 0000000..2676fa5
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-air-fryer.png differ
diff --git a/core/frontend/public/exact/assets/mock/product-coffee.png b/core/frontend/public/exact/assets/mock/product-coffee.png
new file mode 100644
index 0000000..8ea2166
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-coffee.png differ
diff --git a/core/frontend/public/exact/assets/mock/product-earbuds.png b/core/frontend/public/exact/assets/mock/product-earbuds.png
new file mode 100644
index 0000000..16f4cb0
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-earbuds.png differ
diff --git a/core/frontend/public/exact/assets/mock/product-mask.png b/core/frontend/public/exact/assets/mock/product-mask.png
new file mode 100644
index 0000000..66fb12a
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-mask.png differ
diff --git a/core/frontend/public/exact/assets/mock/product-noodle.png b/core/frontend/public/exact/assets/mock/product-noodle.png
new file mode 100644
index 0000000..742130f
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-noodle.png differ
diff --git a/core/frontend/public/exact/assets/mock/product-storage.png b/core/frontend/public/exact/assets/mock/product-storage.png
new file mode 100644
index 0000000..c97c18c
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-storage.png differ
diff --git a/core/frontend/public/exact/assets/mock/product-sunscreen.png b/core/frontend/public/exact/assets/mock/product-sunscreen.png
new file mode 100644
index 0000000..fa3af5e
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-sunscreen.png differ
diff --git a/core/frontend/public/exact/assets/mock/product-yoga-pants.png b/core/frontend/public/exact/assets/mock/product-yoga-pants.png
new file mode 100644
index 0000000..14550bd
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/product-yoga-pants.png differ
diff --git a/core/frontend/public/exact/assets/mock/scene-bathroom.png b/core/frontend/public/exact/assets/mock/scene-bathroom.png
new file mode 100644
index 0000000..db3a459
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-bathroom.png differ
diff --git a/core/frontend/public/exact/assets/mock/scene-bedroom.png b/core/frontend/public/exact/assets/mock/scene-bedroom.png
new file mode 100644
index 0000000..cd4c4df
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-bedroom.png differ
diff --git a/core/frontend/public/exact/assets/mock/scene-cafe.png b/core/frontend/public/exact/assets/mock/scene-cafe.png
new file mode 100644
index 0000000..ab8e5dc
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-cafe.png differ
diff --git a/core/frontend/public/exact/assets/mock/scene-kitchen.png b/core/frontend/public/exact/assets/mock/scene-kitchen.png
new file mode 100644
index 0000000..755d007
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-kitchen.png differ
diff --git a/core/frontend/public/exact/assets/mock/scene-living.png b/core/frontend/public/exact/assets/mock/scene-living.png
new file mode 100644
index 0000000..8c32db2
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-living.png differ
diff --git a/core/frontend/public/exact/assets/mock/scene-night-street.png b/core/frontend/public/exact/assets/mock/scene-night-street.png
new file mode 100644
index 0000000..fe96358
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-night-street.png differ
diff --git a/core/frontend/public/exact/assets/mock/scene-office.png b/core/frontend/public/exact/assets/mock/scene-office.png
new file mode 100644
index 0000000..1857731
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-office.png differ
diff --git a/core/frontend/public/exact/assets/mock/scene-tabletop.png b/core/frontend/public/exact/assets/mock/scene-tabletop.png
new file mode 100644
index 0000000..790a739
Binary files /dev/null and b/core/frontend/public/exact/assets/mock/scene-tabletop.png differ
diff --git a/core/frontend/public/exact/assets/new-product-drawer.js b/core/frontend/public/exact/assets/new-product-drawer.js
new file mode 100644
index 0000000..115f976
--- /dev/null
+++ b/core/frontend/public/exact/assets/new-product-drawer.js
@@ -0,0 +1,784 @@
+/* ============================================================
+ 新建商品 · 共享 Drawer 模块
+ ----------------------------------------------------------
+ 在任意页面只需
+