feat: add AirShelf core implementation
@@ -0,0 +1,42 @@
|
||||
# Exact Design To React Conversion
|
||||
|
||||
> 日期:2026-05-29
|
||||
> 目标:先把 `电商AI平台` / `public/exact` 设计稿完整纳入正式 React 路由,确保视觉还原优先于业务接口继续扩展。
|
||||
|
||||
## 当前实现
|
||||
|
||||
- `public/exact/*.html`:保留为设计对照基线和截图 diff 来源。
|
||||
- `src/routes/exact-html.ts`:由脚本生成,内嵌 21 个 exact 页面文档。
|
||||
- `src/routes/exact-document.tsx`:React 承载层,负责加载 exact 文档、隔离页面样式、接管页面间导航和登录注册提交。
|
||||
- `src/routes/exact-pages/*.tsx`:每个页面对应一个正式 TSX 包装文件。
|
||||
- `scripts/generate-exact-html.mjs`:从 `public/exact` 重新生成 React exact 文档与页面包装。
|
||||
|
||||
## 已覆盖页面
|
||||
|
||||
登录、注册、工作台、商品库、商品详情、商品创建入口、项目列表、新建项目、Pipeline、资产库、账户、团队、消息、图片工具、图片优化、模特图、模特图方案 A/B、平台套图、设置。
|
||||
|
||||
## 验收结果
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Playwright 真实浏览器截图对照输出:
|
||||
|
||||
```text
|
||||
output/playwright/exact-react-final-20260529103829/
|
||||
```
|
||||
|
||||
关键结果:
|
||||
|
||||
- 登录 / 注册:0 像素差。
|
||||
- Dashboard / 商品库 / 项目列表 / Pipeline / 资产库 / 账户 / 团队 / 消息 / AI 工具页:与 exact 基线基本一致,差异为 0 到极低比例。
|
||||
- `product-create-upload.html` 是原型里的跳转 stub,实际落到商品库 drawer,因此差异高于其他页面,但属于原型跳转页本身的动态行为差异。
|
||||
|
||||
## 后续规则
|
||||
|
||||
1. 修改页面前先改 `public/exact` 或确认设计基线,再运行生成脚本。
|
||||
2. 业务 API 接入时,不直接破坏 exact 视觉层;先在承载层或组件层做数据替换。
|
||||
3. 生视频真实测试仍放最后,不在页面还原阶段触发。
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="icon" href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="%23fa5d19"/><text x="16" y="21" text-anchor="middle" font-family="Arial" font-size="12" fill="white">AS</text></svg>' />
|
||||
<title>AirShelf</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "airshelf-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"vite": "^7.2.7",
|
||||
"typescript": "^5.9.3",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"lucide-react": "^0.561.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 201 KiB |
@@ -0,0 +1,7 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
*.md
|
||||
node_modules
|
||||
@@ -0,0 +1,10 @@
|
||||
# ---- Runtime Stage (no build — pure static HTML/CSS/JS) ----
|
||||
FROM docker.m.daocloud.io/nginx:alpine
|
||||
|
||||
RUN sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY . /usr/share/nginx/html/
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,53 @@
|
||||
# 电商AI平台 · Airshelf
|
||||
|
||||
AI 短视频带货生成平台 · 静态设计稿(V2.1 Restraint)。
|
||||
|
||||
## 快速浏览
|
||||
|
||||
直接用浏览器打开任意 HTML,或本地起服务:
|
||||
|
||||
```bash
|
||||
npx http-server . -p 8080
|
||||
# 然后访问 http://localhost:8080/
|
||||
```
|
||||
|
||||
入口页:[index.html](index.html)(工作台)
|
||||
|
||||
## 页面索引
|
||||
|
||||
| 页面 | 文件 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 工作台 | [index.html](index.html) | 进入后首屏 · 4 块 KPI + 最近项目 + 快捷入口 |
|
||||
| 视频项目 | [projects.html](projects.html) | 项目列表 · 表格 + 状态 + 进度 |
|
||||
| 新建项目 | [projects-new.html](projects-new.html) | 4 步向导 · 含实时预估面板 + 推荐气泡 |
|
||||
| 商品库 | [products.html](products.html) | SKU 列表 |
|
||||
| 创建商品 | [product-create.html](product-create.html) | 4 步向导 + 子流程(挑模特 → 生上身) |
|
||||
| 流水线 | [pipeline.html](pipeline.html) | 5 阶段:脚本 → 资产 → 故事板 → 片段 → 拼接 |
|
||||
| 资产库 | [library.html](library.html) | 人物 / 场景 / 视频片段管理 |
|
||||
| 账户 | [account.html](account.html) | 余额 / 充值 / 消费明细 |
|
||||
| 设计系统 | [design-system.html](design-system.html) | 完整 V2.1 token + 组件 + 状态 + Modal/Toast |
|
||||
|
||||
## 设计规范
|
||||
|
||||
详见 [DESIGN_SPEC_V2.md](DESIGN_SPEC_V2.md) · 当前 V2.1。
|
||||
|
||||
**核心视觉特征:**
|
||||
- 配色:冷灰底 `#f9f9f9` + 单橙 accent `#FA5D19`(Firecrawl-aligned)
|
||||
- 圆角:统一 8 px / pill 999 px / 微元素 4 px
|
||||
- Icon:SVG line · stroke 1.5 · linecap round · `currentColor` 继承
|
||||
- 签名元素:容器装订线(左右 1 px 边)+ 四角 22×21 px SVG 准星(圆弧内凹)
|
||||
- 字体:Inter Tight + Inter + Alibaba PuHuiTi
|
||||
- 主 CTA:橙底 + 4 层橙阴影(全场唯一允许阴影的组件)
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
电商AI平台/
|
||||
├── README.md ← 本文档
|
||||
├── DESIGN_SPEC_V2.md ← 设计规范 source of truth
|
||||
├── design-system.html ← 可交互组件参考(token + 状态 demo)
|
||||
├── assets/
|
||||
│ ├── restraint.css ← V2.1 共享样式 · 无 V1 legacy alias
|
||||
│ └── shell.js ← sidebar/topbar/Toast/Modal 渲染器
|
||||
└── *.html ← 8 个页面
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
# 已归档/保留的 HTML 文件说明
|
||||
|
||||
> 2026-05-28 · 主流程收口记录
|
||||
|
||||
下列早期版本或独立实验页**不在主流程上**,已归档到
|
||||
[`_archive/deprecated-pages-20260528/`](_archive/deprecated-pages-20260528/)。
|
||||
|
||||
根目录半成品 Next.js scaffold 已归档到
|
||||
[`../_archive/root-next-20260528/`](../_archive/root-next-20260528/)。
|
||||
|
||||
保留本文件用于说明历史路径,避免后续误改旧页面。
|
||||
|
||||
## ✅ 已归档(无主流程入口)
|
||||
|
||||
| 文件 | 原用途 | 现状 |
|
||||
|------|--------|------|
|
||||
| `_archive/deprecated-pages-20260528/product-create.legacy.html` | 旧版「新建商品」全屏页(3883 行 drawer) | 已被 `product-create.html` stub + drawer 模式替代 |
|
||||
| `_archive/deprecated-pages-20260528/product-create-v2.html` | 备选「新建商品」实验版 | 仅供参考 |
|
||||
| `_archive/deprecated-pages-20260528/studio.html` | 早期工作室页占位 | 已被 `pipeline.html` 替代 |
|
||||
| `_archive/deprecated-pages-20260528/studio-v2.html` | 早期工作室 V2 实验 | 已被 `pipeline.html` 替代 |
|
||||
| `_archive/deprecated-pages-20260528/product-studio.html` | 商品 + 工作室合并实验 | 主流程已拆为 `product-detail.html` + `pipeline.html` |
|
||||
| `../_archive/root-next-20260528/` | 根目录 Next.js 半成品 | 静态 HTML 才是当前 source of truth |
|
||||
|
||||
## ⚠️ 仍保留在主流程/辅助流程
|
||||
|
||||
| 文件 | 决定 |
|
||||
|------|------|
|
||||
| `product-create.html` | **保留** · stub 重定向,处理外部书签/直接访问 → 弹 drawer |
|
||||
| `product-create-upload.html` | **保留** · 商品图上传专用辅助页,通过 drawer 内部调用 |
|
||||
| `model-photo.html` / `platform-cover.html` | **保留** · 「AI 生成素材」二级页(从商品库 gen-choice 跳入) |
|
||||
| `asset-factory.html` | **保留** · sidebar 入口「图片生成」 |
|
||||
| `design-system.html` | **保留** · 设计系统参考,非用户路径 |
|
||||
|
||||
## 📌 sidebar NAV 当前入口对照(assets/shell.js:10-43)
|
||||
|
||||
```
|
||||
工作台 → index.html ✓
|
||||
商品库 → products.html ✓
|
||||
视频项目 → projects.html ✓
|
||||
图片生成 → asset-factory.html ✓
|
||||
资产库 → library.html ✓
|
||||
团队 → team.html ✓
|
||||
消费 → account.html ✓
|
||||
设置 → settings.html ✓
|
||||
```
|
||||
|
||||
未在 sidebar 的"白名单"页面(从其他页面 link 进入):
|
||||
- login.html / register.html(auth flow · 顶级入口)
|
||||
- projects-new.html(项目向导 · 工作台「+新建项目」入口)
|
||||
- pipeline.html(流水线 · 项目列表「打开」入口)
|
||||
- product-detail.html(商品详情 · 商品库卡片入口)
|
||||
- product-create.html stub / product-create-upload.html(drawer 流程辅助)
|
||||
- model-photo.html / platform-cover.html(AI 素材生成入口)
|
||||
- design-system.html(设计参考 · 非用户路径)
|
||||
@@ -0,0 +1,952 @@
|
||||
# 流·Studio 设计规范 · V2.1
|
||||
|
||||
> **版本:** V2.1(2026-05-15)
|
||||
> **风格代号:** Restraint(克制)· Firecrawl-aligned
|
||||
> **适用范围:** 流·Studio 全产品(工作台 / 项目 / 商品库 / 流水线 / 资产库 / 编辑器 / 账户)
|
||||
> **V2.1 更新:** 色彩系统全面对齐 Firecrawl 实测——**冷灰底色**(放弃米白)· **主橙 #FA5D19**(从 #E55B26 调亮)· **20 档 black-alpha**(从 11 档扩展)· **5 色 accent 多彩点**(新增)· accent-black 替代 ink。保留 V2 所有结构性决策(8 px 圆角、inside-border、装订线、mono 装饰)。
|
||||
> **与 V2 关系:** V2.1 兼容 V2 的所有 token 命名(`--ink` `--bg` `--card` `--green` `--red` 等保留为 legacy 别名),代码层无需重写。
|
||||
|
||||
---
|
||||
|
||||
## 0. 变更速查
|
||||
|
||||
### V2 → V2.1(本次 · 色彩系统对齐 Firecrawl)
|
||||
|
||||
| # | 维度 | V2 | V2.1 |
|
||||
| - | ---- | -- | ---- |
|
||||
| C1 | 底色 | `#FAF9F5` 暖米白 | **`#f9f9f9` 冷灰**(`--background-base`) |
|
||||
| C2 | 主橙 | `#E55B26` 砖红 | **`#FA5D19`** 更红更饱和 |
|
||||
| C3 | 文字基色 | `--ink: #15140F` | **`--accent-black: #262626`**(更柔和)· `--ink` 作 legacy 别名 |
|
||||
| C4 | Alpha 阶梯 | 11 档 `--ink-alpha-*` | **20 档 `--black-alpha-*`**(1/2/3/4/5/6/7/8/10/12/16/20/24/32/40/48/56/64/72/88) |
|
||||
| C5 | Alpha base | 全部用 `rgba(0,0,0,...)` | **32% 起换 `rgba(38,38,38,...)`**(避免叠出"灰中带蓝") |
|
||||
| C6 | 状态色 | `--green #3F6B3F` 深森林绿 / `--red #B33A2A` 暗砖红 | **`--accent-forest #42c366`** / **`--accent-crimson #eb3424`**(信号灯感) |
|
||||
| C7 | Accent 多彩 | 仅橙+绿+红 | **新增 5 色** amethyst/bluetron/crimson/forest/honey(仅用于语义信号) |
|
||||
| C8 | 边框 | `#EFEBE0` 暖色 | **`#ededed`** 冷灰(无色相) |
|
||||
| C9 | Selection | 未定义 | **`background: var(--heat-20)` + `color: var(--heat)`**(Firecrawl 签名) |
|
||||
|
||||
### V1 → V2(上次 · 结构性变更,保留)
|
||||
|
||||
| # | 维度 | V1 | V2 | 决策类型 |
|
||||
| - | ---- | -- | -- | -------- |
|
||||
| Δ1 | 圆角 | 大容器 0 / 按钮 9 / pill 999 | **大容器 8 / 按钮 8 / pill 999 / dot 999** | **变更** |
|
||||
| Δ2 | mono `[ STATUS ]` | 大量使用 | **保留**(品牌签名) | 保留 |
|
||||
| Δ3 | 字体 | Inter | **Inter Tight + 字重 500 替代 450**(免费) | 变更(轻) |
|
||||
| Δ4 | 边框 | 真 `border:1px` | **`::before` inside-border**(hover 不抖动) | 变更 |
|
||||
| Δ5 | 主容器左右垂直边 | 无 | **加 `border-x` + 四角准星**(图纸装订线) | 新增 |
|
||||
| Δ6 | 主 CTA 阴影 | 全场无 | **4 层橙色发光** | 变更(小幅) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计哲学
|
||||
|
||||
**一句话:** 一台精密设备的工作面板。
|
||||
|
||||
**三条铁律:**
|
||||
1. **克制大于装饰** — 留白 > 容器 > 内容,大量空气感。
|
||||
2. **单色锚点** — 全场只有一种 accent(橙色),且只用于 CTA / 关键状态 / 强调单词。
|
||||
3. **结构清晰可见** — 用 **1 px 边框 + 8 px 圆角 + 四角准星 + 容器纵向装订线 + mono `[ ]` 标签**暴露"图纸感",而非阴影/渐变隐藏结构。
|
||||
|
||||
**避免的"AI 味":**
|
||||
- 渐变铺面 / 玻璃拟态 / 彩色阴影
|
||||
- 多色 emoji 图标
|
||||
- 圆角无差别(全部 8px / 16px 的 SaaS 模板感)
|
||||
- 卡片浮在背景上的"贴纸感"
|
||||
- 装饰盖过内容(场记板 / 霓虹 / 丝绒幕布)
|
||||
|
||||
**新增禁令:**
|
||||
- ❌ **0 px 硬切角的卡片** —— V2 起所有结构性容器都用 8 px 圆角
|
||||
- ❌ **变深 hue 的 hover 色** —— 橙色 hover 用 alpha 阶梯,不用更深的橙
|
||||
|
||||
---
|
||||
|
||||
## 2. 色彩系统(V2.1 · 对齐 Firecrawl)
|
||||
|
||||
### 2.1 表面 / 背景(冷灰 · 无色相)
|
||||
|
||||
| Token | Hex | 用途 |
|
||||
| ---------------------- | --------- | --------------------------- |
|
||||
| `--background-base` | `#f9f9f9` | 页面底色 · 冷灰 |
|
||||
| `--background-lighter` | `#fbfbfb` | 容器底色 / hover 浅底 |
|
||||
| `--surface` | `#ffffff` | 卡片 / 容器表面 |
|
||||
| `--surface-raised` | `#ffffff` | 浮层 / Modal 表面 |
|
||||
|
||||
> **Legacy 别名(V2 → V2.1):** `--bg = var(--background-base)` / `--bg-soft = var(--background-lighter)` / `--card = var(--surface)`。组件 CSS 不用改。
|
||||
|
||||
### 2.2 边框(冷灰 · 3 档差距极小)
|
||||
|
||||
| Token | Hex | 用途 |
|
||||
| ---------------- | --------- | --------------------- |
|
||||
| `--border-faint` | `#ededed` | **默认 1 px 边框 ★ 80% 场景** |
|
||||
| `--border-muted` | `#e8e8e8` | 略深 |
|
||||
| `--border-loud` | `#e6e6e6` | 最深(强分隔) |
|
||||
|
||||
> **设计意图:** 3 档之间只差 1–2 个色阶,**肉眼几乎看不出**。**用语义(faint/muted/loud)选择,不用视觉对比选择。** 这是 Firecrawl 的细节哲学。
|
||||
|
||||
### 2.3 Heat · 主橙(单 hue + 8 档 alpha)
|
||||
|
||||
| Token | Value | 用途 |
|
||||
| ----------- | ----------------------- | ----------------------------- |
|
||||
| `--heat` | `#fa5d19` | **主橙 100% · CTA / 链接 ★** |
|
||||
| `--heat-90` | `rgba(250,93,25,.90)` | hover(替代 V1 `#D04E1F`) |
|
||||
| `--heat-40` | `rgba(250,93,25,.40)` | focus ring / 边框次级 |
|
||||
| `--heat-20` | `rgba(250,93,25,.20)` | pill 边框 / **selection 底色 ★** |
|
||||
| `--heat-16` | `rgba(250,93,25,.16)` | hover 软底 |
|
||||
| `--heat-12` | `rgba(250,93,25,.12)` | tint 底(active nav / icon-box) |
|
||||
| `--heat-8` | `rgba(250,93,25,.08)` | |
|
||||
| `--heat-4` | `rgba(250,93,25,.04)` | 极弱底 |
|
||||
|
||||
> **从 V2 `#E55B26` 调亮到 V2.1 `#FA5D19`** —— 更红更饱和,与 Firecrawl 100% 实测一致。**hover 永远不换 hue,只换 alpha。**
|
||||
|
||||
### 2.4 Accent · 5 色信号(新增)
|
||||
|
||||
> **作用:** 仅用于**语义信号**——代码高亮、图标色、状态色源。**禁止做大面积背景或装饰**。全场仍只有 1 个 accent(橙)。
|
||||
|
||||
| Token | Hex | 用途 |
|
||||
| ------------------ | --------- | ----------------------------- |
|
||||
| `--accent-black` | `#262626` | **主前景**(替代 V2 `--ink #15140F`,更柔和) |
|
||||
| `--accent-white` | `#ffffff` | 反色文字(在橙底 / 黑底上) |
|
||||
| `--accent-amethyst`| `#9061ff` | 紫 · 代码 property |
|
||||
| `--accent-bluetron`| `#2a6dfb` | 蓝 · info |
|
||||
| `--accent-crimson` | `#eb3424` | 红 · **error / 失败 ★** |
|
||||
| `--accent-forest` | `#42c366` | 绿 · **success / 成功 ★** |
|
||||
| `--accent-honey` | `#ecb730` | 黄 · warning |
|
||||
|
||||
> **Legacy 别名:** `--ink = var(--accent-black)` / `--green = var(--accent-forest)` / `--red = var(--accent-crimson)`。
|
||||
|
||||
### 2.5 Black-Alpha 阶梯(20 档 · 核心工具尺)
|
||||
|
||||
> **替代 V2 的 11 档 ink-alpha**。这是日常用得最多的 token。0–24% 用 `rgba(0,0,0,...)`;**32% 起换 `rgba(38,38,38,...)`**(=`--accent-black` 作底),避免叠出"灰中带蓝"——Firecrawl 实测细节。
|
||||
|
||||
| Token | Light 值 | Dark 值 | 典型用途 |
|
||||
| ------------------ | ---------------------- | ---------------------- | ----------------------- |
|
||||
| `--black-alpha-1` | `rgba(0,0,0,.01)` | `rgba(255,255,255,.01)`| 极弱底 |
|
||||
| `--black-alpha-2` | `rgba(0,0,0,.02)` | `rgba(255,255,255,.02)`| |
|
||||
| `--black-alpha-3` | `rgba(0,0,0,.03)` | `rgba(255,255,255,.03)`| |
|
||||
| `--black-alpha-4` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.04)`| **hover bg ★** |
|
||||
| `--black-alpha-5` | `rgba(0,0,0,.05)` | `rgba(255,255,255,.05)`| tab 间分隔条 |
|
||||
| `--black-alpha-6` | `rgba(0,0,0,.06)` | `rgba(255,255,255,.06)`| |
|
||||
| `--black-alpha-7` | `rgba(0,0,0,.07)` | `rgba(255,255,255,.07)`| **active bg ★** |
|
||||
| `--black-alpha-8` | `rgba(0,0,0,.08)` | `rgba(255,255,255,.08)`| |
|
||||
| `--black-alpha-10` | `rgba(0,0,0,.10)` | `rgba(255,255,255,.10)`| |
|
||||
| `--black-alpha-12` | `rgba(0,0,0,.12)` | `rgba(255,255,255,.12)`| **inside-border ★** |
|
||||
| `--black-alpha-16` | `rgba(0,0,0,.16)` | `rgba(255,255,255,.16)`| |
|
||||
| `--black-alpha-20` | `rgba(0,0,0,.20)` | `rgba(255,255,255,.20)`| |
|
||||
| `--black-alpha-24` | `rgba(0,0,0,.24)` | `rgba(255,255,255,.24)`| input hover 边框 |
|
||||
| `--black-alpha-32` | `rgba(38,38,38,.32)` | `rgba(255,255,255,.32)`| ← base 切换 → ↓ |
|
||||
| `--black-alpha-40` | `rgba(38,38,38,.40)` | `rgba(255,255,255,.40)`| |
|
||||
| `--black-alpha-48` | `rgba(38,38,38,.48)` | `rgba(255,255,255,.48)`| **占位字色 ★** |
|
||||
| `--black-alpha-56` | `rgba(38,38,38,.56)` | `rgba(255,255,255,.56)`| **次级文字 / 未选中 Tab ★** |
|
||||
| `--black-alpha-64` | `rgba(38,38,38,.64)` | `rgba(255,255,255,.64)`| 描述文字 |
|
||||
| `--black-alpha-72` | `rgba(38,38,38,.72)` | `rgba(255,255,255,.72)`| 强次级 |
|
||||
| `--black-alpha-88` | `rgba(38,38,38,.88)` | `rgba(255,255,255,.88)`| 近主前景 |
|
||||
|
||||
> **V2 → V2.1 兼容映射(legacy 别名,组件 CSS 不必改):**
|
||||
> - `--ink-alpha-4` → `--black-alpha-4`
|
||||
> - `--ink-alpha-7` → `--black-alpha-7`
|
||||
> - `--ink-alpha-12` → `--black-alpha-12`
|
||||
> - `--ink-alpha-24` → `--black-alpha-24`
|
||||
> - `--ink-alpha-32` → `--black-alpha-32`
|
||||
> - `--ink-alpha-48` → `--black-alpha-48`
|
||||
> - `--ink-alpha-56` → `--black-alpha-56`
|
||||
> - `--ink-alpha-64` → `--black-alpha-64`
|
||||
> - `--ink-alpha-72` → `--black-alpha-72`
|
||||
> - `--ink-alpha-88` → `--black-alpha-88`
|
||||
|
||||
### 2.6 状态色配套底/边
|
||||
|
||||
| 含义 | 主色 | 配套底色(8% alpha) | 配套边框(20% alpha) |
|
||||
| ---- | -------------------------- | --------------------------- | --------------------------- |
|
||||
| 成功 | `--green` (`#42c366`) | `rgba(66,195,102,.08)` | `rgba(66,195,102,.20)` |
|
||||
| 失败 | `--red` (`#eb3424`) | `rgba(235,52,36,.08)` | `rgba(235,52,36,.20)` |
|
||||
| 信息 | `--heat` (`#fa5d19`) | `--heat-12` | `--heat-20` |
|
||||
| 警告 | `--accent-honey`(`#ecb730`)| `rgba(236,183,48,.08)` | `rgba(236,183,48,.20)` |
|
||||
|
||||
### 2.7 Selection · 文字选中色(Firecrawl 签名)
|
||||
|
||||
```css
|
||||
::selection {
|
||||
background: var(--heat-20);
|
||||
color: var(--heat);
|
||||
}
|
||||
```
|
||||
|
||||
选中任何文字时,底色 20% 橙、文字 100% 橙。这是 Firecrawl 易被忽略但**整站感知到位**的细节。
|
||||
|
||||
---
|
||||
|
||||
## 3. 字体系统(V2.1 · 中英混排策略)
|
||||
|
||||
### 3.1 字体族 · Inter + 阿里巴巴普惠体
|
||||
|
||||
| 用途 | 字体声明 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| **正文 / UI**| `'Inter', 'Alibaba PuHuiTi', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif` |
|
||||
| **纯英文场景** | `'Inter', system-ui, sans-serif`(`--font-inter`)· 用于"Ctrl K"等强制纯英文徽标 |
|
||||
| **Mono 装饰** | `'JetBrains Mono', 'Geist Mono', ui-monospace, monospace` |
|
||||
|
||||
**核心策略 · 浏览器字符级 fallthrough:**
|
||||
|
||||
```
|
||||
Inter ────────→ 英文 / 数字 / 符号(命中)
|
||||
│
|
||||
↓ (CJK 不命中,继续找)
|
||||
Alibaba PuHuiTi ──→ 中文(命中)
|
||||
│
|
||||
↓ (字体未加载到)
|
||||
PingFang SC / Microsoft YaHei ──→ 系统中文兜底
|
||||
```
|
||||
|
||||
浏览器对每个字符**逐个查找** font-family 链,Inter 不含 CJK 字形 → 中文字符自动跳到下一个候选。这是中英混排的标准做法,**不需要 JS、不会字重错位**。
|
||||
|
||||
**V2 → V2.1 → V2.1 变更轨迹:**
|
||||
- V2: `Inter Tight` → 中文走系统 fallback → 字重错位
|
||||
- V2.1 (前一版): 单一 `Alibaba PuHuiTi` → 英文用普惠体英文字形 → 英文略圆,缺乏 Inter 的"科技感"
|
||||
- **V2.1 (本版)** : **Inter(英)+ Alibaba PuHuiTi(中)双字体协作** → 各自处理最擅长的语种
|
||||
|
||||
**为什么这个组合最优:**
|
||||
- Inter:Vercel / Linear / Stripe 御用,**工程产品默认审美**。专门给屏幕 UI 优化,数字字形漂亮(同宽)。中文不擅长。
|
||||
- Alibaba PuHuiTi:阿里出品,免费商用,**为中英混排专门设计的笔画粗细配比**(45/55/65/85 多档),中文笔画与 Inter 视觉重量贴近。专门给中文优化。
|
||||
- 两者结合:**英文有 Inter 的锐利,中文有普惠体的清晰**,字重之间衔接自然。
|
||||
|
||||
**Inter 载入:**
|
||||
|
||||
```html
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
```
|
||||
|
||||
**Alibaba PuHuiTi 载入:**
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: 'Alibaba PuHuiTi';
|
||||
font-weight: 400; /* 55 Regular */
|
||||
src: local('Alibaba PuHuiTi 3.0'),
|
||||
local('AlibabaPuHuiTi-3-55-Regular'),
|
||||
url('https://chinese-fonts-cdn.deno.dev/packages/alibaba_puhuiti/dist/AlibabaPuHuiTi-3-55-Regular/AlibabaPuHuiTi-3-55-Regular.woff2') format('woff2');
|
||||
}
|
||||
/* 同写法处理 500 Medium / 600 SemiBold / 700 Bold */
|
||||
```
|
||||
|
||||
> **优先级:** 本地安装(设计师机器一般装了)→ CDN 加载 → 系统中文兜底。**永远不会出现假字或方框。**
|
||||
|
||||
**特殊场景 · 强制纯英文用 `--font-inter`:**
|
||||
|
||||
某些场景必须确保用 Inter(比如 "Ctrl K" 这种快捷键徽标想要 Inter Bold 的紧凑感),直接用专属变量:
|
||||
|
||||
```css
|
||||
.search-wrap .k {
|
||||
font-family: var(--font-inter); /* 跳过 fallback 链,锁定 Inter */
|
||||
font-weight: 700;
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
```
|
||||
|
||||
Mono 字体保留 `JetBrains Mono`——用作装饰元素 `[ 200 OK ]` `// 05.14` `/v2`,**不参与中文**。
|
||||
|
||||
### 3.2 字号 / 字重 / 行高(V2.1 整体放大半档,行高提升)
|
||||
|
||||
| 角色 | 字号 | 字重 | 字距 | 行高 | 用途 |
|
||||
| ---------------- | ------- | ---- | ---------- | ---- | ---------------- |
|
||||
| H1 / Hero | 36 px | 500 | -0.024em | 1.2 | 页面主标题(从 V2 的 32 上调) |
|
||||
| 区块 H2 | 28 px | 500 | -0.02em | 1.25 | section-head 标题 |
|
||||
| KPI 数值 | 32 px | 500 | -0.02em | 1.1 | 统计大数字 |
|
||||
| 子区 H3 | 16 px | 500 | -0.01em | 1.4 | subsection 标题(从 15 上调) |
|
||||
| 卡片标题 | 14 px | 500 | normal | 1.4 | 项目名 / 商品名(从 13.5 上调) |
|
||||
| 正文 body | 14 px | 400 | normal | **1.65** | 默认正文(行高从 1.5 上调) |
|
||||
| 区块描述 | 14 px | 400 | normal | 1.75 | section-head 描述 |
|
||||
| 子区 lead | 13 px | 400 | normal | 1.8 | 子区下方说明 |
|
||||
| Label(按钮/Tab)| 13 px | 500 | normal | 1.4 | 按钮文字 / Tab |
|
||||
| Pill 文字 | 11.5 px | 500 | normal | 1.3 | 状态徽标 |
|
||||
| Mono 标签 | 11–11.5 px | 400/500 | 0.04em | 1.5 | `[ STATUS ]` |
|
||||
| Mono 散点 | 8.5 px | 400 | 0.04em | 1 | 背景 ASCII 装饰 |
|
||||
|
||||
**字重档位仍仅 3 档:400 / 500 / 600**。普惠体 500(Medium 65)的中文笔画比 Inter 500 重一些,**整体不显单薄**,因此 600 几乎不需要。
|
||||
|
||||
**关键属性:**
|
||||
- 数值类必须加 `font-variant-numeric: tabular-nums`
|
||||
- 标题用 negative letter-spacing(-0.01 ~ -0.024em)
|
||||
- 正文行高 **1.65–1.8**(V2 是 1.5–1.7),中文字间留呼吸
|
||||
|
||||
---
|
||||
|
||||
## 4. 圆角规则 · V2 统一 8 px
|
||||
|
||||
> **核心原则(V2 改写):统一 8 px / 状态徽标完全圆 / 极少数微元素降到 4–6 px**
|
||||
|
||||
| 元素类型 | 圆角值 | 例子 |
|
||||
| ------------------------------ | ---------- | ------------------------------- |
|
||||
| 所有结构性容器(大卡片 / 区块) | **8 px** | `.stats` `.list-card` `.shortcut` `.tip` `.modal` |
|
||||
| 所有按钮 / 输入框 | **8 px** | `.btn` `.pill-btn` `.icon-btn` `.search` |
|
||||
| nav 项 | **8 px** | `nav a`(V1 是 7,V2 统一) |
|
||||
| 缩略图 / 画面占位 | **8 px** | `.thumb` `.ic` |
|
||||
| 头像 / 小色块 | **6 px** | `.av` `.team .p`(可选 8) |
|
||||
| Mono 标签 / badge / kbd | **4 px** | `.kbd` `.badge` |
|
||||
| 进度条段位 | **2 px** | `.prog span` |
|
||||
| Pill 状态徽标 / dot | **999 px** | `.pill` `.dot`(完全圆) |
|
||||
|
||||
**为什么改:** Firecrawl 实测全站统一 8 px,工程感来自"准星 + 装订线 + mono 装饰",**不是硬切角**。0 圆角容器在小尺寸下会显得"卡顿、廉价",8 px 是同时兼顾"图纸感"和"成品感"的最佳值。
|
||||
|
||||
---
|
||||
|
||||
## 5. 边框 / 阴影 / 描边
|
||||
|
||||
### 5.1 边框策略 · V2 改为 inside-border
|
||||
|
||||
> **V2 改进:** 默认边框用 `::before` 伪元素绘制,而非真 `border`。原因:hover 时让 `::before` 透明度 → 0 不会触发布局抖动。
|
||||
|
||||
**通用工具类:**
|
||||
|
||||
```css
|
||||
.inside-border {
|
||||
position: relative;
|
||||
}
|
||||
.inside-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
transition: opacity .2s ease, border-color .2s ease;
|
||||
}
|
||||
.inside-border:hover::before { opacity: 0; }
|
||||
```
|
||||
|
||||
**层级:**
|
||||
- 默认:`var(--border-faint)`
|
||||
- 略深:`var(--border-muted)`(主分隔线)
|
||||
- 最深:`var(--border-loud)`(强分隔,少用)
|
||||
- **禁止 2px / 3px 实线**
|
||||
- **虚线**仅用于 `.tip` 提示框:`1px dashed var(--border-faint)`
|
||||
|
||||
### 5.2 阴影 · V2 引入主 CTA 专属橙色发光
|
||||
|
||||
**默认无阴影**(V1 规则保留)。
|
||||
|
||||
**新增例外 · 主 CTA 4 层橙色阴影**(替代 V1 的"全场无"):
|
||||
|
||||
```css
|
||||
.btn-primary {
|
||||
box-shadow:
|
||||
inset 0 -4px 8px rgba(250, 93, 25, 0.20), /* 内阴影:底部暗一点 = 立体 */
|
||||
0 1px 1px rgba(250, 93, 25, 0.12),
|
||||
0 2px 4px rgba(250, 93, 25, 0.10),
|
||||
0 0.5px 0.5px rgba(250, 93, 25, 0.16);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
box-shadow:
|
||||
inset 0 -4px 8px rgba(250, 93, 25, 0.20),
|
||||
0 1px 1px rgba(250, 93, 25, 0.16),
|
||||
0 4px 8px rgba(250, 93, 25, 0.20), /* 更高更亮 */
|
||||
0 0.5px 0.5px rgba(250, 93, 25, 0.16);
|
||||
}
|
||||
```
|
||||
|
||||
**Toast 阴影**(V1 保留):`0 4px 20px rgba(21,20,15,0.06)`,白色调,不属于橙色发光体系。
|
||||
|
||||
### 5.3 容器四角"+"准星(签名元素 · V2 升级为 SVG)
|
||||
|
||||
V1 用字符 `+`(font-family JetBrains Mono);V2 升级为 SVG 路径,带圆弧内凹,质感更工程化:
|
||||
|
||||
```html
|
||||
<svg width="22" height="21" viewBox="0 0 22 21" fill="none" class="corner">
|
||||
<path d="M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z" fill="var(--border-muted)"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
**位置:** 容器四角,中心精确落在边框交点上(用 `-translate-x-1/2 -translate-y-1/2`)。
|
||||
|
||||
**字符版本兼容:** 简单卡片(如 modal 内嵌)仍可用 `content: '+'` 字符版,但全屏主容器必须用 SVG。
|
||||
|
||||
---
|
||||
|
||||
## 6. 主内容容器 · 新增装订线规则(V2 新增章节)
|
||||
|
||||
> **核心签名:** 主工作区始终被两条 1 px 垂直边线包夹,配合四角准星,形成图纸装订线效果。
|
||||
|
||||
```html
|
||||
<div class="workbench-container">
|
||||
<svg class="corner top-left">...</svg>
|
||||
<svg class="corner top-right">...</svg>
|
||||
<svg class="corner bottom-left">...</svg>
|
||||
<svg class="corner bottom-right">...</svg>
|
||||
<!-- content -->
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.workbench-container {
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
border-left: 1px solid var(--border-faint);
|
||||
border-right: 1px solid var(--border-faint);
|
||||
position: relative;
|
||||
/* 上下边线不要,只左右 */
|
||||
}
|
||||
.workbench-container .corner {
|
||||
position: absolute;
|
||||
width: 22px; height: 21px;
|
||||
}
|
||||
.workbench-container .corner.top-left { top: -10.5px; left: -11px; }
|
||||
.workbench-container .corner.top-right { top: -10.5px; right: -11px; }
|
||||
.workbench-container .corner.bottom-left { bottom: -10.5px; left: -11px; }
|
||||
.workbench-container .corner.bottom-right { bottom: -10.5px; right: -11px; }
|
||||
```
|
||||
|
||||
适用范围:工作台 / 项目列表页 / 商品库 / 流水线主区。**编辑器全屏画布、Modal 内不必加。**
|
||||
|
||||
---
|
||||
|
||||
## 7. 间距 / 栅格(V2.1 全局放大,Firecrawl-level 呼吸)
|
||||
|
||||
**基础栅格:** 4 px
|
||||
|
||||
**常用间距阶梯:** 4 / 8 / 12 / 16 / 20 / 24 / 28 / 32 / 40 / 48 / 64 / 80 / 104
|
||||
|
||||
> **V2 → V2.1 变更:** 全局垂直间距系统性放大 30%。原因:Alibaba PuHuiTi 中文笔画比 Inter 厚一档,**字体密度提升 → 必须用更大的留白补偿**,否则视觉拥挤。这就是为什么 Firecrawl 的间距看起来"奢侈"——它的字体也是出版级偏紧凑的 Suisse Intl,留白补偿到位。
|
||||
|
||||
**主区块布局:**
|
||||
- 侧边栏宽度:`248 px`(无变)
|
||||
- **主内容 padding:`72 px 80 px 120 px`**(从 V2 的 `48 56 56` 上调)
|
||||
- 内容最大宽度:`1280 px`(从 1480 收窄,留更多自然边距)
|
||||
- 区块间垂直:**`104 px`**(`section margin-bottom` · 从 80 上调)
|
||||
- 子区间垂直:**`64 px`**(`subsection margin-bottom` · 从 48 上调)
|
||||
- 子区标题底距:**`22 px`**(`h3 → 内容` · 从 16 上调)
|
||||
- 卡片网格间距:`24 px`(主区) / `14 px`(子区)
|
||||
- 卡片内 padding:
|
||||
- 大卡片 / stats: **`28 px 30 px`**(从 22 24 上调)
|
||||
- 列表行: **`20 px 24 px`**(从 14 18 上调)
|
||||
- 快捷入口: **`18 px 20 px`**(从 14 上调)
|
||||
- Modal 头/体: **`24 px 28 px`**(从 20 24 上调)
|
||||
- Hero / section-head: **`52 px 56 px`** / `padding-bottom: 28 px`
|
||||
|
||||
**最重要的一条:** 别再吝啬空气。**当不确定 padding 是 16 还是 24 时,选 24。** 当不确定 margin 是 48 还是 64 时,选 64。
|
||||
|
||||
**间距/字号配对原则:**
|
||||
- 标题旁边的描述/副标:`mb 10–14 px`(紧凑组合)
|
||||
- 标题下方的正文/列表:`mb 22–28 px`(分组组合)
|
||||
- section 顶部 mono-tag → h2:`mb 14 px`
|
||||
- h2 → section description:`mb 12 px`
|
||||
- section-head 整体下方边线:`pb 28 px / mb 44 px`
|
||||
|
||||
---
|
||||
|
||||
## 8. 背景:制图纸网格(V1 保留)
|
||||
|
||||
### 8.1 三层叠加
|
||||
|
||||
```
|
||||
图层 1(最上):主交叉点 "+" 准星 SVG — 240×240 重复
|
||||
图层 2(中间):子交叉点小圆点 — 60×60 重复
|
||||
图层 3(最下):虚线网格 — 240×240 重复(stroke-dasharray: 1.5 4)
|
||||
```
|
||||
|
||||
**配色:**
|
||||
- "+" 准星:`#B8B3A4`(stroke 1 px)
|
||||
- 小点:`#CFCABB`(r=0.9)
|
||||
- 虚线:`#E2DED2`(stroke 1 px)
|
||||
|
||||
### 8.2 视觉聚焦遮罩
|
||||
|
||||
```css
|
||||
mask-image: radial-gradient(ellipse 95% 80% at 50% 35%, #000 25%, transparent 95%);
|
||||
```
|
||||
|
||||
### 8.3 装饰散点(Mono ASCII)
|
||||
|
||||
主区域 4 个固定位置撒 ASCII 散点。字号 8.5 px / 颜色 `--ink-alpha-12` / 透明度 0.8 / `pointer-events: none`。
|
||||
|
||||
### 8.4 边角 Mono 标签(品牌签名 · 保留)
|
||||
|
||||
主区域 4 个角各放一个 Mono 标签:
|
||||
```
|
||||
左上 [ 200 OK ] 右上 [ /v2 ]
|
||||
左下 [ .MP4 · 9:16 ] 右下 [ STUDIO ]
|
||||
```
|
||||
|
||||
字号 10.5 px / 颜色 `--ink-alpha-48` / 字距 0.06em。
|
||||
|
||||
> **作用:** 让页面看起来像「在某个开发环境 / 调试视图里」,而不是普通官网。Firecrawl **没有**这个元素,这是流·Studio 的独特品牌资产。
|
||||
|
||||
---
|
||||
|
||||
## 9. Icon 系统(V2 强化章节,直接对应用户优化诉求 ①)
|
||||
|
||||
### 9.1 通用规则
|
||||
|
||||
- **格式:** 一律 SVG inline,**禁止** `<img>` 引图标
|
||||
- **库选择:** 推荐 Lucide(line icon,1.5–2 px stroke)或 Phosphor Regular
|
||||
- **stroke width:** 统一 **1.5 px**(替代 V1 提到的 1.8)
|
||||
- **stroke linecap / linejoin:** `round`
|
||||
- **填充:** **不填充**(纯 line icon)
|
||||
- **颜色:** 通过 `stroke="currentColor"`,继承父元素 `color`
|
||||
- **emoji 禁用:** 任何场景都不允许彩色 emoji
|
||||
|
||||
### 9.2 尺寸阶梯
|
||||
|
||||
| 场景 | 尺寸 | 用途 |
|
||||
| ------------------- | ------- | ----------------------------- |
|
||||
| Icon-S | 14 px | 内嵌 inline 文字旁 |
|
||||
| Icon-M(默认) | 16 px | 按钮内 / Tab / list 行 |
|
||||
| Icon-L | 20 px | 顶栏 / 快捷入口 / dropdown 触发器 |
|
||||
| Icon-XL | 24 px | Modal 头部 / Toast / 空状态 |
|
||||
| Icon-Hero | 36 px | 空状态插画 / 大占位 |
|
||||
|
||||
### 9.3 颜色规则
|
||||
|
||||
| 场景 | 颜色 |
|
||||
| ------------ | --------------------- |
|
||||
| 默认 | `--ink-alpha-56` |
|
||||
| Hover | `--ink` |
|
||||
| Active / 选中| `--heat` |
|
||||
| Disabled | `--ink-alpha-12` |
|
||||
| 在主 CTA 内 | `#FFFFFF` |
|
||||
|
||||
### 9.4 Icon-Box(快捷入口左侧的方块图标容器)
|
||||
|
||||
```css
|
||||
.icon-box {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px; /* V1 是 0,V2 改 8 */
|
||||
background: var(--heat-12);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.icon-box svg { width: 16px; stroke: var(--heat); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 组件规范 · 含完整状态(V2 大幅扩充,对应用户诉求 ②③④)
|
||||
|
||||
### 10.1 按钮(3 种类型 × 5 种状态 × 3 种尺寸)
|
||||
|
||||
**类型:**
|
||||
|
||||
| 类型 | 背景 | 文字 | 边框/描边 |
|
||||
| ----------------- | ---------------- | ------------------ | ---------------------------------- |
|
||||
| `.btn` 默认 | `--card` 白 | `--ink` | inside-border `--ink-alpha-12` |
|
||||
| `.btn-primary` 主 | `--heat` | `#FFFFFF` | 无,靠橙色阴影分层 |
|
||||
| `.btn-ghost` 无框 | transparent | `--ink-alpha-56` | 无 |
|
||||
|
||||
**尺寸:**
|
||||
|
||||
| 尺寸 | 高度 | padding | 字号 | icon 尺寸 |
|
||||
| ------ | ----- | ------------ | ------- | --------- |
|
||||
| `-sm` | 28 px | `0 10 px` | 12 px | 14 px |
|
||||
| 默认 | 32 px | `0 14 px` | 13 px | 16 px |
|
||||
| `-lg` | 40 px | `0 18 px` | 14 px | 16 px |
|
||||
|
||||
**5 种状态(每种类型都要实现):**
|
||||
|
||||
| 状态 | `.btn` 默认 | `.btn-primary` 主 |
|
||||
| -------- | ------------------------------------------ | ---------------------------------------------- |
|
||||
| Default | 白底 / inside-border `--ink-alpha-12` | 橙底 + 4 层橙阴影 |
|
||||
| Hover | 底色 `--ink-alpha-4` + 边框 opacity → 0 | 阴影第 3 层加亮(`0 4px 8px rgba(229,91,38,0.20)`) |
|
||||
| Active(按下) | 底色 `--ink-alpha-7` + `scale(0.99)` | `scale(0.995)` + 阴影 inset 加深 |
|
||||
| Focused | 外层 2 px `--heat-40` ring,offset 2 px | 同左 |
|
||||
| Disabled | 底 `--bg-soft` + 文字 `--ink-alpha-12` + `cursor: not-allowed` + 无 hover | 底 `--heat-40` + 文字 `#FFFFFF` + 阴影消失 + `cursor: not-allowed` |
|
||||
|
||||
**过渡:** 全场统一 `transition: background-color 200ms, opacity 200ms, transform 100ms, box-shadow 100ms ease`。
|
||||
|
||||
### 10.2 Pill(状态徽标)· 严格分级(对应用户诉求 ②)
|
||||
|
||||
> **V2 核心改进:Pill 分 3 级,同级别尺寸完全一致**
|
||||
|
||||
| 级别 | 高度 | padding | 字号 | 圆角 | dot 尺寸 | 用途 |
|
||||
| ------------ | ----- | ------------ | ------- | ----- | -------- | --------------------- |
|
||||
| **L1 大胶囊** | 28 px | `0 12 px` | 13 px | 999 px| 8 px | 项目状态 / 列表行 |
|
||||
| **L2 中胶囊** | 22 px | `0 10 px` | 11.5 px | 999 px| 6 px | **默认 / 通用** |
|
||||
| **L3 小胶囊** | 18 px | `0 8 px` | 10.5 px | 999 px| 5 px | KPI 卡角标 / Mono 标签 |
|
||||
|
||||
**色调(3 种,通用所有级别):**
|
||||
|
||||
| 状态 | 文字色 | 底色 | 边框(20% alpha)|
|
||||
| ---- | --------------- | ------------- | ---------------- |
|
||||
| info | `--heat` | `--heat-12` | `--heat-20` |
|
||||
| ok | `--green` | `--green-bg`(`#3F6B3F14`) | `--green-bd`(`#3F6B3F33`) |
|
||||
| err | `--red` | `--red-bg`(`#B33A2A14`) | `--red-bd`(`#B33A2A33`) |
|
||||
|
||||
每个 pill 前置圆点(`.dot`,直径同上表,颜色继承文字色)。
|
||||
|
||||
**HTML 写法:**
|
||||
|
||||
```html
|
||||
<span class="pill pill-l2 pill-info">
|
||||
<span class="dot"></span>
|
||||
生成中
|
||||
</span>
|
||||
```
|
||||
|
||||
### 10.3 输入框 / 搜索(V2.1 · 含 Firecrawl 式快捷键提示)
|
||||
|
||||
**尺寸:** 高 **36 px**(从 V2 的 32 上调,中文留白更舒展)/ padding `0 14 px` / 字号 14 px / 圆角 8 px
|
||||
|
||||
**状态:**
|
||||
|
||||
| 状态 | 边框 | 底色 |
|
||||
| -------- | --------------------------------- | --------------------- |
|
||||
| Default | inside-border `--black-alpha-12` | `--card` |
|
||||
| Hover | inside-border `--black-alpha-24` | `--card` |
|
||||
| Focused | inside-border `--heat-40` + inset 1 px | `--card` |
|
||||
| Error | inside-border `--red` | `--red-bg` |
|
||||
| Disabled | inside-border `--black-alpha-12` | **`--black-alpha-5`**(从 `--bg-soft` 改 · 冷灰底太接近白,看不出禁用) |
|
||||
|
||||
占位字色 `--black-alpha-48`,disabled 占位 `--black-alpha-24`。
|
||||
|
||||
**带图标 / 快捷键搜索框(参考 Firecrawl 实测):**
|
||||
|
||||
```
|
||||
[🔍] [ 搜索任意内容... ] Ctrl K
|
||||
│ │
|
||||
│ 16px line icon, color: --black-alpha-56 │ mono · 11.5px · --black-alpha-48
|
||||
│ 左 14 px,**z-index: 2**(关键) │ 右 14 px,**z-index: 2**
|
||||
│ │ 平铺文本(无边框盒),不用 kbd 样式
|
||||
```
|
||||
|
||||
**关键坑(已修):**
|
||||
1. **搜索 icon 看不见** —— `<input>` 的白色 bg 会盖住同级的 SVG icon。**SVG 必须加 `z-index: 2`** 才能抬到 input 之上。同理 Ctrl K 提示也要 z-index。
|
||||
2. **快捷键不用 `⌘`** —— JetBrains Mono webfont 不带 U+2318 `⌘` 字形,会显示成方框。**Windows 用户体系用 "Ctrl K" 纯文本**;Mac 端要显示 ⌘ 时用 SVG command 图标(见 §9.4)。
|
||||
3. **快捷键提示不要用 kbd 边框盒** —— Firecrawl 是平铺灰色 mono 文本,无任何 border / bg / radius。**显得克制,符合"克制大于装饰"。**
|
||||
|
||||
### 10.4 KPI 统计行(`.stats`)
|
||||
|
||||
- 1 行 4 格,共用一个 inside-border 容器,圆角 8 px,**无 gap**
|
||||
- 列与列之间用 `border-right: 1px solid var(--border-faint)` 分隔(最后一列去掉)
|
||||
- 容器四角加 SVG "+" 准星
|
||||
- 每格结构:label + L3 pill → 大数字(30 px) → delta / progress
|
||||
|
||||
### 10.5 列表行(`.list-row`)
|
||||
|
||||
```
|
||||
[缩略图 54×70] [标题 + meta] [进度条 5 段] [L2 pill] [按钮 -sm]
|
||||
```
|
||||
|
||||
- grid 5 列:`54px 1fr auto auto auto`
|
||||
- 行高 padding `14 px 18 px`
|
||||
- 行间 1 px 分隔线(`--border-faint`),最后行去掉
|
||||
- **整行 hover** → `--ink-alpha-4` 底色
|
||||
- **整行 active**(键盘选中)→ `--ink-alpha-7` 底色
|
||||
- **整行 disabled** → opacity 0.5 + `cursor: not-allowed`
|
||||
|
||||
### 10.6 进度条段位(流水线 5 阶段专用)· **V2.1 语义色重写**
|
||||
|
||||
5 个 `18×5 px` 小段,3 px 间距,**每段颜色映射真实业务状态**(替代 V2 的"全灰已完成"——那样像项目失败):
|
||||
|
||||
| 状态 | 颜色 | 视觉处理 |
|
||||
| -------- | --------------------- | ------------------------------ |
|
||||
| 未开始 | `--black-alpha-8` | 极弱灰底 · 静态 |
|
||||
| **已完成** | `--accent-forest`(`#42c366`)| **绿色 · 静态**(替代 V2 灰色) |
|
||||
| **进行中** | `--heat`(`#fa5d19`) | **橙色 + 1.4s 脉动**(opacity 1↔0.55 + scaleY 1↔0.7)· 区别于失败 |
|
||||
| 失败 | `--accent-crimson`(`#eb3424`)| 红色 · 静态 |
|
||||
|
||||
圆角:每段 2 px。
|
||||
|
||||
**关键设计:**
|
||||
- **绿/橙/红/灰** 四色一眼可辨:已完成的绿色让"5/5 全绿"=完美交付,**不会再有"全灰像失败了"的误读**
|
||||
- **橙色脉动动画** 是区分"进行中"与"失败"的关键——红色永远静态,只有橙色会呼吸,潜意识上"动 = 在运行,静 = 出错"
|
||||
|
||||
```css
|
||||
.prog span.now {
|
||||
background: var(--heat);
|
||||
animation: prog-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes prog-pulse {
|
||||
0%,100% { opacity: 1; transform: scaleY(1); }
|
||||
50% { opacity: .55; transform: scaleY(.7); }
|
||||
}
|
||||
```
|
||||
|
||||
### 10.7 快捷入口卡(`.shortcut`)
|
||||
|
||||
- 白底 / inside-border / **8 px 圆角**(V1 是 0) / 14 px padding
|
||||
- 左侧 32×32 橙色 tint 图标块(`--heat-12` 底 / 8 px 圆角 / 居中 16 px line icon)
|
||||
- 右侧:标题(13 px 500)+ Mono 描述(11.5 px,`--ink-alpha-48`)
|
||||
- **Default → Hover → Active 完整状态:**
|
||||
- Hover:底色 `--ink-alpha-4` / 标题 underline-from-orange 1 px(可选)
|
||||
- Active(点击瞬间):scale(0.99)
|
||||
- Focused(键盘):2 px `--heat-40` ring
|
||||
|
||||
### 10.8 提示框(`.tip`)
|
||||
|
||||
- 白底 / **1 px 虚线**边框(`dashed`)/ **8 px 圆角**
|
||||
- 加粗标题独立一行 + 正文
|
||||
- 内联代码用 `.mono` 类:橙色文字 + `--heat-12` 底 + 4 px 圆角
|
||||
|
||||
### 10.9 Toast
|
||||
|
||||
- 右下角 24 px 偏移 / 白底 / inside-border / **8 px 圆角**
|
||||
- 唯一允许的"白阴影":`0 4px 20px rgba(21,20,15,0.06)`
|
||||
- 进入动画:`translateX(420px → 0)`,缓动 `cubic-bezier(0.34, 1.56, 0.64, 1)`,300 ms
|
||||
- 自动消失:2400 ms
|
||||
- 内容结构:左侧 24×24 橙色 tint 图标(`--heat-12` 底)+ 右侧标题 + Mono 副文本(`[ 200 OK ]`)
|
||||
|
||||
### 10.10 弹窗(Modal)
|
||||
|
||||
- 居中,460–480 px 宽,白底,**8 px 圆角**(V1 是 0)
|
||||
- 四角加 SVG "+" 准星
|
||||
- 遮罩 `rgba(21,20,15,0.42)`,带 `backdrop-filter: blur(8px)`
|
||||
- 进入动画:`scale(0.96 → 1)`,250 ms 弹性缓动
|
||||
- 三段结构:
|
||||
- **Header:** 36 px 橙色 tint 图标 + 标题 + Mono 副标
|
||||
- **Body:** 13 px / `--ink-alpha-56` / 行高 1.7
|
||||
- **Footer:** 右对齐两个按钮(取消 + 主 CTA)
|
||||
- ESC 关闭 / 点击遮罩关闭
|
||||
|
||||
### 10.11 Tab(双层)
|
||||
|
||||
**主 Tab:**
|
||||
- 高 36 px / padding `0 14 px` / 字号 13 px / 字重 500
|
||||
- 未选中:`--ink-alpha-56` 文字
|
||||
- 选中:`--ink` 文字 + 底部 2 px `--heat` 横线(整个 tab 宽度)
|
||||
- Hover(未选中态):`--ink-alpha-7` 底色
|
||||
|
||||
**副 Tab(过滤型):**
|
||||
- 高 28 px / padding `0 10 px` / 字号 12 px
|
||||
- 未选中:`--ink-alpha-56` + 灰度 icon
|
||||
- 选中:`--ink` + 多色 icon(grayscale: 0)
|
||||
- Tab 之间用 1 px 高 12 px 的 `--ink-alpha-7` 竖条分隔(可选)
|
||||
|
||||
### 10.12 Dropdown / Select
|
||||
|
||||
- 触发器:同输入框样式(高 32 / 圆角 8 / inside-border)
|
||||
- 右侧 16 px chevron-down icon,色 `--ink-alpha-48`,展开时旋转 180°
|
||||
- 弹层:白底 / 8 px 圆角 / `0 4px 20px rgba(21,20,15,0.06)` 阴影 / **唯一允许阴影场景之二**
|
||||
- 选项:高 32 / padding `0 12 / hover `--ink-alpha-4` / 选中 `--heat-12` + 右侧 14 px checkmark `--heat`
|
||||
- 分组分隔:1 px `--border-faint`
|
||||
|
||||
### 10.13 Checkbox / Radio / Switch(V2.1 · 全部用 SVG icon)
|
||||
|
||||
**通用原则:** 所有 indicator 都用真 SVG(via background-image data URI 或 inline `<svg>`),**禁止用 border-width / transform: rotate(45deg) 凑对勾**——那种 CSS hack 在缩放/字体渲染下会变形。
|
||||
|
||||
**Checkbox:**
|
||||
- **容器:** 16×16 / 4 px 圆角 / inside-border `--black-alpha-24`(hover 时 → `--black-alpha-48`)
|
||||
- **Checked:** `--heat` 底 · 居中 12×12 白色 SVG checkmark(`<path d="M20 6 9 17l-5-5"/>` · stroke 3 · linecap round)
|
||||
- **Indeterminate:** `--heat` 底 · 居中 12×12 白色 SVG 横线(`<path d="M5 12h14"/>` · stroke 3)
|
||||
- **Disabled:** 底 `--black-alpha-5` + 边 `--black-alpha-12` + icon 不渲染或半透明
|
||||
- **实现方式:** `background-image: url("data:image/svg+xml,...")` · `background-size: 12px 12px` · `background-position: center` · 这样不需要 ::after 凑出 icon,SVG 是真实的
|
||||
|
||||
**Radio:**
|
||||
- 16×16 圆 / inside-border `--black-alpha-24`
|
||||
- Checked:内嵌 8×8 `--heat` 实心圆(纯几何形状,不算 icon,用 ::after 即可)
|
||||
- Disabled:底 `--black-alpha-5`
|
||||
|
||||
**Switch:**
|
||||
- 容器 28×16 / 999 px 圆角
|
||||
- Off:底 `--black-alpha-12` / 圆球 `#FFFFFF` 12×12 + 1 px subtle shadow
|
||||
- On:底 `--heat` / 圆球右移到 `left: 14px`
|
||||
- 过渡:`background 200 ms`,圆球位移 `left 200 ms`
|
||||
|
||||
> **为什么不用字符 `✓` `✗` 这种 Unicode:** 不同系统、不同字体对这些字符的字形支持差异巨大(`⌘` 在 JetBrains Mono Web 字体里就是缺字形 → 显示成方框)。**SVG 是唯一可靠的解。**
|
||||
|
||||
---
|
||||
|
||||
## 11. 微观细节(品牌签名 · V1 保留)
|
||||
|
||||
### 11.1 标签全部包方括号
|
||||
`[ 200 OK ]` `[ .MP4 · 9:16 ]` `[ /v2 ]` `[ /sidebar collapse ]`
|
||||
|
||||
### 11.2 时间戳像代码注释
|
||||
`// 05.14 · 周五`
|
||||
|
||||
### 11.3 数值后缀
|
||||
`¥327` 主体大字 + `.40` 小字次级(`<small>`)
|
||||
|
||||
### 11.4 强调单词上色
|
||||
正文里 `<b style="color: var(--ink)">3 个项目</b>` —— 让具体数值/名词比周围文字更深一档(不是变橙)。**橙色只留给 CTA。**
|
||||
|
||||
### 11.5 ASCII 字符做装饰
|
||||
`↑ 本月 +3` `↓ -1.2%`
|
||||
|
||||
### 11.6 链接式"更多"按钮
|
||||
`[ ALL · 12 ] →` —— Mono 标签 + 箭头,色 `--ink-alpha-48`,hover 转橙。
|
||||
|
||||
### 11.7 缩略图不放图,放比例
|
||||
`9:16` Mono 字符占位。
|
||||
|
||||
---
|
||||
|
||||
## 12. Don't List(绝对禁止)
|
||||
|
||||
- ❌ **0 px 卡片**(V1 → V2 反转)
|
||||
- ❌ 渐变背景(只有 hero 区可考虑,但首选纯色)
|
||||
- ❌ 玻璃拟态(`backdrop-filter` 只用于 modal 遮罩)
|
||||
- ❌ 彩色 emoji 图标(用 SVG line icon,1.5 px stroke)
|
||||
- ❌ 多个 accent 色(全场只有橙色)
|
||||
- ❌ 大圆角容器(>12 px 直接判错)
|
||||
- ❌ 灰色阴影 / 文字阴影(只允许橙色主 CTA 阴影 + Toast/Dropdown 白阴影)
|
||||
- ❌ 鲜艳的状态色(避免荧光绿、电光蓝、霓虹粉)
|
||||
- ❌ 居中对齐大段正文(全部左对齐)
|
||||
- ❌ 把装饰当主角(场记板、丝绒、霓虹灯)
|
||||
- ❌ 无意义的微动效(hover 旋转、缩放、彩虹流光)
|
||||
- ❌ **hover 时换深 hue 的橙**(用 alpha)
|
||||
- ❌ **真 `border` + hover 边框消失**(用 inside-border ::before)
|
||||
- ❌ **同一行混用直角和圆角**(用户原话:"不要有些是直角,胶囊又是圆角")
|
||||
|
||||
---
|
||||
|
||||
## 13. Sass / CSS Token 速查表(V2.1)
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* ============================================================
|
||||
Color system V2.1 · Firecrawl-aligned
|
||||
============================================================ */
|
||||
|
||||
/* === 表面 / 背景(冷灰)=== */
|
||||
--background-base: #f9f9f9;
|
||||
--background-lighter: #fbfbfb;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
|
||||
/* === 边框(冷灰 · 3 档)=== */
|
||||
--border-faint: #ededed; /* 默认 1 px */
|
||||
--border-muted: #e8e8e8;
|
||||
--border-loud: #e6e6e6;
|
||||
|
||||
/* === Accent 多彩(5 色信号)=== */
|
||||
--accent-black: #262626;
|
||||
--accent-white: #ffffff;
|
||||
--accent-amethyst: #9061ff;
|
||||
--accent-bluetron: #2a6dfb;
|
||||
--accent-crimson: #eb3424;
|
||||
--accent-forest: #42c366;
|
||||
--accent-honey: #ecb730;
|
||||
|
||||
/* === Heat · 单 hue + 8 档 alpha === */
|
||||
--heat: #fa5d19;
|
||||
--heat-90: rgba(250, 93, 25, .90);
|
||||
--heat-40: rgba(250, 93, 25, .40);
|
||||
--heat-20: rgba(250, 93, 25, .20);
|
||||
--heat-16: rgba(250, 93, 25, .16);
|
||||
--heat-12: rgba(250, 93, 25, .12);
|
||||
--heat-8: rgba(250, 93, 25, .08);
|
||||
--heat-4: rgba(250, 93, 25, .04);
|
||||
|
||||
/* === Black-Alpha 阶梯(20 档 · 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)(保留作为历史)
|
||||
@@ -0,0 +1,16 @@
|
||||
# Deprecated Static Pages Archive · 2026-05-28
|
||||
|
||||
These pages were moved out of the active static app because the current
|
||||
navigation and production prototype use `products.html`, `product-detail.html`,
|
||||
`pipeline.html`, `asset-factory.html`, `model-photo.html`, and
|
||||
`platform-cover.html` instead.
|
||||
|
||||
Archived pages:
|
||||
- `product-create.legacy.html`
|
||||
- `product-create-v2.html`
|
||||
- `product-studio.html`
|
||||
- `studio.html`
|
||||
- `studio-v2.html`
|
||||
|
||||
Keep them only as reference material. They should not be linked from the active
|
||||
Airshelf static flow.
|
||||
@@ -0,0 +1,414 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>新建商品 · Airshelf</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/restraint.css?v=2026052607">
|
||||
<style>
|
||||
/* ─── 主表单 ─── */
|
||||
.form-grid {
|
||||
display: grid; grid-template-columns: 1.05fr 1fr; gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.form-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
padding: 24px;
|
||||
}
|
||||
.form-card .card-h {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-card .card-h h3 {
|
||||
font-size: 14px; font-weight: 600; color: var(--accent-black);
|
||||
}
|
||||
.form-card .card-h .req-tag {
|
||||
font-family: var(--font-mono); font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
background: var(--crimson-bg); color: var(--accent-crimson);
|
||||
border-radius: var(--r-sm); letter-spacing: .04em;
|
||||
border: 1px solid var(--red-bd);
|
||||
}
|
||||
.form-card .card-h .opt-tag {
|
||||
font-family: var(--font-mono); font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
background: var(--background-lighter); color: var(--black-alpha-56);
|
||||
border-radius: var(--r-sm); letter-spacing: .04em;
|
||||
border: 1px solid var(--border-faint);
|
||||
}
|
||||
.form-card .card-sub {
|
||||
font-family: var(--font-mono); font-size: 11.5px;
|
||||
color: var(--black-alpha-48); margin: -10px 0 14px; letter-spacing: .02em;
|
||||
}
|
||||
|
||||
/* 原图槽位 */
|
||||
.photo-grid {
|
||||
display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.photo-slot {
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--r-md);
|
||||
border: 1px dashed var(--border-faint);
|
||||
background: var(--background-lighter);
|
||||
display: grid; place-items: center;
|
||||
color: var(--black-alpha-32);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
font-size: 10px; font-family: var(--font-mono); letter-spacing: .04em;
|
||||
transition: all var(--t-base);
|
||||
}
|
||||
.photo-slot:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-8); }
|
||||
.photo-slot.filled {
|
||||
border-style: solid;
|
||||
background-size: cover; background-position: center;
|
||||
cursor: default; color: transparent;
|
||||
}
|
||||
.photo-slot.filled:hover { border-color: var(--heat-40); }
|
||||
.photo-slot .slot-label {
|
||||
position: absolute; top: 5px; left: 5px;
|
||||
font-family: var(--font-mono); font-size: 9.5px; font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
background: rgba(255,255,255,.92); color: var(--black-alpha-72);
|
||||
border-radius: var(--r-sm); letter-spacing: .04em;
|
||||
}
|
||||
.photo-slot .slot-main {
|
||||
position: absolute; top: 5px; right: 5px;
|
||||
font-family: var(--font-mono); font-size: 9.5px; font-weight: 600;
|
||||
padding: 2px 6px; background: var(--heat); color: #fff;
|
||||
border-radius: var(--r-sm); letter-spacing: .04em;
|
||||
}
|
||||
.photo-slot .slot-x {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
width: 18px; height: 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(21,20,15,.7); color: #fff;
|
||||
display: none; place-items: center; cursor: pointer; border: 0;
|
||||
}
|
||||
.photo-slot.filled:hover .slot-x { display: grid; }
|
||||
.photo-slot.filled:hover .slot-main { display: none; }
|
||||
.photo-slot .slot-x svg { width: 9px; height: 9px; }
|
||||
.photo-slot .plus { width: 22px; height: 22px; border: 1px solid currentColor; border-radius: var(--r-sm); display: grid; place-items: center; margin-bottom: 4px; }
|
||||
.photo-slot .plus svg { width: 12px; height: 12px; }
|
||||
|
||||
.upload-tip {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-top: 14px; padding: 10px 12px;
|
||||
background: var(--heat-8); border: 1px dashed var(--heat-40);
|
||||
border-radius: var(--r-md);
|
||||
font-size: 12px; color: var(--accent-black); line-height: 1.5;
|
||||
}
|
||||
.upload-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; }
|
||||
.upload-tip strong { color: var(--heat); font-weight: 600; }
|
||||
|
||||
/* AI 提示 banner(选填字段说明) */
|
||||
.ai-tip {
|
||||
margin-top: -6px; margin-bottom: 16px;
|
||||
padding: 10px 12px;
|
||||
background: var(--background-lighter);
|
||||
border-radius: var(--r-md);
|
||||
border: 1px dashed var(--border-faint);
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
font-size: 12px; color: var(--black-alpha-72); line-height: 1.55;
|
||||
}
|
||||
.ai-tip svg { width: 13px; height: 13px; color: var(--heat); flex-shrink: 0; margin-top: 2px; }
|
||||
.ai-tip strong { color: var(--accent-black); font-weight: 600; }
|
||||
|
||||
/* 卖点 bullet 输入 */
|
||||
.sell-list { list-style: none; margin: 0; padding: 0; }
|
||||
.sell-list li {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sell-list li.add { background: var(--surface); border-style: dashed; }
|
||||
.sell-list .num {
|
||||
width: 18px; height: 18px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 10.5px; font-family: var(--font-mono);
|
||||
color: var(--black-alpha-56);
|
||||
display: grid; place-items: center; flex-shrink: 0;
|
||||
}
|
||||
.sell-list li.add .num { background: transparent; color: var(--heat); border-color: var(--heat-40); }
|
||||
.sell-list .txt { flex: 1; min-width: 0; }
|
||||
.sell-list .bl-input {
|
||||
flex: 1; border: 0; background: transparent;
|
||||
font-size: 13px; color: var(--accent-black); padding: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.sell-list .bl-input::placeholder { color: var(--black-alpha-48); }
|
||||
.sell-list .bl-x {
|
||||
width: 20px; height: 20px;
|
||||
display: grid; place-items: center;
|
||||
color: var(--black-alpha-48); cursor: pointer;
|
||||
background: transparent; border: 0; opacity: 0;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.sell-list li:hover .bl-x { opacity: 1; }
|
||||
.sell-list .bl-x:hover { color: var(--accent-crimson); }
|
||||
.sell-list .bl-x svg { width: 11px; height: 11px; }
|
||||
|
||||
/* 底部操作行 */
|
||||
.form-foot {
|
||||
position: sticky; bottom: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
padding: 14px 22px;
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.form-foot .req-info {
|
||||
font-family: var(--font-mono); font-size: 11.5px;
|
||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||
}
|
||||
.form-foot .req-info .ok { color: var(--accent-forest); }
|
||||
.form-foot .req-info .miss { color: var(--accent-crimson); }
|
||||
.form-foot .actions {
|
||||
margin-left: auto;
|
||||
display: flex; gap: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page">
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>新建商品</h1>
|
||||
<div class="sub"><span class="mono">// 上传原图 + 填写基本信息</span> · 保存后可在工作台逐步丰富素材</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 表单 ============ -->
|
||||
<div class="form-grid">
|
||||
|
||||
<!-- 左:原图 -->
|
||||
<div class="form-card">
|
||||
<div class="card-h">
|
||||
<h3>商品原图</h3>
|
||||
<span class="req-tag">必填</span>
|
||||
</div>
|
||||
<div class="card-sub">// 1-5 张 · 这是后续所有 AI 生成的源材料</div>
|
||||
<input type="file" id="photo-input" accept="image/*" multiple hidden>
|
||||
<div class="photo-grid" id="photo-grid"></div>
|
||||
<div class="upload-tip">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></svg>
|
||||
<span>建议上传 <strong>正面 / 侧面 / 细节 / 包装</strong> 4 张,后续在工作台生成的<strong>白底三视图</strong>更准确。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右:基本信息 -->
|
||||
<div class="form-card">
|
||||
<div class="card-h">
|
||||
<h3>基本信息</h3>
|
||||
<span class="req-tag">必填</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">商品名称<span class="req">*</span></label>
|
||||
<input class="input" id="p-name" placeholder="例: 透真玻尿酸补水面膜">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">品类<span class="req">*</span></label>
|
||||
<select class="select" id="p-cat">
|
||||
<option value="">— 选择品类 —</option>
|
||||
<option>美妆个护</option>
|
||||
<option>服饰内衣</option>
|
||||
<option>食品饮料</option>
|
||||
<option>家居家电</option>
|
||||
<option>数码 3C</option>
|
||||
<option>个护清洁</option>
|
||||
<option>运动户外</option>
|
||||
<option>母婴亲子</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:0;">
|
||||
<label class="field-label">价格(元)</label>
|
||||
<input class="input" id="p-price" type="number" placeholder="选填 · 仅用于素材生成参考">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="form-card" style="margin-bottom: 24px;">
|
||||
<div class="card-h">
|
||||
<h3>卖点 & 人群</h3>
|
||||
<span class="opt-tag">选填 · 推荐</span>
|
||||
</div>
|
||||
<div class="ai-tip">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l1.5 4.5L18 8l-4.5 1.5L12 14l-1.5-4.5L6 8l4.5-1.5L12 2z"/></svg>
|
||||
<span>填上这两项,后续 AI 生脚本(<strong>痛点种草 / 剧情带货</strong> 等模板)质量明显更高 —— 系统会用卖点构造钩子,用人群定语气。现在不填也可以,做视频项目时仍可补。</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">核心卖点</label>
|
||||
<div class="field-hint" style="margin: 4px 0 8px;">3-5 条要点,回车添加</div>
|
||||
<ul class="sell-list" id="sell-list">
|
||||
<li class="add"><span class="num">+</span><input class="bl-input" id="sell-input" placeholder="例: 玻尿酸双效保湿,4 小时持久水润"></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:0;">
|
||||
<label class="field-label">目标人群</label>
|
||||
<input class="input" id="p-target" placeholder="例: 22-32 岁女性、敏感肌、办公室通勤">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 底部操作 ============ -->
|
||||
<div class="form-foot">
|
||||
<span class="req-info" id="req-info">// 必填检查:<span class="miss">商品名 / 品类 / ≥1 张图</span> 未完成</span>
|
||||
<div class="actions">
|
||||
<a class="btn" href="products.html">取消</a>
|
||||
<button class="btn btn-primary" id="save-btn" disabled>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>
|
||||
保存并进入工作台
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="assets/icons.js?v=2026052608"></script>
|
||||
<script src="assets/shell.js?v=2026052607"></script>
|
||||
<script>
|
||||
Shell.render({
|
||||
active: 'products',
|
||||
crumbs: [
|
||||
{ label: '工作台', href: 'index.html' },
|
||||
{ label: '商品库', href: 'products.html' },
|
||||
{ label: '新建' }
|
||||
]
|
||||
});
|
||||
|
||||
const MAX = 5;
|
||||
const photos = []; // { id, dataUrl }
|
||||
const SLOT_LABELS = ['主图', '细节 02', '细节 03', '细节 04', '细节 05'];
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
// 渲染槽位
|
||||
function renderPhotos() {
|
||||
const grid = $('photo-grid');
|
||||
grid.innerHTML = '';
|
||||
for (let i = 0; i < MAX; i++) {
|
||||
const slot = document.createElement('div');
|
||||
const p = photos[i];
|
||||
if (p) {
|
||||
slot.className = 'photo-slot filled';
|
||||
slot.style.backgroundImage = `url("${p.dataUrl}")`;
|
||||
slot.innerHTML = `
|
||||
<span class="slot-label">${SLOT_LABELS[i]}</span>
|
||||
${i === 0 ? '<span class="slot-main">MAIN</span>' : ''}
|
||||
<button class="slot-x" type="button" data-i="${i}" aria-label="移除">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
|
||||
</button>
|
||||
`;
|
||||
} else if (i === photos.length) {
|
||||
slot.className = 'photo-slot empty';
|
||||
slot.innerHTML = `<div class="plus"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 5v14M5 12h14"/></svg></div><span>添加</span>`;
|
||||
slot.addEventListener('click', () => $('photo-input').click());
|
||||
} else {
|
||||
slot.className = 'photo-slot';
|
||||
slot.style.opacity = '.6';
|
||||
}
|
||||
grid.appendChild(slot);
|
||||
}
|
||||
// 绑定删除
|
||||
grid.querySelectorAll('.slot-x').forEach(b => {
|
||||
b.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const i = +b.dataset.i;
|
||||
photos.splice(i, 1);
|
||||
renderPhotos();
|
||||
syncSave();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 文件上传
|
||||
$('photo-input').addEventListener('change', e => {
|
||||
const files = [...e.target.files].filter(f => f.type.startsWith('image/'));
|
||||
const remain = MAX - photos.length;
|
||||
files.slice(0, remain).forEach(f => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => {
|
||||
photos.push({
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 5),
|
||||
dataUrl: ev.target.result,
|
||||
});
|
||||
renderPhotos();
|
||||
syncSave();
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
});
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
// 卖点
|
||||
const sellList = $('sell-list');
|
||||
const sellInput = $('sell-input');
|
||||
sellInput.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
const t = sellInput.value.trim();
|
||||
if (!t) return;
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<span class="num"></span><span class="txt"></span><button class="bl-x" type="button" aria-label="删除"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>`;
|
||||
li.querySelector('.txt').textContent = t;
|
||||
sellList.querySelector('.add').before(li);
|
||||
sellInput.value = '';
|
||||
renumberSell();
|
||||
li.querySelector('.bl-x').addEventListener('click', () => {
|
||||
li.remove();
|
||||
renumberSell();
|
||||
});
|
||||
});
|
||||
function renumberSell() {
|
||||
sellList.querySelectorAll('li:not(.add) .num').forEach((n, i) => n.textContent = i + 1);
|
||||
}
|
||||
|
||||
// 必填检查
|
||||
function syncSave() {
|
||||
const hasName = $('p-name').value.trim().length > 0;
|
||||
const hasCat = $('p-cat').value.length > 0;
|
||||
const hasPhoto = photos.length > 0;
|
||||
const ok = hasName && hasCat && hasPhoto;
|
||||
$('save-btn').disabled = !ok;
|
||||
|
||||
const missing = [];
|
||||
if (!hasName) missing.push('商品名');
|
||||
if (!hasCat) missing.push('品类');
|
||||
if (!hasPhoto) missing.push('≥1 张图');
|
||||
|
||||
const info = $('req-info');
|
||||
if (ok) {
|
||||
info.innerHTML = '// 必填检查:<span class="ok">已全部完成 ✓</span> · 可进入工作台';
|
||||
} else {
|
||||
info.innerHTML = `// 必填检查:<span class="miss">${missing.join(' / ')}</span> 未完成`;
|
||||
}
|
||||
}
|
||||
$('p-name').addEventListener('input', syncSave);
|
||||
$('p-cat').addEventListener('change', syncSave);
|
||||
|
||||
// 保存 → 跳工作台
|
||||
$('save-btn').addEventListener('click', () => {
|
||||
if ($('save-btn').disabled) return;
|
||||
Shell.toast('商品已建档', `进入工作台`);
|
||||
setTimeout(() => {
|
||||
// 真实场景下会带商品 ID;demo 直接跳
|
||||
location.href = 'product-studio.html?from=new&onboard=1';
|
||||
}, 500);
|
||||
});
|
||||
|
||||
renderPhotos();
|
||||
syncSave();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,881 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>消费 · Airshelf</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
|
||||
<style>
|
||||
/* ─── 顶部:左右布局(余额 banner + 快速充值)─── */
|
||||
.top-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(480px, .85fr); gap: 16px; margin-bottom: 22px; align-items: stretch; }
|
||||
@media (max-width: 1120px) { .top-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.balance-banner {
|
||||
background: var(--accent-black);
|
||||
color: var(--accent-white);
|
||||
padding: 26px 28px;
|
||||
position: relative;
|
||||
border: 1px solid var(--accent-black);
|
||||
border-radius: var(--r-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
min-height: 246px;
|
||||
}
|
||||
.balance-banner::before, .balance-banner::after,
|
||||
.balance-banner > .corner-tr, .balance-banner > .corner-bl {
|
||||
content: ''; position: absolute; width: 14px; height: 14px;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center;
|
||||
background-size: contain; pointer-events: none;
|
||||
}
|
||||
.balance-banner::before { top: -7px; left: -7px; }
|
||||
.balance-banner::after { bottom: -7px; right: -7px; }
|
||||
.balance-banner > .corner-tr { top: -7px; right: -7px; }
|
||||
.balance-banner > .corner-bl { bottom: -7px; left: -7px; }
|
||||
|
||||
/* 主余额 · 突出展示 */
|
||||
.balance-hero { display: flex; flex-direction: column; gap: 4px; }
|
||||
.balance-hero .lbl { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.55); letter-spacing: .06em; text-transform: uppercase; }
|
||||
.balance-hero .v { font-size: 38px; font-weight: 700; letter-spacing: -.02em; font-variant-numeric: tabular-nums; line-height: 1.1; }
|
||||
.balance-hero .meta { font-size: 11.5px; color: rgba(255,255,255,.5); font-family: var(--font-mono); letter-spacing: .02em; margin-top: 4px; }
|
||||
|
||||
/* 子统计 · 月限额 / 已用 */
|
||||
.balance-sub { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding: 16px 0 0; border-top: 1px solid rgba(255,255,255,.1); }
|
||||
.balance-sub .col { min-width: 0; }
|
||||
.balance-sub .lbl { font-family: var(--font-mono); font-size: 10px; color: rgba(255,255,255,.5); letter-spacing: .06em; text-transform: uppercase; }
|
||||
.balance-sub .v { font-size: 18px; font-weight: 700; letter-spacing: -.01em; margin-top: 4px; font-variant-numeric: tabular-nums; }
|
||||
.balance-sub .meta { font-size: 10.5px; color: rgba(255,255,255,.42); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
|
||||
|
||||
.balance-foot { margin-top: auto; padding-top: 2px; }
|
||||
.balance-meter { height: 5px; background: rgba(255,255,255,.12); border-radius: var(--r-pill); overflow: hidden; }
|
||||
.balance-meter > span { display: block; height: 100%; width: 5.4%; background: var(--heat); border-radius: inherit; }
|
||||
.balance-foot-meta { display: flex; justify-content: space-between; gap: 14px; margin-top: 8px; color: rgba(255,255,255,.46); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }
|
||||
|
||||
/* ─── 快速充值 pane(右栏)─── */
|
||||
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 20px; margin-bottom: 16px; }
|
||||
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 6px; }
|
||||
.pane .desc { font-size: 11.5px; color: var(--black-alpha-48); margin-bottom: 14px; font-family: var(--font-mono); letter-spacing: .02em; }
|
||||
.topup-pane { display: flex; flex-direction: column; padding: 20px 22px; margin-bottom: 0; min-height: 246px; }
|
||||
.topup-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
|
||||
.topup-head h3 { margin-bottom: 5px; }
|
||||
.topup-head .desc { margin-bottom: 0; }
|
||||
.topup-selected { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); white-space: nowrap; padding-top: 2px; }
|
||||
|
||||
.recharge-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
||||
.recharge-card { min-height: 76px; border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 10px 8px; text-align: center; cursor: pointer; background: var(--surface); position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base), transform var(--t-fast); }
|
||||
.recharge-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-24); }
|
||||
.recharge-card:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--heat-40); }
|
||||
.recharge-card.selected { border-color: var(--heat); background: var(--heat-12); box-shadow: inset 0 0 0 1px var(--heat); }
|
||||
.recharge-card.selected::after { content: '✓'; position: absolute; top: 6px; right: 7px; width: 15px; height: 15px; border-radius: 50%; display: grid; place-items: center; background: var(--heat); color: var(--accent-white); font-size: 10px; line-height: 1; }
|
||||
.recharge-card .amt { font-size: 17px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.15; }
|
||||
.recharge-card .gift { font-size: 10px; color: var(--black-alpha-48); margin-top: 4px; font-family: var(--font-mono); white-space: nowrap; }
|
||||
.recharge-card .gift.bonus { color: var(--accent-forest); font-weight: 600; }
|
||||
.recharge-card .ribbon { position: absolute; top: 6px; left: 7px; font-family: var(--font-mono); font-size: 9px; padding: 1px 5px; background: var(--heat); color: var(--accent-white); letter-spacing: .03em; font-weight: 600; border-radius: var(--r-sm); }
|
||||
.pay-row { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
||||
.pay-title { font-size: 12px; font-weight: 600; color: var(--accent-black); line-height: 1.2; }
|
||||
.pay-row .input { width: 100%; box-sizing: border-box; height: 38px; }
|
||||
.pay-btn-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.pay-method-btn { height: 38px; border-radius: var(--r-pill); display: inline-flex; justify-content: center; align-items: center; gap: 8px; background: var(--surface); color: var(--accent-black); border-color: var(--border-faint); font-weight: 500; }
|
||||
.pay-method-btn:hover { background: var(--background-lighter); color: var(--accent-black); border-color: var(--black-alpha-24); }
|
||||
.pay-method-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--black-alpha-16); }
|
||||
.pay-logo { width: 18px; height: 18px; border-radius: 6px; display: inline-block; flex: 0 0 18px; overflow: hidden; }
|
||||
.pay-logo img { width: 100%; height: 100%; display: block; object-fit: cover; border-radius: inherit; }
|
||||
@media (max-width: 720px) {
|
||||
.recharge-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
/* ─── Tab strip ─── */
|
||||
.billing-tabs { display: flex; align-items: flex-end; gap: 4px; border-bottom: 1px solid var(--border-faint); margin: 24px 0 18px; padding: 0 2px; overflow-x: auto; scrollbar-width: none; }
|
||||
.billing-tabs::-webkit-scrollbar { display: none; }
|
||||
.billing-tabs .tab { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 6px; background: transparent; border: 0; border-bottom: 2px solid transparent; border-radius: var(--r-md) var(--r-md) 0 0; margin-bottom: -1px; padding: 10px 14px; font-size: 13px; font-weight: 500; color: var(--black-alpha-56); font-family: inherit; cursor: pointer; letter-spacing: 0; user-select: none; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||
.billing-tabs .tab:hover { color: var(--accent-black); background: var(--black-alpha-4); }
|
||||
.billing-tabs .tab:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--heat-40); }
|
||||
.billing-tabs .tab.active { color: var(--accent-black); border-bottom-color: var(--heat); font-weight: 600; }
|
||||
.billing-tabs .tab .count { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); padding: 1px 7px; background: var(--black-alpha-4); border-radius: var(--r-sm); letter-spacing: .04em; }
|
||||
.billing-tabs .tab.active .count { background: var(--heat-12); color: var(--heat); }
|
||||
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* ─── 总览 · 趋势 + 阶段分布 (两栏等高) ─── */
|
||||
.overview-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 16px; align-items: stretch; }
|
||||
|
||||
.trend-pane { padding: 18px 20px 14px; display: flex; flex-direction: column; }
|
||||
.trend-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 14px; }
|
||||
.trend-head h3 { margin-bottom: 0; }
|
||||
.trend-head .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
|
||||
.trend-head .spacer { flex: 1; }
|
||||
.trend-head .chip { font-family: var(--font-mono); font-size: 10.5px; padding: 3px 8px; border: 1px solid var(--border-faint); border-radius: var(--r-pill); color: var(--black-alpha-56); cursor: pointer; }
|
||||
.trend-head .chip.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }
|
||||
|
||||
.trend-chart { display: grid; grid-template-rows: 1fr auto; gap: 6px; min-height: 170px; flex: 1; padding: 6px 4px 2px; position: relative; }
|
||||
.trend-chart .bars { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; align-items: end; height: 100%; }
|
||||
.trend-chart .bar { background: var(--background-lighter); border-radius: 2px 2px 0 0; position: relative; transition: background var(--t-base); cursor: pointer; }
|
||||
.trend-chart .bar > span { display: block; width: 100%; background: var(--heat); border-radius: 2px 2px 0 0; }
|
||||
.trend-chart .bar:hover > span { background: var(--accent-black); }
|
||||
.trend-chart .bar.peak > span { background: var(--accent-black); }
|
||||
.trend-chart .x-axis { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-32); text-align: center; letter-spacing: .02em; }
|
||||
.trend-foot { display: flex; gap: 14px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-faint); font-size: 12px; }
|
||||
.trend-foot .item { display: flex; align-items: baseline; gap: 6px; }
|
||||
.trend-foot .item .k { color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }
|
||||
.trend-foot .item .v { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--accent-black); }
|
||||
.trend-foot .item .v.warn { color: #B45309; }
|
||||
|
||||
/* ─── 阶段分布 ─── */
|
||||
.stage-pane .usage-line { display: flex; justify-content: space-between; padding: 4px 0 4px; font-size: 12.5px; }
|
||||
.stage-pane .usage-line .k { color: var(--accent-black); }
|
||||
.stage-pane .usage-line .v { font-variant-numeric: tabular-nums; color: var(--accent-black); font-weight: 600; }
|
||||
.stage-pane .usage-bar { height: 4px; background: var(--background-lighter); border-radius: 2px; margin: 4px 0 10px; overflow: hidden; }
|
||||
.stage-pane .usage-bar > span { display: block; height: 100%; transition: width .3s ease; }
|
||||
.stage-pane .total { display: flex; justify-content: space-between; padding-top: 10px; margin-top: 6px; border-top: 1px solid var(--border-faint); font-size: 13px; font-weight: 600; }
|
||||
.stage-pane .total .v { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ─── 扣费规则 + 四层预检 ─── */
|
||||
.rule-pane .rule-list { font-size: 12.5px; color: var(--black-alpha-56); line-height: 1.7; }
|
||||
.rule-pane .rule-list strong { color: var(--accent-black); font-weight: 600; }
|
||||
.rule-pane .mono-acc { font-family: var(--font-mono); color: var(--heat); background: var(--heat-12); padding: 1px 5px; font-size: 11.5px; border-radius: var(--r-sm); }
|
||||
|
||||
.quota-rules { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-faint); }
|
||||
.quota-rules .qr-head { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; margin-bottom: 10px; }
|
||||
.quota-rules .step { display: grid; grid-template-columns: 22px 1fr; gap: 10px; align-items: baseline; margin-bottom: 6px; font-size: 12.5px; color: var(--accent-black); }
|
||||
.quota-rules .step .num { width: 20px; height: 20px; border-radius: 50%; background: var(--heat-12); color: var(--heat); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; display: grid; place-items: center; }
|
||||
.quota-rules .step .formula { font-family: var(--font-mono); font-size: 11.5px; color: var(--heat); background: var(--heat-12); padding: 0 4px; border-radius: var(--r-sm); }
|
||||
|
||||
/* ─── 表格通用 ─── */
|
||||
.billing-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border-muted); border-radius: var(--r-md); overflow: hidden; }
|
||||
.billing-table th, .billing-table td { padding: 11px 14px; text-align: left; font-size: 12.5px; border-bottom: 0; }
|
||||
.billing-table thead th { background: var(--background-lighter); border-bottom: 1px solid var(--border-muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
|
||||
.billing-table tbody tr:hover { background: var(--background-lighter); }
|
||||
.billing-table .ts { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
|
||||
.billing-table .neg { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-black); text-align: right; }
|
||||
.billing-table .pos { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-forest); text-align: right; }
|
||||
.billing-table .zero { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--black-alpha-32); text-align: right; }
|
||||
.billing-table .muted { color: var(--black-alpha-56); font-size: 11.5px; }
|
||||
.billing-table .ref { color: var(--black-alpha-48); font-size: 10.5px; font-family: var(--font-mono); }
|
||||
.billing-table .who { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.billing-table .who .av { width: 24px; height: 24px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: inline-grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--accent-black); }
|
||||
.billing-table .role-pill { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: var(--r-pill); font-size: 10.5px; font-weight: 500; }
|
||||
.billing-table .role-pill .dot { width: 5px; height: 5px; border-radius: 50%; }
|
||||
.billing-table .role-super { background: var(--heat-12); color: var(--heat); }
|
||||
.billing-table .role-super .dot { background: var(--heat); }
|
||||
.billing-table .role-admin { background: rgba(30,64,175,.1); color: #1E40AF; }
|
||||
.billing-table .role-admin .dot { background: #1E40AF; }
|
||||
.billing-table .role-member { background: var(--background-lighter); color: var(--black-alpha-56); }
|
||||
.billing-table .role-member .dot { background: var(--black-alpha-56); }
|
||||
.billing-table .status-tag { font-family: var(--font-mono); font-size: 10px; padding: 1px 6px; border-radius: var(--r-sm); letter-spacing: .04em; }
|
||||
.billing-table .status-tag.ok { background: rgba(66,195,102,.12); color: var(--accent-forest); }
|
||||
.billing-table .status-tag.wip { background: var(--heat-12); color: var(--heat); }
|
||||
.billing-table .status-tag.fail { background: rgba(235,52,36,.10); color: var(--accent-crimson); }
|
||||
.billing-table .progress-mini { width: 80px; height: 4px; background: var(--background-lighter); border-radius: 2px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 8px; }
|
||||
.billing-table .progress-mini > span { display: block; height: 100%; background: var(--heat); }
|
||||
|
||||
/* ─── 流水筛选条 ─── */
|
||||
.filter-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
|
||||
.filter-bar select, .filter-bar input { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 6px 10px; font-size: 12.5px; font-family: inherit; color: var(--accent-black); }
|
||||
.filter-bar select { padding-right: 24px; }
|
||||
.filter-bar .spacer { flex: 1; }
|
||||
.filter-bar .ct { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
|
||||
|
||||
/* 充值 modal */
|
||||
.topup-modal { width: min(460px, 92vw); }
|
||||
.topup-modal .topup-qr { aspect-ratio: 1; max-width: 220px; margin: 4px auto 12px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; position: relative; overflow: hidden; }
|
||||
.topup-modal .topup-qr::before {
|
||||
content: ''; position: absolute; inset: 18px;
|
||||
background-image: linear-gradient(45deg, var(--accent-black) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, var(--accent-black) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, var(--accent-black) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, var(--accent-black) 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
|
||||
opacity: .14;
|
||||
}
|
||||
.topup-modal .topup-qr .center { position: relative; z-index: 1; background: var(--surface); padding: 10px 12px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-56); letter-spacing: .02em; text-align: center; }
|
||||
.topup-modal .topup-info { text-align: center; font-size: 13px; color: var(--black-alpha-56); margin-bottom: 6px; }
|
||||
.topup-modal .topup-amt { text-align: center; font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--heat); margin-bottom: 6px; }
|
||||
.topup-modal .topup-note { text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; margin-bottom: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page">
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>消费</h1>
|
||||
<div class="sub"><span class="mono">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部:余额 banner(左)+ 快速充值(右)左右布局 -->
|
||||
<div class="top-grid">
|
||||
<!-- 左:余额 banner -->
|
||||
<div class="balance-banner">
|
||||
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
|
||||
<div class="balance-hero">
|
||||
<div class="lbl">团队余额</div>
|
||||
<div class="v">¥327.40</div>
|
||||
<div class="meta">// 充值累加 · 不重置</div>
|
||||
</div>
|
||||
<div class="balance-sub">
|
||||
<div class="col">
|
||||
<div class="lbl">本月限额</div>
|
||||
<div class="v">¥3,000.00</div>
|
||||
<div class="meta">// 按自然月重置</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="lbl">当月已用</div>
|
||||
<div class="v">¥162.60</div>
|
||||
<div class="meta">// 占比 5.4% · 健康</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="balance-foot">
|
||||
<div class="balance-meter" aria-label="本月额度使用率 5.4%"><span></span></div>
|
||||
<div class="balance-foot-meta">
|
||||
<span>团队月剩余 ¥2,837.40</span>
|
||||
<span>使用率 5.4%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右:快速充值 -->
|
||||
<div class="pane topup-pane">
|
||||
<div class="topup-head">
|
||||
<div>
|
||||
<h3>快速充值</h3>
|
||||
<div class="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
|
||||
</div>
|
||||
<div class="topup-selected" id="topup-selected-label">已选 ¥500</div>
|
||||
</div>
|
||||
<div class="recharge-row">
|
||||
<div class="recharge-card" data-amt="100" role="button" tabindex="0" aria-pressed="false"><div class="amt">¥100</div><div class="gift">无赠送</div></div>
|
||||
<div class="recharge-card selected" data-amt="500" role="button" tabindex="0" aria-pressed="true"><span class="ribbon">推荐</span><div class="amt">¥500</div><div class="gift bonus">+ ¥30 赠送</div></div>
|
||||
<div class="recharge-card" data-amt="1000" role="button" tabindex="0" aria-pressed="false"><div class="amt">¥1000</div><div class="gift bonus">+ ¥80 赠送</div></div>
|
||||
<div class="recharge-card" data-amt="3000" role="button" tabindex="0" aria-pressed="false"><div class="amt">¥3000</div><div class="gift bonus">+ ¥300 赠送</div></div>
|
||||
</div>
|
||||
<div class="pay-row">
|
||||
<div class="pay-title">自定义金额</div>
|
||||
<input class="input" id="custom-amt" placeholder="最低 ¥50,可输入任意金额">
|
||||
<div class="pay-btn-row">
|
||||
<button class="btn pay-method-btn pay-wechat" onclick="openTopup('wechat')" aria-label="微信支付">
|
||||
<span class="pay-logo" aria-hidden="true"><img src="assets/pay-wechat.png" alt=""></span>
|
||||
微信支付
|
||||
</button>
|
||||
<button class="btn pay-method-btn pay-alipay" onclick="openTopup('alipay')" aria-label="支付宝">
|
||||
<span class="pay-logo" aria-hidden="true"><img src="assets/pay-alipay.png" alt=""></span>
|
||||
支付宝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab strip -->
|
||||
<div class="billing-tabs" role="tablist">
|
||||
<button class="tab active" type="button" data-tab="overview" role="tab" aria-selected="true" aria-controls="panel-overview">总览</button>
|
||||
<button class="tab" type="button" data-tab="by-project" role="tab" aria-selected="false" aria-controls="panel-by-project">项目 <span class="count">8</span></button>
|
||||
<button class="tab" type="button" data-tab="by-member" role="tab" aria-selected="false" aria-controls="panel-by-member">成员 <span class="count">5</span></button>
|
||||
<button class="tab" type="button" data-tab="bills" role="tab" aria-selected="false" aria-controls="panel-bills">账单流水 <span class="count">30天</span></button>
|
||||
</div>
|
||||
|
||||
<!-- ===== Tab 1: 总览 ===== -->
|
||||
<div class="tab-panel active" id="panel-overview">
|
||||
<div class="overview-grid">
|
||||
<div class="pane trend-pane">
|
||||
<div class="trend-head">
|
||||
<h3>消费趋势</h3>
|
||||
<span class="sub" id="trend-sub">// 近 14 天 · 单位 ¥</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="chip active" data-grain="day">日</button>
|
||||
<button class="chip" data-grain="week">周</button>
|
||||
<button class="chip" data-grain="month">月</button>
|
||||
</div>
|
||||
<div class="trend-chart">
|
||||
<div class="bars" id="trend-bars">
|
||||
<!-- JS 注入 14 根柱 -->
|
||||
</div>
|
||||
<div class="x-axis" id="trend-xaxis">
|
||||
<!-- JS 注入日期 -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-foot">
|
||||
<div class="item"><span class="k">14 天合计</span><span class="v" id="trend-sum">¥0.00</span></div>
|
||||
<div class="item"><span class="k">日均</span><span class="v" id="trend-avg">¥0.00</span></div>
|
||||
<div class="item"><span class="k">峰值</span><span class="v warn" id="trend-peak">¥0.00</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane stage-pane">
|
||||
<h3>本月按阶段分布</h3>
|
||||
<div class="desc">// PRD §5.3.5 扣费规则 · 仅确认后扣</div>
|
||||
<div class="usage-line"><span class="k">视频片段(Seedance)</span><span class="v">¥98.40</span></div>
|
||||
<div class="usage-bar"><span style="width:60%; background:var(--heat);"></span></div>
|
||||
<div class="usage-line"><span class="k">故事板(image-2)</span><span class="v">¥36.00</span></div>
|
||||
<div class="usage-bar"><span style="width:22%; background:var(--accent-forest);"></span></div>
|
||||
<div class="usage-line"><span class="k">基础资产</span><span class="v">¥21.00</span></div>
|
||||
<div class="usage-bar"><span style="width:13%; background:var(--black-alpha-56);"></span></div>
|
||||
<div class="usage-line"><span class="k">脚本 LLM</span><span class="v">¥7.20</span></div>
|
||||
<div class="usage-bar"><span style="width:5%; background:var(--black-alpha-32);"></span></div>
|
||||
<div class="total"><span>合计</span><span class="v">¥162.60</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane rule-pane" style="margin-top: 16px;">
|
||||
<h3>扣费 + 四层额度预检规则</h3>
|
||||
<div class="desc">// PRD §5.3.5 + §10.3 · 对接团队请以此页为准</div>
|
||||
<div class="rule-list">
|
||||
<strong>① 失败不扣</strong>:模型超时 / 内容审核拦截 / 生成异常一律不扣费。<br>
|
||||
<strong>② 用户重跑不扣首次</strong>:第一次重跑保留原扣费,第二次起按次结算。<br>
|
||||
<strong>③ 仅在你点击 <span class="mono-acc">[ 确认通过 ]</span> 时入账</strong>。<br>
|
||||
<strong>④ 导出不再扣费</strong>,所有 token 已在过程中结算。
|
||||
</div>
|
||||
<div class="quota-rules">
|
||||
<div class="qr-head">// 任务确认前 · 四层额度预检(任一不通过即拦截)</div>
|
||||
<div class="step"><span class="num">1</span><span><strong>个人日剩余</strong> ≥ 任务预估 × <span class="formula">1.2</span></span></div>
|
||||
<div class="step"><span class="num">2</span><span><strong>个人月剩余</strong> ≥ 同上</span></div>
|
||||
<div class="step"><span class="num">3</span><span><strong>团队月剩余</strong> ≥ 同上</span></div>
|
||||
<div class="step"><span class="num">4</span><span><strong>团队总余额</strong> ≥ 同上</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Tab 2: 按项目 ===== -->
|
||||
<div class="tab-panel" id="panel-by-project">
|
||||
<div class="filter-bar">
|
||||
<select id="proj-f-status">
|
||||
<option value="all">全部状态</option>
|
||||
<option value="wip">进行中</option>
|
||||
<option value="ok">已完成</option>
|
||||
<option value="fail">失败 · 待重跑</option>
|
||||
</select>
|
||||
<select id="proj-f-range">
|
||||
<option value="month">本月</option>
|
||||
<option value="30d">近 30 天</option>
|
||||
<option value="90d">近 90 天</option>
|
||||
</select>
|
||||
<button class="chip" id="proj-f-reset" type="button" style="height:30px;font-size:11px;display:none;">清除筛选</button>
|
||||
<span class="spacer"></span>
|
||||
<span class="ct" id="proj-count">共 <b style="color:var(--accent-black);">0</b> 个项目 · 消耗 ¥0.00</span>
|
||||
</div>
|
||||
<table class="billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目</th>
|
||||
<th>商品</th>
|
||||
<th>所属成员</th>
|
||||
<th>当前阶段</th>
|
||||
<th>状态</th>
|
||||
<th style="text-align:right;">消耗</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="proj-body">
|
||||
<!-- JS 注入 -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="proj-empty" class="empty-state" style="display:none; padding:48px 0; text-align:center; font-family:var(--font-mono); font-size:12px; color:var(--black-alpha-48); letter-spacing:.02em;">
|
||||
// 当前筛选条件下没有项目 · 试试调整状态 / 时间范围
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Tab 3: 按成员 ===== -->
|
||||
<div class="tab-panel" id="panel-by-member">
|
||||
<div class="filter-bar">
|
||||
<select id="mem-f-role">
|
||||
<option value="all">全部角色</option>
|
||||
<option value="super">超管</option>
|
||||
<option value="admin">团管</option>
|
||||
<option value="member">成员</option>
|
||||
</select>
|
||||
<select id="mem-f-range">
|
||||
<option value="month">本月</option>
|
||||
<option value="30d">近 30 天</option>
|
||||
<option value="90d">近 90 天</option>
|
||||
</select>
|
||||
<button class="chip" id="mem-f-reset" type="button" style="height:30px;font-size:11px;display:none;">清除筛选</button>
|
||||
<span class="spacer"></span>
|
||||
<span class="ct" id="mem-count">共 <b style="color:var(--accent-black);">0</b> 人 · 合计 ¥0.00</span>
|
||||
</div>
|
||||
<table class="billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>成员</th>
|
||||
<th>角色</th>
|
||||
<th>已完成项目</th>
|
||||
<th>已用 / 月度额度</th>
|
||||
<th>最近活跃</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="member-body">
|
||||
<!-- JS 注入 -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="mem-empty" class="empty-state" style="display:none; padding:48px 0; text-align:center; font-family:var(--font-mono); font-size:12px; color:var(--black-alpha-48); letter-spacing:.02em;">
|
||||
// 当前筛选条件下没有成员 · 试试调整角色 / 时间范围
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Tab 4: 账单流水 ===== -->
|
||||
<div class="tab-panel" id="panel-bills">
|
||||
<div class="filter-bar">
|
||||
<select id="bills-f-stage">
|
||||
<option value="all">全部阶段</option>
|
||||
<option value="视频片段">视频片段</option>
|
||||
<option value="故事板">故事板</option>
|
||||
<option value="基础资产">基础资产</option>
|
||||
<option value="脚本 LLM">脚本 LLM</option>
|
||||
<option value="充值">充值</option>
|
||||
<option value="导出">导出</option>
|
||||
</select>
|
||||
<select id="bills-f-member">
|
||||
<option value="all">全部成员</option>
|
||||
<option value="李">小李</option>
|
||||
<option value="张">张运营</option>
|
||||
<option value="王">王小姐</option>
|
||||
<option value="陈">陈策划</option>
|
||||
</select>
|
||||
<select id="bills-f-range">
|
||||
<option value="30d">近 30 天</option>
|
||||
<option value="7d">近 7 天</option>
|
||||
<option value="month">本月</option>
|
||||
<option value="all">全部</option>
|
||||
</select>
|
||||
<button class="chip" id="bills-f-reset" type="button" style="height:30px;font-size:11px;display:none;">清除筛选</button>
|
||||
<span class="spacer"></span>
|
||||
<span class="ct">共 <b id="bills-count" style="color:var(--accent-black);">0</b> 条</span>
|
||||
</div>
|
||||
<table class="billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>项目 / 类型</th>
|
||||
<th>详情</th>
|
||||
<th>成员</th>
|
||||
<th>状态</th>
|
||||
<th style="text-align:right;">金额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bills-body">
|
||||
<!-- JS 注入 -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="bills-empty" class="empty-state" style="display:none; padding:48px 0; text-align:center; font-family:var(--font-mono); font-size:12px; color:var(--black-alpha-48); letter-spacing:.02em;">
|
||||
// 当前筛选条件下没有账单 · 试试调整阶段 / 成员 / 时间范围
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 充值 modal -->
|
||||
<div class="modal-bg" id="topup-bg" onclick="if(event.target===this)Shell.closeModal('topup-bg')">
|
||||
<div class="modal topup-modal">
|
||||
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
|
||||
<div class="modal-h">
|
||||
<div class="ic-m">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4"/></svg>
|
||||
</div>
|
||||
<div class="ti">扫码支付<span id="topup-channel-label">// 微信支付</span></div>
|
||||
</div>
|
||||
<div class="modal-b">
|
||||
<div class="topup-info">支付金额</div>
|
||||
<div class="topup-amt" id="topup-amt">¥500.00</div>
|
||||
<div class="topup-note" id="topup-bonus">// 含 ¥30 赠送 · 实到账 ¥530</div>
|
||||
<div class="topup-qr">
|
||||
<div class="center" id="topup-channel-name">微信扫码<br><span style="color:var(--black-alpha-32);">/topup/wx/TX...</span></div>
|
||||
</div>
|
||||
<div style="text-align:center; font-family:var(--font-mono); font-size:11px; color:var(--black-alpha-48); letter-spacing:.02em;">// 5 分钟内有效 · 到账后自动关闭</div>
|
||||
</div>
|
||||
<div class="modal-f">
|
||||
<button class="btn" type="button" onclick="Shell.closeModal('topup-bg')">取消</button>
|
||||
<button class="btn btn-primary" type="button" onclick="topupDone()">已完成支付</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="assets/icons.js?v=2026052608"></script>
|
||||
<script src="assets/shell.js?v=2026060101"></script>
|
||||
<script>
|
||||
Shell.render({ active: 'account', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '消费' }] });
|
||||
|
||||
/* ============================================================
|
||||
Mock 数据
|
||||
============================================================ */
|
||||
const TREND_DAYS = [
|
||||
{ d: '05.08', v: 12.40 }, { d: '05.09', v: 18.80 }, { d: '05.10', v: 6.20 }, { d: '05.11', v: 0 },
|
||||
{ d: '05.12', v: 4.50 }, { d: '05.13', v: 22.10 }, { d: '05.14', v: 14.60 }, { d: '05.15', v: 9.30 },
|
||||
{ d: '05.16', v: 28.40 }, { d: '05.17', v: 13.80 }, { d: '05.18', v: 8.20 }, { d: '05.19', v: 11.50 },
|
||||
{ d: '05.20', v: 19.40 }, { d: '05.21', v: 7.80 },
|
||||
];
|
||||
|
||||
// 8 周(以「W18~W21 + 前 4 周」做演示),按 7 天合计估算
|
||||
const TREND_WEEKS = [
|
||||
{ d: 'W14', v: 64.20 }, { d: 'W15', v: 92.80 }, { d: 'W16', v: 118.40 }, { d: 'W17', v: 78.60 },
|
||||
{ d: 'W18', v: 102.30 }, { d: 'W19', v: 138.20 }, { d: 'W20', v: 86.40 }, { d: 'W21', v: 27.20 },
|
||||
];
|
||||
// 6 个月 demo,自然月聚合估算 + 当月真实合计 162.60
|
||||
const TREND_MONTHS = [
|
||||
{ d: '2025-12', v: 384.20 }, { d: '2026-01', v: 528.40 }, { d: '2026-02', v: 296.80 },
|
||||
{ d: '2026-03', v: 412.00 }, { d: '2026-04', v: 348.60 }, { d: '2026-05', v: 162.60 },
|
||||
];
|
||||
const TREND_DATA = { day: TREND_DAYS, week: TREND_WEEKS, month: TREND_MONTHS };
|
||||
const TREND_LABEL = {
|
||||
day: { sub: '// 近 14 天 · 单位 ¥', sumLbl: '14 天合计', avgLbl: '日均' },
|
||||
week: { sub: '// 近 8 周 · 单位 ¥', sumLbl: '8 周合计', avgLbl: '周均' },
|
||||
month: { sub: '// 近 6 月 · 单位 ¥', sumLbl: '6 月合计', avgLbl: '月均' },
|
||||
};
|
||||
let _trendGrain = 'day';
|
||||
|
||||
const PROJECTS_BILL = [
|
||||
{ name: '补水面膜 · v3', product: '透真补水面膜', owner: '李', role: 'super', stage: 'Stage 3 故事板', stagePct: 60, status: 'wip', statusLabel: '进行中', amount: 48.20 },
|
||||
{ name: '透真防晒 · 通勤对比', product: '透真防晒', owner: '李', role: 'super', stage: 'Stage 5 导出', stagePct: 100, status: 'ok', statusLabel: '已完成', amount: 32.60 },
|
||||
{ name: '蓝牙耳机 · 开箱测评', product: 'Pro 4 蓝牙耳机', owner: '张', role: 'admin', stage: 'Stage 4 视频', stagePct: 80, status: 'wip', statusLabel: '进行中', amount: 28.40 },
|
||||
{ name: '速食面 · 加班场景', product: '熊猫速食面', owner: '陈', role: 'member', stage: 'Stage 5 导出', stagePct: 100, status: 'ok', statusLabel: '已完成', amount: 12.80 },
|
||||
{ name: '春日新品 · 立体口红', product: '凝彩立体口红', owner: '李', role: 'super', stage: 'Stage 2 资产', stagePct: 40, status: 'wip', statusLabel: '进行中', amount: 18.30 },
|
||||
{ name: '咖啡冻干 · 早八', product: '冷萃咖啡冻干', owner: '王', role: 'member', stage: 'Stage 3 故事板', stagePct: 60, status: 'fail', statusLabel: '失败 · 待重跑', amount: 0 },
|
||||
{ name: '瑜伽裤 · 通勤穿搭', product: '透气速干瑜伽裤', owner: '王', role: 'member', stage: 'Stage 5 导出', stagePct: 100, status: 'ok', statusLabel: '已完成', amount: 14.80 },
|
||||
{ name: '保温杯 · 户外随行', product: '316 保温杯', owner: '张', role: 'admin', stage: 'Stage 1 脚本', stagePct: 20, status: 'wip', statusLabel: '进行中', amount: 7.50 },
|
||||
];
|
||||
|
||||
const MEMBERS_BILL = [
|
||||
{ av: '李', name: '小李', role: 'super', projectsDone: 14, used: 162.60, monthly: 10000, lastActive: '15 分钟前' },
|
||||
{ av: '张', name: '张运营', role: 'admin', projectsDone: 8, used: 98.40, monthly: 6000, lastActive: '10 分钟前' },
|
||||
{ av: '王', name: '王小姐', role: 'member', projectsDone: 4, used: 45.20, monthly: 2000, lastActive: '28 分钟前' },
|
||||
{ av: '陈', name: '陈策划', role: 'member', projectsDone: 1, used: 12.80, monthly: 2000, lastActive: '4 小时前' },
|
||||
{ av: '林', name: '林新人', role: 'member', projectsDone: 0, used: 0.00, monthly: 2000, lastActive: '尚未激活' },
|
||||
];
|
||||
|
||||
const BILLS = [
|
||||
{ ts: '05.21 14:32', proj: '补水面膜 · v3', type: '视频片段', detail: 'Seedance · 场 1 · 1 镜', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -0.45 },
|
||||
{ ts: '05.21 14:08', proj: '补水面膜 · v3', type: '视频片段', detail: 'Seedance · 场 2 · 1 镜', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.45 },
|
||||
{ ts: '05.20 21:42', proj: '蓝牙耳机 · 开箱测评', type: '视频片段', detail: 'Seedance · 场 3 · 6 镜', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -1.20 },
|
||||
{ ts: '05.20 18:21', proj: '透真防晒 · 通勤对比', type: '视频片段', detail: 'Seedance · 整段 · 6 镜', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -1.20 },
|
||||
{ ts: '05.20 16:00', proj: '蓝牙耳机 · 开箱测评', type: '故事板', detail: 'image-2 · 整张重跑 · 场 2', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.45 },
|
||||
{ ts: '05.20 11:02', proj: '充值', type: '充值', detail: '微信支付 · TX2024052011021Z', who: '李', role: 'super', status: 'ok', statusLabel: '到账', amount: 500.00 },
|
||||
{ ts: '05.19 18:08', proj: '速食面 · 加班场景', type: '故事板', detail: 'image-2 · 场 1', who: '陈', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.30 },
|
||||
{ ts: '05.19 16:08', proj: '补水面膜 · v3', type: '故事板', detail: 'image-2 · 场 1', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -0.45 },
|
||||
{ ts: '05.19 14:02', proj: '补水面膜 · v3', type: '脚本 LLM', detail: '2.4k tokens · AI 全生', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -0.04 },
|
||||
{ ts: '05.19 13:38', proj: '补水面膜 · v3', type: '基础资产', detail: 'image-2 · 5 张', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -1.05 },
|
||||
{ ts: '05.19 11:18', proj: '补水面膜 · v3', type: '故事板', detail: 'image-2 · 场 2', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.45 },
|
||||
{ ts: '05.19 11:12', proj: '速食面 · 加班场景', type: '基础资产', detail: 'image-2 · 2 张', who: '陈', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.42 },
|
||||
{ ts: '05.18 15:42', proj: '咖啡冻干 · 早八', type: '故事板', detail: 'image-2 · 场 3', who: '王', role: 'member', status: 'fail', statusLabel: '失败不扣', amount: 0 },
|
||||
{ ts: '05.18 09:42', proj: '蓝牙耳机 · 开箱测评', type: '基础资产', detail: 'image-2 · 4 张', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.84 },
|
||||
{ ts: '05.17 14:38', proj: '蓝牙耳机 · 开箱测评', type: '脚本 LLM', detail: '1.8k tokens · 自带粘贴', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.03 },
|
||||
{ ts: '05.17 10:30', proj: '瑜伽裤 · 通勤穿搭', type: '导出', detail: '1080×1920 · 9:16 · 38s', who: '王', role: 'member', status: 'ok', statusLabel: '免费', amount: 0 },
|
||||
{ ts: '05.17 10:08', proj: '瑜伽裤 · 通勤穿搭', type: '视频片段', detail: 'Seedance · 整段 · 5 镜', who: '王', role: 'member', status: 'ok', statusLabel: '通过', amount: -3.20 },
|
||||
{ ts: '05.16 19:38', proj: '透真防晒 · 通勤对比', type: '视频片段', detail: 'Seedance · 4 镜', who: '王', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.80 },
|
||||
{ ts: '05.16 11:42', proj: '透真防晒 · 通勤对比', type: '故事板', detail: 'image-2 · 场 2', who: '王', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.45 },
|
||||
{ ts: '05.15 16:08', proj: '透真防晒 · 通勤对比', type: '基础资产', detail: 'image-2 · 3 张', who: '王', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.63 },
|
||||
];
|
||||
|
||||
const ROLE_META = {
|
||||
super: { label: '超管', cls: 'role-super' },
|
||||
admin: { label: '团管', cls: 'role-admin' },
|
||||
member: { label: '成员', cls: 'role-member' },
|
||||
};
|
||||
|
||||
function fmtMoney(n) { return '¥' + Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
|
||||
function amtStr(n) {
|
||||
if (n === 0) return '¥0.00';
|
||||
return (n > 0 ? '+' : '-') + fmtMoney(n);
|
||||
}
|
||||
function amtCls(n) { return n > 0 ? 'pos' : (n === 0 ? 'zero' : 'neg'); }
|
||||
|
||||
/* ─── 趋势柱 ─── */
|
||||
function renderTrend() {
|
||||
const data = TREND_DATA[_trendGrain];
|
||||
const meta = TREND_LABEL[_trendGrain];
|
||||
const bars = document.getElementById('trend-bars');
|
||||
const xax = document.getElementById('trend-xaxis');
|
||||
const max = Math.max(...data.map(d => d.v));
|
||||
// bars 的 grid-template-columns 默认是 repeat(14, 1fr),需要按数据长度动态调整
|
||||
bars.style.gridTemplateColumns = `repeat(${data.length}, 1fr)`;
|
||||
xax.style.gridTemplateColumns = `repeat(${data.length}, 1fr)`;
|
||||
bars.innerHTML = data.map(d => {
|
||||
const h = max > 0 ? (d.v / max * 100) : 0;
|
||||
const isPeak = d.v === max;
|
||||
return `<div class="bar${isPeak ? ' peak' : ''}" title="${d.d} · ${fmtMoney(d.v)}"><span style="height: ${h.toFixed(1)}%"></span></div>`;
|
||||
}).join('');
|
||||
// x 轴标签:日 = 隔列显示 MM·DD 的 DD 部分;周/月 = 全显示
|
||||
if (_trendGrain === 'day') {
|
||||
xax.innerHTML = data.map((d, i) => i % 2 === 0 ? `<span>${d.d.slice(3)}</span>` : '<span></span>').join('');
|
||||
} else if (_trendGrain === 'week') {
|
||||
xax.innerHTML = data.map(d => `<span>${d.d}</span>`).join('');
|
||||
} else {
|
||||
xax.innerHTML = data.map(d => `<span>${d.d.slice(5)}</span>`).join('');
|
||||
}
|
||||
const sum = data.reduce((s, d) => s + d.v, 0);
|
||||
document.getElementById('trend-sub').textContent = meta.sub;
|
||||
document.getElementById('trend-sum').textContent = fmtMoney(sum);
|
||||
document.getElementById('trend-avg').textContent = fmtMoney(sum / data.length);
|
||||
document.getElementById('trend-peak').textContent = fmtMoney(max);
|
||||
// 旁标 label
|
||||
document.querySelectorAll('.trend-foot .item').forEach((it, idx) => {
|
||||
const k = it.querySelector('.k');
|
||||
if (idx === 0 && k) k.textContent = meta.sumLbl;
|
||||
else if (idx === 1 && k) k.textContent = meta.avgLbl;
|
||||
});
|
||||
}
|
||||
|
||||
/* ─── 趋势 日/周/月 切换 ─── */
|
||||
document.querySelectorAll('.trend-head .chip[data-grain]').forEach(chip => {
|
||||
chip.addEventListener('click', () => {
|
||||
document.querySelectorAll('.trend-head .chip[data-grain]').forEach(c => c.classList.remove('active'));
|
||||
chip.classList.add('active');
|
||||
_trendGrain = chip.dataset.grain;
|
||||
renderTrend();
|
||||
});
|
||||
});
|
||||
|
||||
/* ─── 按项目 表格 + 筛选 ─── */
|
||||
const PROJ_FILTER = { status: 'all', range: 'month' };
|
||||
const RANGE_MULT = { 'month': 1, '30d': 1, '90d': 2.6 }; // 演示用,30/90 天放大倍率(月 = 当月真实)
|
||||
const RANGE_LBL = { 'month': '当月', '30d': '近 30 天', '90d': '近 90 天' };
|
||||
function renderProjects() {
|
||||
const tb = document.getElementById('proj-body');
|
||||
const mult = RANGE_MULT[PROJ_FILTER.range] || 1;
|
||||
const list = PROJECTS_BILL.filter(p => PROJ_FILTER.status === 'all' || p.status === PROJ_FILTER.status);
|
||||
tb.innerHTML = list.map(p => {
|
||||
const r = ROLE_META[p.role];
|
||||
const amt = p.amount * mult;
|
||||
return `
|
||||
<tr>
|
||||
<td><a href="pipeline.html?product=${encodeURIComponent(p.product)}" style="color:var(--accent-black);text-decoration:none;font-weight:500;">${p.name}</a></td>
|
||||
<td class="muted">${p.product}</td>
|
||||
<td><span class="who"><span class="av">${p.owner}</span><span class="role-pill ${r.cls}"><span class="dot"></span>${r.label}</span></span></td>
|
||||
<td><span class="muted">${p.stage}</span><span class="progress-mini"><span style="width:${p.stagePct}%"></span></span></td>
|
||||
<td><span class="status-tag ${p.status}">${p.statusLabel}</span></td>
|
||||
<td class="${amtCls(-amt)}">${amt === 0 ? '¥0.00' : '-' + fmtMoney(amt)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
const sum = list.reduce((s, p) => s + p.amount * mult, 0);
|
||||
document.getElementById('proj-count').innerHTML =
|
||||
`共 <b style="color:var(--accent-black);">${list.length}</b> 个项目 · ${RANGE_LBL[PROJ_FILTER.range]}消耗 ${fmtMoney(sum)}`;
|
||||
const empty = document.getElementById('proj-empty');
|
||||
const tbl = document.querySelector('#panel-by-project .billing-table');
|
||||
empty.style.display = list.length === 0 ? '' : 'none';
|
||||
tbl.style.display = list.length === 0 ? 'none' : '';
|
||||
const isDef = PROJ_FILTER.status === 'all' && PROJ_FILTER.range === 'month';
|
||||
document.getElementById('proj-f-reset').style.display = isDef ? 'none' : '';
|
||||
}
|
||||
|
||||
['status', 'range'].forEach(key => {
|
||||
const sel = document.getElementById('proj-f-' + key);
|
||||
sel.addEventListener('change', () => { PROJ_FILTER[key] = sel.value; renderProjects(); });
|
||||
});
|
||||
document.getElementById('proj-f-reset').addEventListener('click', () => {
|
||||
PROJ_FILTER.status = 'all'; PROJ_FILTER.range = 'month';
|
||||
document.getElementById('proj-f-status').value = 'all';
|
||||
document.getElementById('proj-f-range').value = 'month';
|
||||
renderProjects();
|
||||
});
|
||||
|
||||
/* ─── 按成员 表格 + 筛选 ─── */
|
||||
const MEM_FILTER = { role: 'all', range: 'month' };
|
||||
function renderMembers() {
|
||||
const tb = document.getElementById('member-body');
|
||||
const mult = RANGE_MULT[MEM_FILTER.range] || 1;
|
||||
const list = MEMBERS_BILL.filter(m => MEM_FILTER.role === 'all' || m.role === MEM_FILTER.role);
|
||||
tb.innerHTML = list.map(m => {
|
||||
const r = ROLE_META[m.role];
|
||||
const used = m.used * mult;
|
||||
const pct = m.monthly > 0 ? (used / m.monthly * 100) : 0;
|
||||
return `
|
||||
<tr style="cursor:pointer;">
|
||||
<td><span class="who"><span class="av">${m.av}</span><strong style="font-weight:500;">${m.name}</strong></span></td>
|
||||
<td><span class="role-pill ${r.cls}"><span class="dot"></span>${r.label}</span></td>
|
||||
<td>${m.projectsDone}</td>
|
||||
<td>
|
||||
<strong style="font-variant-numeric:tabular-nums;font-weight:600;">${fmtMoney(used)}</strong>
|
||||
<span class="muted"> / ${fmtMoney(m.monthly)} · ${pct.toFixed(1)}%</span>
|
||||
<span class="progress-mini"><span style="width:${Math.min(100, pct)}%; background:${pct >= 85 ? '#B45309' : 'var(--heat)'};"></span></span>
|
||||
</td>
|
||||
<td><span class="ts">${m.lastActive}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
const sum = list.reduce((s, m) => s + m.used * mult, 0);
|
||||
document.getElementById('mem-count').innerHTML =
|
||||
`共 <b style="color:var(--accent-black);">${list.length}</b> 人 · ${RANGE_LBL[MEM_FILTER.range]}合计 ${fmtMoney(sum)}`;
|
||||
const empty = document.getElementById('mem-empty');
|
||||
const tbl = document.querySelector('#panel-by-member .billing-table');
|
||||
empty.style.display = list.length === 0 ? '' : 'none';
|
||||
tbl.style.display = list.length === 0 ? 'none' : '';
|
||||
const isDef = MEM_FILTER.role === 'all' && MEM_FILTER.range === 'month';
|
||||
document.getElementById('mem-f-reset').style.display = isDef ? 'none' : '';
|
||||
}
|
||||
|
||||
['role', 'range'].forEach(key => {
|
||||
const sel = document.getElementById('mem-f-' + key);
|
||||
sel.addEventListener('change', () => { MEM_FILTER[key] = sel.value; renderMembers(); });
|
||||
});
|
||||
document.getElementById('mem-f-reset').addEventListener('click', () => {
|
||||
MEM_FILTER.role = 'all'; MEM_FILTER.range = 'month';
|
||||
document.getElementById('mem-f-role').value = 'all';
|
||||
document.getElementById('mem-f-range').value = 'month';
|
||||
renderMembers();
|
||||
});
|
||||
|
||||
/* ─── 账单流水:筛选 + 表格渲染 ─── */
|
||||
const BILLS_FILTER = { stage: 'all', member: 'all', range: '30d' };
|
||||
// demo "今天" = 05.21,所有 ts 都是 MM.DD 格式,这里基于这一假定算时间区间
|
||||
const TODAY_MD = '05.21';
|
||||
function mdToDay(md) {
|
||||
// 把 "MM.DD" 当成 2026 年的日子换算成 1970 epoch ms,只用来比较先后
|
||||
const [m, d] = md.split('.').map(Number);
|
||||
return Date.UTC(2026, (m || 1) - 1, d || 1);
|
||||
}
|
||||
const TODAY_MS = mdToDay(TODAY_MD);
|
||||
|
||||
function passRange(ts, range) {
|
||||
if (range === 'all') return true;
|
||||
const md = ts.slice(0, 5); // "05.21"
|
||||
if (range === 'month') return md.startsWith(TODAY_MD.slice(0, 3));
|
||||
const diff = TODAY_MS - mdToDay(md);
|
||||
if (range === '7d') return diff <= 6 * 86400000;
|
||||
if (range === '30d') return diff <= 29 * 86400000;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getFilteredBills() {
|
||||
return BILLS.filter(b => {
|
||||
if (BILLS_FILTER.stage !== 'all' && b.type !== BILLS_FILTER.stage) return false;
|
||||
if (BILLS_FILTER.member !== 'all' && b.who !== BILLS_FILTER.member) return false;
|
||||
if (!passRange(b.ts, BILLS_FILTER.range)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderBills() {
|
||||
const tb = document.getElementById('bills-body');
|
||||
const list = getFilteredBills();
|
||||
tb.innerHTML = list.map(b => {
|
||||
const r = ROLE_META[b.role];
|
||||
return `
|
||||
<tr>
|
||||
<td class="ts">${b.ts}</td>
|
||||
<td><strong style="font-weight:500;">${b.proj}</strong><br><span class="muted">${b.type}</span></td>
|
||||
<td class="muted">${b.detail}</td>
|
||||
<td><span class="who"><span class="av">${b.who}</span></span></td>
|
||||
<td><span class="status-tag ${b.status}">${b.statusLabel}</span></td>
|
||||
<td class="${amtCls(b.amount)}">${b.amount === 0 ? '¥0.00' : (b.amount > 0 ? '+' + fmtMoney(b.amount) : '-' + fmtMoney(b.amount))}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
document.getElementById('bills-count').textContent = list.length;
|
||||
const empty = document.getElementById('bills-empty');
|
||||
const table = document.querySelector('#panel-bills .billing-table');
|
||||
if (list.length === 0) {
|
||||
empty.style.display = '';
|
||||
table.style.display = 'none';
|
||||
} else {
|
||||
empty.style.display = 'none';
|
||||
table.style.display = '';
|
||||
}
|
||||
// 「清除筛选」按钮:任何一个非默认就显示
|
||||
const isDefault = BILLS_FILTER.stage === 'all' && BILLS_FILTER.member === 'all' && BILLS_FILTER.range === '30d';
|
||||
document.getElementById('bills-f-reset').style.display = isDefault ? 'none' : '';
|
||||
}
|
||||
|
||||
/* ─── 筛选绑定 ─── */
|
||||
['stage', 'member', 'range'].forEach(key => {
|
||||
const sel = document.getElementById('bills-f-' + key);
|
||||
sel.addEventListener('change', () => {
|
||||
BILLS_FILTER[key] = sel.value;
|
||||
renderBills();
|
||||
});
|
||||
});
|
||||
document.getElementById('bills-f-reset').addEventListener('click', () => {
|
||||
BILLS_FILTER.stage = 'all'; BILLS_FILTER.member = 'all'; BILLS_FILTER.range = '30d';
|
||||
document.getElementById('bills-f-stage').value = 'all';
|
||||
document.getElementById('bills-f-member').value = 'all';
|
||||
document.getElementById('bills-f-range').value = '30d';
|
||||
renderBills();
|
||||
});
|
||||
|
||||
/* ─── Tab 切换 ─── */
|
||||
document.querySelectorAll('.billing-tabs .tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const targetId = 'panel-' + tab.dataset.tab;
|
||||
document.querySelectorAll('.billing-tabs .tab').forEach(t => {
|
||||
const active = t === tab;
|
||||
t.classList.toggle('active', active);
|
||||
t.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
document.querySelectorAll('.tab-panel').forEach(p => {
|
||||
p.classList.toggle('active', p.id === targetId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* ─── 快速充值卡选择 ─── */
|
||||
function setRechargeCard(card) {
|
||||
document.querySelectorAll('.recharge-card').forEach(c => {
|
||||
const active = c === card;
|
||||
c.classList.toggle('selected', active);
|
||||
c.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
});
|
||||
const label = document.getElementById('topup-selected-label');
|
||||
if (label && card) label.textContent = '已选 ¥' + Number(card.dataset.amt).toLocaleString('zh-CN');
|
||||
}
|
||||
document.querySelectorAll('.recharge-card').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
setRechargeCard(card);
|
||||
document.getElementById('custom-amt').value = '';
|
||||
});
|
||||
card.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setRechargeCard(card);
|
||||
document.getElementById('custom-amt').value = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
document.getElementById('custom-amt').addEventListener('input', e => {
|
||||
const raw = e.target.value.trim();
|
||||
const label = document.getElementById('topup-selected-label');
|
||||
if (!raw) {
|
||||
const selected = document.querySelector('.recharge-card.selected');
|
||||
if (label && selected) label.textContent = '已选 ¥' + Number(selected.dataset.amt).toLocaleString('zh-CN');
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.recharge-card').forEach(c => {
|
||||
c.classList.remove('selected');
|
||||
c.setAttribute('aria-pressed', 'false');
|
||||
});
|
||||
if (label) label.textContent = Number(raw) >= 50 ? '自定义 ¥' + Number(raw).toLocaleString('zh-CN') : '最低 ¥50';
|
||||
});
|
||||
|
||||
/* ─── 充值 modal ─── */
|
||||
function openTopup(channel) {
|
||||
const selected = document.querySelector('.recharge-card.selected');
|
||||
const customRaw = document.getElementById('custom-amt').value.trim();
|
||||
const custom = Number(customRaw);
|
||||
let amt = 500, bonus = 30;
|
||||
if (custom >= 50) { amt = custom; bonus = 0; }
|
||||
else if (selected) {
|
||||
amt = Number(selected.dataset.amt);
|
||||
const bonusEl = selected.querySelector('.gift.bonus');
|
||||
bonus = bonusEl ? Number(bonusEl.textContent.replace(/\D/g, '')) : 0;
|
||||
}
|
||||
document.getElementById('topup-amt').textContent = fmtMoney(amt);
|
||||
document.getElementById('topup-bonus').textContent = bonus > 0
|
||||
? `// 含 ¥${bonus} 赠送 · 实到账 ¥${(amt + bonus).toFixed(2)}`
|
||||
: '// 无赠送';
|
||||
const isAlipay = channel === 'alipay';
|
||||
document.getElementById('topup-channel-label').textContent = isAlipay ? '// 支付宝' : '// 微信支付';
|
||||
document.getElementById('topup-channel-name').innerHTML = (isAlipay ? '支付宝扫码' : '微信扫码') +
|
||||
'<br><span style="color:var(--black-alpha-32);">/topup/' + (isAlipay ? 'ali' : 'wx') + '/TX' + Date.now() + '</span>';
|
||||
Shell.openModal('topup-bg');
|
||||
}
|
||||
function topupDone() {
|
||||
Shell.closeModal('topup-bg');
|
||||
Shell.toast('充值成功', '余额已更新 · 可开发票');
|
||||
}
|
||||
|
||||
/* ─── 初始化 ─── */
|
||||
renderTrend();
|
||||
renderProjects();
|
||||
renderMembers();
|
||||
renderBills();
|
||||
</script>
|
||||
<script src="assets/api-bridge.js?v=2026060114"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,947 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>图片生成 · Airshelf</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
|
||||
<style>
|
||||
#page-content { padding: 24px 28px 60px; }
|
||||
|
||||
/* ─── 三 Hero 卡片网格(模特上身图 / 平台套图 / 图片创作 · 等比)─── */
|
||||
.factory-hero {
|
||||
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px; margin-bottom: 56px;
|
||||
}
|
||||
@media (max-width: 1400px) { .factory-hero { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 1000px) { .factory-hero { grid-template-columns: 1fr; } }
|
||||
|
||||
.factory-card {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
padding: 28px 30px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 卡片内 · 文上图下 单列(3 卡并排时保持视觉一致)*/
|
||||
.factory-body {
|
||||
display: flex; flex-direction: column;
|
||||
gap: 18px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.factory-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.factory-tag {
|
||||
align-self: flex-start;
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--black-alpha-48); letter-spacing: .06em;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border-faint);
|
||||
background: var(--background-lighter);
|
||||
border-radius: var(--r-sm);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.factory-title {
|
||||
font-size: 22px; font-weight: 600;
|
||||
letter-spacing: -.018em; line-height: 1.25;
|
||||
color: var(--accent-black);
|
||||
}
|
||||
.factory-desc {
|
||||
margin-top: 8px;
|
||||
font-size: 13.5px; color: var(--black-alpha-64); line-height: 1.55;
|
||||
}
|
||||
|
||||
/* feature 列表 */
|
||||
.factory-features {
|
||||
list-style: none; padding: 0;
|
||||
margin: 22px 0 0;
|
||||
display: flex; flex-direction: column; gap: 11px;
|
||||
}
|
||||
.factory-features li {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 13px; color: var(--black-alpha-72); font-weight: 500;
|
||||
}
|
||||
.factory-features .ff-ic {
|
||||
width: 26px; height: 26px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--heat-12);
|
||||
color: var(--heat);
|
||||
border-radius: var(--r-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.factory-features .ff-ic svg { width: 14px; height: 14px; }
|
||||
|
||||
/* 平台 chip 行 */
|
||||
.platform-row {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.platform-chip {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
height: 30px; padding: 0 12px 0 8px;
|
||||
border: 1px solid var(--border-faint);
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-pill);
|
||||
transition: background var(--t-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
.platform-chip:hover { background: var(--black-alpha-4); }
|
||||
.platform-chip .code {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 20px; height: 20px;
|
||||
background: var(--accent-black); color: var(--accent-white);
|
||||
font-family: var(--font-mono); font-size: 8.5px; font-weight: 600;
|
||||
border-radius: var(--r-pill); letter-spacing: .04em;
|
||||
}
|
||||
.platform-chip .nm {
|
||||
font-size: 12px; color: var(--accent-black); font-weight: 500;
|
||||
}
|
||||
|
||||
/* CTA 行:主按钮 + 价格 mono */
|
||||
.factory-cta {
|
||||
margin-top: auto; padding-top: 24px;
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
}
|
||||
.factory-cta .cost {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--black-alpha-48); letter-spacing: .04em;
|
||||
}
|
||||
|
||||
/* 视觉占位 + feature 列表已隐藏 — CTA 卡片只保留标题 + 描述 + 按钮 */
|
||||
.factory-visual { display: none; }
|
||||
.factory-features { display: none; }
|
||||
.model-visual { grid-template-columns: repeat(4, 1fr); }
|
||||
.model-visual .main { aspect-ratio: 3 / 4; grid-column: span 1; }
|
||||
.model-visual .stack { display: contents; }
|
||||
.model-visual .stack .placeholder { aspect-ratio: 3 / 4; }
|
||||
.kit-visual { grid-template-columns: repeat(4, 1fr); }
|
||||
.kit-visual .placeholder { aspect-ratio: 1 / 1; }
|
||||
|
||||
/* ─── 任务中心 · section header ─── */
|
||||
.section-h { display: flex; align-items: center; gap: 12px; margin-top: 24px; margin-bottom: 14px; }
|
||||
.section-h h2 { font-size: 18px; font-weight: 600; letter-spacing: -.01em; }
|
||||
.section-h .sub-mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
|
||||
|
||||
/* ─── 视图切换 (复用 projects.html · 图标 + 文字) ─── */
|
||||
.view-toggle { display: inline-flex; border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; }
|
||||
.view-toggle button { padding: 0 14px; background: var(--surface); color: var(--black-alpha-56); font-size: 13px; border: 0; border-right: 1px solid var(--border-faint); border-radius: 0; height: 36px; cursor: pointer; font-family: inherit; display: flex; align-items: center; gap: 6px; transition: background var(--t-base), color var(--t-base); }
|
||||
.view-toggle button:last-child { border-right: 0; }
|
||||
.view-toggle button:hover { background: var(--background-lighter); color: var(--accent-black); }
|
||||
.view-toggle button.active { background: var(--heat-12); color: var(--heat); font-weight: 600; }
|
||||
.view-toggle button svg { width: 13px; height: 13px; }
|
||||
|
||||
/* ─── 网格视图 · 卡片(原 history-grid) ─── */
|
||||
.history-grid {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
@media (max-width: 1280px) { .history-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 960px) { .history-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
.history-grid[hidden] { display: none; }
|
||||
|
||||
.history-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
padding: 12px;
|
||||
display: grid; grid-template-columns: 78px 1fr; gap: 14px;
|
||||
align-items: center;
|
||||
transition: background var(--t-base);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.history-card:hover { background: var(--black-alpha-4); }
|
||||
.history-card:hover .card-del-btn { opacity: 1; }
|
||||
.history-card .placeholder { width: 78px; height: 78px; }
|
||||
|
||||
/* ─── 列表视图 · 表格 ─── */
|
||||
#task-list-view {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-muted);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
#task-list-view[hidden] { display: none; }
|
||||
#task-list-view table.t {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
#task-list-view table.t thead th {
|
||||
background: var(--background-lighter);
|
||||
border-bottom-color: var(--border-muted);
|
||||
}
|
||||
#task-list-view table.t tbody td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.task-name-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.task-thumb { width: 40px; height: 40px; flex-shrink: 0; border-radius: var(--r-sm); }
|
||||
.task-name { font-weight: 600; color: var(--accent-black); font-size: 13.5px; }
|
||||
.task-sub { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
|
||||
table.t .task-list-prog { display: flex; align-items: center; gap: 8px; min-width: 120px; }
|
||||
table.t .task-list-prog .bar { flex: 1; height: 4px; background: var(--black-alpha-7); border-radius: 2px; overflow: hidden; }
|
||||
table.t .task-list-prog .bar span { display: block; height: 100%; background: var(--heat); border-radius: 2px; animation: hp-pulse 1.4s ease-in-out infinite; }
|
||||
table.t .task-list-prog .pct { font-family: var(--font-mono); font-size: 10.5px; color: var(--heat); letter-spacing: .02em; white-space: nowrap; }
|
||||
|
||||
/* ─── 行末 ⋯ 删除气泡 (复用 projects.html) ─── */
|
||||
.row-action { display: flex; gap: 4px; justify-content: flex-end; }
|
||||
table.t tbody tr .row-more { opacity: 0; transition: opacity .15s; }
|
||||
table.t tbody tr:hover .row-more { opacity: 1; }
|
||||
.row-more {
|
||||
position: relative; display: inline-flex;
|
||||
cursor: pointer; align-items: center;
|
||||
color: var(--black-alpha-56);
|
||||
padding: 4px;
|
||||
}
|
||||
.row-more:hover { color: var(--accent-black); }
|
||||
.row-more-tip {
|
||||
position: absolute; top: calc(100% + 6px); right: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.08);
|
||||
padding: 4px; min-width: 110px;
|
||||
opacity: 0; pointer-events: none;
|
||||
transform: translateY(-2px);
|
||||
transition: opacity .15s, transform .15s;
|
||||
z-index: 12;
|
||||
}
|
||||
.row-more-tip::before {
|
||||
content: ''; position: absolute;
|
||||
top: -8px; left: 0; right: 0; height: 8px;
|
||||
}
|
||||
.row-more:hover .row-more-tip,
|
||||
.row-more-tip:hover { opacity: 1; pointer-events: auto; transform: translateY(0); }
|
||||
.row-more-tip .mi {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
width: 100%; padding: 6px 10px;
|
||||
background: transparent; border: 0;
|
||||
border-radius: var(--r-sm); cursor: pointer;
|
||||
font-size: 12.5px; color: var(--accent-black);
|
||||
font-family: inherit; text-align: left;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.row-more-tip .mi:hover {
|
||||
background: var(--crimson-bg, #fdebea);
|
||||
color: var(--accent-crimson, #c43d3d);
|
||||
}
|
||||
.row-more-tip .mi svg { width: 13px; height: 13px; }
|
||||
|
||||
/* ─── 删除确认 modal · 复用 ─── */
|
||||
.modal-bg.show { display: flex; }
|
||||
.mono-acc { font-family: var(--font-mono); color: var(--heat); font-weight: 600; }
|
||||
|
||||
.history-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.history-name {
|
||||
font-size: 13.5px; font-weight: 600; color: var(--accent-black);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.history-type {
|
||||
font-size: 11.5px; color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono); letter-spacing: .02em;
|
||||
}
|
||||
.history-foot {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-top: 4px; gap: 6px; min-width: 0;
|
||||
}
|
||||
.history-foot .mono {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.history-foot .pill { padding: 2px 8px; font-size: 10.5px; }
|
||||
.history-foot .pill .dot { width: 5px; height: 5px; }
|
||||
|
||||
/* 进度条(生成中状态) */
|
||||
.history-prog {
|
||||
margin-top: 6px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.history-prog .bar {
|
||||
flex: 1; height: 4px;
|
||||
background: var(--black-alpha-7);
|
||||
border-radius: 2px; overflow: hidden;
|
||||
}
|
||||
.history-prog .bar span {
|
||||
display: block; height: 100%;
|
||||
background: var(--heat); border-radius: 2px;
|
||||
animation: hp-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes hp-pulse {
|
||||
0%, 100% { opacity: 1; transform: scaleY(1); }
|
||||
50% { opacity: .55; transform: scaleY(.7); }
|
||||
}
|
||||
.history-prog .pct {
|
||||
font-family: var(--font-mono); font-size: 10px;
|
||||
color: var(--heat); letter-spacing: .02em; white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page">
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>图片生成</h1>
|
||||
<div class="sub">
|
||||
<span class="mono">// 一键生成</span>
|
||||
<span>·</span>
|
||||
<span>电商视觉素材,提升内容制作效率</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 双 Hero 卡片 -->
|
||||
<div class="factory-hero">
|
||||
|
||||
<!-- 卡片 A · 模特上身图 -->
|
||||
<div class="factory-card with-corners">
|
||||
<span class="corner-tr" aria-hidden></span>
|
||||
<span class="corner-bl" aria-hidden></span>
|
||||
|
||||
<div class="factory-body">
|
||||
<div class="factory-text">
|
||||
<span class="factory-tag">[ MODEL · TRY-ON ]</span>
|
||||
<div class="factory-title">模特上身图</div>
|
||||
<div class="factory-desc">选择模特,AI 生成商品模特上身效果图</div>
|
||||
|
||||
<ul class="factory-features">
|
||||
<li>
|
||||
<span class="ff-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="9" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19c0-3 2.7-5 6-5s6 2 6 5M14 19c.5-2.4 2.4-4 5-4 .8 0 1.5.2 2 .5"/></svg>
|
||||
</span>
|
||||
支持多模特选择
|
||||
</li>
|
||||
<li>
|
||||
<span class="ff-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="8" height="8"/><rect x="13" y="3" width="8" height="8"/><rect x="3" y="13" width="8" height="8"/><rect x="13" y="13" width="8" height="8"/></svg>
|
||||
</span>
|
||||
一次生成 4 张
|
||||
</li>
|
||||
<li>
|
||||
<span class="ff-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 9-4 9 4-9 4-9-4z"/><path d="m3 12 9 4 9-4M3 17l9 4 9-4"/></svg>
|
||||
</span>
|
||||
支持多商品并行
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="factory-cta">
|
||||
<a class="btn btn-primary btn-lg" href="model-photo.html">
|
||||
开始生成
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
<span class="cost">[ ≈ ¥0.30 / 张 ]</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="factory-visual model-visual">
|
||||
<div class="placeholder main"><span class="ph-frame">Ava · 9:16</span></div>
|
||||
<div class="stack">
|
||||
<div class="placeholder"><span class="ph-frame">变体 01</span></div>
|
||||
<div class="placeholder"><span class="ph-frame">变体 02</span></div>
|
||||
<div class="placeholder"><span class="ph-frame">变体 03</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片 B · 平台套图 -->
|
||||
<div class="factory-card with-corners">
|
||||
<span class="corner-tr" aria-hidden></span>
|
||||
<span class="corner-bl" aria-hidden></span>
|
||||
|
||||
<div class="factory-body">
|
||||
<div class="factory-text">
|
||||
<span class="factory-tag">[ PLATFORM · KIT ]</span>
|
||||
<div class="factory-title">平台套图</div>
|
||||
<div class="factory-desc">选择平台模板,AI 生成电商平台套图</div>
|
||||
|
||||
<ul class="factory-features">
|
||||
<li>
|
||||
<span class="ff-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V9M9 21V5M15 21v-8M21 21V11"/></svg>
|
||||
</span>
|
||||
覆盖主流电商平台
|
||||
</li>
|
||||
<li>
|
||||
<span class="ff-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="8" height="8"/><rect x="13" y="3" width="8" height="8"/><rect x="3" y="13" width="8" height="8"/><rect x="13" y="13" width="8" height="8"/></svg>
|
||||
</span>
|
||||
一键生成 4 张套图
|
||||
</li>
|
||||
<li>
|
||||
<span class="ff-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z"/></svg>
|
||||
</span>
|
||||
智能排版设计
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="factory-cta">
|
||||
<a class="btn btn-primary btn-lg" href="platform-cover.html">
|
||||
开始生成
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
<span class="cost">[ ≈ ¥0.50 / 张 ]</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="factory-visual kit-visual">
|
||||
<div class="placeholder"><span class="ph-frame">套图 / TB</span></div>
|
||||
<div class="placeholder"><span class="ph-frame">套图 / DY</span></div>
|
||||
<div class="placeholder"><span class="ph-frame">套图 / XHS</span></div>
|
||||
<div class="placeholder"><span class="ph-frame">套图 / PDD</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片 C · 图片创作(自由创作 AI 图片工作台)-->
|
||||
<div class="factory-card with-corners">
|
||||
<span class="corner-tr" aria-hidden></span>
|
||||
<span class="corner-bl" aria-hidden></span>
|
||||
|
||||
<div class="factory-body">
|
||||
<div class="factory-text">
|
||||
<span class="factory-tag">[ IMAGE · STUDIO ]</span>
|
||||
<div class="factory-title">图片创作</div>
|
||||
<div class="factory-desc">自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写</div>
|
||||
|
||||
<ul class="factory-features">
|
||||
<li>
|
||||
<span class="ff-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="9" r="3"/><path d="M3 19c0-3 2.7-5 6-5s6 2 6 5"/></svg>
|
||||
</span>
|
||||
人物 · 商品 全支持
|
||||
</li>
|
||||
<li>
|
||||
<span class="ff-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="6" height="18"/><rect x="9" y="3" width="6" height="18"/><rect x="15" y="3" width="6" height="18"/></svg>
|
||||
</span>
|
||||
正面 / 侧面 / 背面 一次输出
|
||||
</li>
|
||||
<li>
|
||||
<span class="ff-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
|
||||
</span>
|
||||
多镜头一致性保证
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="factory-cta">
|
||||
<a class="btn btn-primary btn-lg" href="image-optimize.html">
|
||||
开始生成
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
<span class="cost">[ ≈ ¥0.40 / 组 ]</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="factory-visual tri-visual">
|
||||
<div class="placeholder"><span class="ph-frame">正 / 侧 / 背 · 三视图</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ============= 任务中心 · 参考 projects.html 布局 ============= -->
|
||||
<div class="section-h">
|
||||
<h2>任务中心</h2>
|
||||
<span class="sub-mono">// <span id="tc-sub-total">0</span> 个 · <span id="tc-sub-gen">0</span> 生成中 · <span id="tc-sub-ok">0</span> 已完成 · <span id="tc-sub-err">0</span> 失败</span>
|
||||
</div>
|
||||
|
||||
<!-- 状态 tabs (复用 .tabs) -->
|
||||
<div class="tabs" id="tc-tabs">
|
||||
<div class="tab active" data-filter="all">全部 <span class="count">0</span></div>
|
||||
<div class="tab" data-filter="gen">生成中 <span class="count">0</span></div>
|
||||
<div class="tab" data-filter="ok">已完成 <span class="count">0</span></div>
|
||||
<div class="tab" data-filter="err">失败 <span class="count">0</span></div>
|
||||
</div>
|
||||
|
||||
<!-- toolbar: search + 类型 chip + clear + view-toggle -->
|
||||
<div class="toolbar">
|
||||
<div class="search-inline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input class="input" id="tc-search" placeholder="搜索任务名">
|
||||
</div>
|
||||
<div class="chip-wrap" data-key="time">
|
||||
<button class="chip" type="button"><span class="chip-label">时间</span> <svg class="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
|
||||
<div class="chip-menu">
|
||||
<div class="mi selected" data-value="all"><svg class="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg><span>全部时间</span></div>
|
||||
<div class="mi-sep"></div>
|
||||
<div class="mi" data-value="today"><svg class="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg><span>今天</span></div>
|
||||
<div class="mi" data-value="1h"><svg class="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg><span>1 小时内</span></div>
|
||||
<div class="mi" data-value="10min"><svg class="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg><span>10 分钟内</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chip-wrap" data-key="type">
|
||||
<button class="chip" type="button"><span class="chip-label">任务类型</span> <svg class="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
|
||||
<div class="chip-menu"></div>
|
||||
</div>
|
||||
<button class="clear-filters" id="tc-clear" type="button" hidden>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4l8 8M12 4l-8 8"/></svg>
|
||||
清空筛选
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<div class="view-toggle" id="tc-view-toggle">
|
||||
<button type="button" data-view="grid">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="5" height="5"/><rect x="9" y="2" width="5" height="5"/><rect x="2" y="9" width="5" height="5"/><rect x="9" y="9" width="5" height="5"/></svg>
|
||||
网格
|
||||
</button>
|
||||
<button type="button" class="active" data-view="list">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h12M2 12h12"/></svg>
|
||||
列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-meta" id="tc-result-meta">// 显示 <span class="count">0</span> / 0 个任务</div>
|
||||
|
||||
<!-- ============= LIST VIEW (默认) ============= -->
|
||||
<div id="task-list-view">
|
||||
<table class="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:42%">任务</th>
|
||||
<th style="width:160px">进度</th>
|
||||
<th>状态</th>
|
||||
<th style="width:120px">创建于</th>
|
||||
<th style="width:48px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="task-list-tbody"><!-- JS 从卡片同步生成 --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ============= GRID VIEW (JS 从 localStorage 动态渲染) ============= -->
|
||||
<div class="history-grid" id="task-grid" hidden></div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<div id="tc-empty" hidden style="padding:60px 20px;text-align:center;color:var(--black-alpha-48);font-size:13px;line-height:1.6">
|
||||
<div style="font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;margin-bottom:6px;">// NO TASKS YET</div>
|
||||
<div id="tc-empty-text">还没有任务,去上方选一个工序开始生成吧</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ===== 删除确认 modal (复用 projects.html 风格) ===== -->
|
||||
<div class="modal-bg" id="tc-del-bg">
|
||||
<div class="modal" role="dialog">
|
||||
<span class="corner-tr" aria-hidden></span>
|
||||
<span class="corner-bl" aria-hidden></span>
|
||||
<div class="modal-h">
|
||||
<div class="ic-m" style="background:var(--crimson-bg,#fdebea);color:var(--accent-crimson,#c43d3d)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6"/></svg>
|
||||
</div>
|
||||
<div class="ti">确认删除任务<span>// CONFIRM DELETE</span></div>
|
||||
</div>
|
||||
<div class="modal-b" id="tc-del-body">即将删除任务记录。</div>
|
||||
<div class="modal-f">
|
||||
<button class="btn" type="button" id="tc-del-cancel">取消</button>
|
||||
<button class="btn" type="button" id="tc-del-ok" style="background:var(--accent-crimson);color:var(--accent-white);border-color:var(--accent-crimson)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/></svg>
|
||||
确认删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/icons.js?v=2026052608"></script>
|
||||
<script src="assets/shell.js?v=2026060101"></script>
|
||||
<script src="assets/new-product-drawer.js?v=202605211643"></script>
|
||||
<script>
|
||||
Shell.render({
|
||||
active: 'asset-factory',
|
||||
crumbs: [{ label: '工作台', href: 'index.html' }, { label: '图片生成' }]
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
任务中心 · 从 localStorage 读取(与 model-photo / platform-cover 共享)
|
||||
============================================================ */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const TYPE_LABEL = { model: '模特上身图', platform: '平台套图', image: '图片创作' };
|
||||
const STATUS_LABEL = { ok: '已完成', gen: '生成中', err: '失败' };
|
||||
const STATUS_PILL = { ok: 'ok', gen: 'info', err: 'err' };
|
||||
const KEY_BY_TYPE = {
|
||||
model: 'fs-image-tasks-model',
|
||||
platform: 'fs-image-tasks-platform',
|
||||
image: 'fs-image-tasks-image',
|
||||
};
|
||||
const URL_BY_TYPE = {
|
||||
model: 'model-photo.html',
|
||||
platform: 'platform-cover.html',
|
||||
image: 'image-optimize.html',
|
||||
};
|
||||
|
||||
const taskGrid = document.getElementById('task-grid');
|
||||
const listTbody = document.getElementById('task-list-tbody');
|
||||
const gridView = taskGrid;
|
||||
const listView = document.getElementById('task-list-view');
|
||||
|
||||
let cards = []; // 动态生成的 .task-card 元素数组(顺序与任务时间倒序一致)
|
||||
|
||||
const state = { filter: 'all', type: 'all', time: 'all', search: '', view: 'list' };
|
||||
|
||||
function _timeMatch(createdAt, key) {
|
||||
if (key === 'all' || !createdAt) return true;
|
||||
const now = Date.now();
|
||||
const diff = now - Number(createdAt);
|
||||
if (key === '10min') return diff <= 10 * 60 * 1000;
|
||||
if (key === '1h') return diff <= 60 * 60 * 1000;
|
||||
if (key === 'today') {
|
||||
const a = new Date(now); const b = new Date(Number(createdAt));
|
||||
return a.toDateString() === b.toDateString();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const TIME_LABEL = { all: '时间', today: '今天', '1h': '1 小时内', '10min': '10 分钟内' };
|
||||
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
|
||||
/* ---------- localStorage 读写 ---------- */
|
||||
function loadType(type) {
|
||||
try { return JSON.parse(localStorage.getItem(KEY_BY_TYPE[type]) || '[]'); } catch (e) { return []; }
|
||||
}
|
||||
function saveType(type, arr) {
|
||||
try { localStorage.setItem(KEY_BY_TYPE[type], JSON.stringify(arr)); } catch (e) {}
|
||||
}
|
||||
function loadAllTasks() {
|
||||
const all = [];
|
||||
Object.keys(KEY_BY_TYPE).forEach(type => {
|
||||
loadType(type).forEach(t => all.push({ ...t, type })); // type 兜底
|
||||
});
|
||||
all.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
return all;
|
||||
}
|
||||
|
||||
/* ---------- task → 渲染辅助 ---------- */
|
||||
function subTextOf(t) {
|
||||
const lbl = TYPE_LABEL[t.type] || t.type;
|
||||
const count = (t.snap && t.snap.count) || 4;
|
||||
return `${lbl} · ${count} 张`;
|
||||
}
|
||||
function thumbLabelOf(t) {
|
||||
// 取「商品 × 模特/平台」中右半作为缩略图占位文字
|
||||
const parts = (t.name || '').split(/\s[×x]\s/);
|
||||
return (parts[1] || parts[0] || '—').slice(0, 8);
|
||||
}
|
||||
|
||||
/* ---------- 点击行 / 卡片 → 跳转工作台(携带 taskId) ---------- */
|
||||
function goToWorkbench(t) {
|
||||
const base = URL_BY_TYPE[t.type] || URL_BY_TYPE.model;
|
||||
location.href = base + '?taskId=' + encodeURIComponent(t.id);
|
||||
}
|
||||
|
||||
/* ---------- 1. 从 task 数据生成卡片 + list 行 ---------- */
|
||||
function cardFor(t) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'task-card history-card';
|
||||
card.dataset.status = t.status;
|
||||
card.dataset.type = t.type;
|
||||
card.dataset.name = t.name;
|
||||
card.dataset.taskId = t.id;
|
||||
card.dataset.createdAt = String(t.createdAt || 0);
|
||||
card.style.cursor = 'pointer';
|
||||
card.innerHTML = `
|
||||
<button class="card-del-btn" type="button" title="删除任务">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6"/></svg>
|
||||
</button>
|
||||
<div class="placeholder"><span class="ph-frame">${esc(thumbLabelOf(t))}</span></div>
|
||||
<div class="history-body">
|
||||
<div class="history-name">${esc(t.name)}</div>
|
||||
<div class="history-type">${esc(subTextOf(t))}</div>
|
||||
<div class="history-foot">
|
||||
<span class="mono">// ${esc(t.time || '')}</span>
|
||||
<span class="pill ${STATUS_PILL[t.status] || 'info'}"><span class="dot"></span>${esc(STATUS_LABEL[t.status] || t.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
function rowFor(t) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.taskRow = '1';
|
||||
tr.dataset.name = t.name;
|
||||
tr.dataset.type = t.type;
|
||||
tr.dataset.status = t.status;
|
||||
tr.dataset.taskId = t.id;
|
||||
tr.addEventListener('click', () => goToWorkbench(t));
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<div class="task-name-cell">
|
||||
<div class="placeholder task-thumb"><span class="ph-frame">${esc(thumbLabelOf(t))}</span></div>
|
||||
<div>
|
||||
<div class="task-name">${esc(t.name)}</div>
|
||||
<div class="task-sub">${esc(subTextOf(t))}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>${t.status === 'gen'
|
||||
? `<div class="task-list-prog"><div class="bar"><span style="width:60%"></span></div><span class="pct">60%</span></div>`
|
||||
: (t.status === 'ok' ? '<span class="muted-2 mono" style="font-size:11px;">已完成</span>' : '<span class="muted-2 mono" style="font-size:11px;">—</span>')}</td>
|
||||
<td><span class="pill ${STATUS_PILL[t.status] || 'info'}"><span class="dot"></span>${esc(STATUS_LABEL[t.status] || t.status)}</span></td>
|
||||
<td class="muted-2">${esc(t.time || '')}</td>
|
||||
<td>
|
||||
<div class="row-action">
|
||||
<span class="row-more"><svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor"/><circle cx="8" cy="8" r="1.2" fill="currentColor"/><circle cx="13" cy="8" r="1.2" fill="currentColor"/></svg>
|
||||
<div class="row-more-tip"><button class="mi mi-del-task" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6"/></svg>删除任务</button></div>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
tr.querySelector('.row-more').addEventListener('click', e => e.stopPropagation());
|
||||
return tr;
|
||||
}
|
||||
|
||||
/* ---------- 2. 全量重渲染(load → render → filter) ---------- */
|
||||
function renderAll() {
|
||||
// 清空 grid / list
|
||||
taskGrid.innerHTML = '';
|
||||
listTbody.innerHTML = '';
|
||||
cards = [];
|
||||
|
||||
const tasks = loadAllTasks();
|
||||
tasks.forEach(t => {
|
||||
const card = cardFor(t);
|
||||
const row = rowFor(t);
|
||||
taskGrid.appendChild(card);
|
||||
listTbody.appendChild(row);
|
||||
card._listRow = row;
|
||||
card._task = t;
|
||||
row._card = card;
|
||||
cards.push(card);
|
||||
|
||||
// 卡片点击 → 跳工作台
|
||||
card.addEventListener('click', e => {
|
||||
if (e.target.closest('.card-del-btn')) return;
|
||||
goToWorkbench(t);
|
||||
});
|
||||
// 卡片删除按钮
|
||||
card.querySelector('.card-del-btn').addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
openDelConfirm(card);
|
||||
});
|
||||
});
|
||||
|
||||
// 类型 chip 菜单(基于现有 task 的 type 集合,动态)
|
||||
rebuildTypeMenu();
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
/* ---------- 3. 构建类型 chip 菜单(基于当前 cards 的 type 集合) ---------- */
|
||||
const checkSvg = '<svg class="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg>';
|
||||
const typeMenu = document.querySelector('.chip-wrap[data-key="type"] .chip-menu');
|
||||
function rebuildTypeMenu() {
|
||||
const typeOptions = [...new Set(cards.map(c => c.dataset.type))];
|
||||
typeMenu.innerHTML = `<div class="mi selected" data-value="all">${checkSvg}<span>全部任务类型</span></div><div class="mi-sep"></div>`
|
||||
+ typeOptions.map(v => `<div class="mi" data-value="${esc(v)}">${checkSvg}<span>${esc(TYPE_LABEL[v] || v)}</span></div>`).join('');
|
||||
syncTypeChip();
|
||||
}
|
||||
|
||||
function syncTypeChip() {
|
||||
const wrap = document.querySelector('.chip-wrap[data-key="type"]');
|
||||
const label = wrap.querySelector('.chip-label');
|
||||
const chip = wrap.querySelector('.chip');
|
||||
if (state.type === 'all') {
|
||||
label.textContent = '任务类型';
|
||||
chip.classList.remove('active');
|
||||
} else {
|
||||
label.textContent = TYPE_LABEL[state.type] || state.type;
|
||||
chip.classList.add('active');
|
||||
}
|
||||
wrap.querySelectorAll('.mi').forEach(mi => mi.classList.toggle('selected', mi.dataset.value === state.type));
|
||||
}
|
||||
|
||||
function syncTimeChip() {
|
||||
const wrap = document.querySelector('.chip-wrap[data-key="time"]');
|
||||
if (!wrap) return;
|
||||
const label = wrap.querySelector('.chip-label');
|
||||
const chip = wrap.querySelector('.chip');
|
||||
if (state.time === 'all') {
|
||||
label.textContent = '时间';
|
||||
chip.classList.remove('active');
|
||||
} else {
|
||||
label.textContent = TIME_LABEL[state.time] || state.time;
|
||||
chip.classList.add('active');
|
||||
}
|
||||
wrap.querySelectorAll('.mi').forEach(mi => mi.classList.toggle('selected', mi.dataset.value === state.time));
|
||||
}
|
||||
|
||||
/* ---------- 3. applyFilter ---------- */
|
||||
function applyFilter() {
|
||||
const q = state.search.toLowerCase();
|
||||
let visible = 0;
|
||||
cards.forEach(card => {
|
||||
const okStatus = state.filter === 'all' || card.dataset.status === state.filter;
|
||||
const okType = state.type === 'all' || card.dataset.type === state.type;
|
||||
const okTime = _timeMatch(card.dataset.createdAt, state.time);
|
||||
const okSearch = !q || (card.dataset.name || '').toLowerCase().includes(q);
|
||||
const show = okStatus && okType && okTime && okSearch;
|
||||
card.style.display = show ? '' : 'none';
|
||||
if (card._listRow) card._listRow.style.display = show ? '' : 'none';
|
||||
if (show) visible++;
|
||||
});
|
||||
|
||||
// 计数
|
||||
const counts = { all: cards.length, gen: 0, ok: 0, err: 0 };
|
||||
cards.forEach(c => { if (counts[c.dataset.status] !== undefined) counts[c.dataset.status]++; });
|
||||
document.querySelectorAll('#tc-tabs .tab').forEach(t => {
|
||||
const f = t.dataset.filter;
|
||||
t.querySelector('.count').textContent = f === 'all' ? counts.all : counts[f];
|
||||
});
|
||||
document.getElementById('tc-sub-total').textContent = counts.all;
|
||||
document.getElementById('tc-sub-gen').textContent = counts.gen;
|
||||
document.getElementById('tc-sub-ok').textContent = counts.ok;
|
||||
document.getElementById('tc-sub-err').textContent = counts.err;
|
||||
|
||||
document.getElementById('tc-result-meta').innerHTML = `// 显示 <span class="count">${visible}</span> / ${cards.length} 个任务`;
|
||||
document.getElementById('tc-clear').hidden = !(state.search || state.type !== 'all' || state.time !== 'all');
|
||||
|
||||
// 空态
|
||||
const emptyEl = document.getElementById('tc-empty');
|
||||
const emptyText = document.getElementById('tc-empty-text');
|
||||
if (cards.length === 0) {
|
||||
emptyEl.hidden = false;
|
||||
emptyText.textContent = '还没有任务,去上方选一个工序开始生成吧';
|
||||
listView.hidden = true; gridView.hidden = true;
|
||||
} else if (visible === 0) {
|
||||
emptyEl.hidden = false;
|
||||
emptyText.textContent = '没有符合筛选条件的任务';
|
||||
// 视图本身保留,空表头也保留
|
||||
} else {
|
||||
emptyEl.hidden = true;
|
||||
// 恢复 view 显示(可能在 length===0 分支被隐藏)
|
||||
if (state.view === 'list') { listView.hidden = false; gridView.hidden = true; }
|
||||
else { listView.hidden = true; gridView.hidden = false; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 4. 事件绑定 ---------- */
|
||||
// status tabs
|
||||
document.querySelectorAll('#tc-tabs .tab').forEach(t => {
|
||||
t.addEventListener('click', () => {
|
||||
document.querySelectorAll('#tc-tabs .tab').forEach(x => x.classList.remove('active'));
|
||||
t.classList.add('active');
|
||||
state.filter = t.dataset.filter;
|
||||
applyFilter();
|
||||
});
|
||||
});
|
||||
// search
|
||||
document.getElementById('tc-search').addEventListener('input', e => {
|
||||
state.search = e.target.value.trim();
|
||||
applyFilter();
|
||||
});
|
||||
// type chip
|
||||
const typeWrap = document.querySelector('.chip-wrap[data-key="type"]');
|
||||
typeWrap.querySelector('.chip').addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const isOpen = typeWrap.classList.contains('open');
|
||||
document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));
|
||||
if (!isOpen) typeWrap.classList.add('open');
|
||||
});
|
||||
typeMenu.addEventListener('click', e => {
|
||||
const mi = e.target.closest('.mi');
|
||||
if (!mi) return;
|
||||
e.stopPropagation();
|
||||
state.type = mi.dataset.value;
|
||||
typeWrap.classList.remove('open');
|
||||
syncTypeChip();
|
||||
applyFilter();
|
||||
});
|
||||
// time chip
|
||||
const timeWrap = document.querySelector('.chip-wrap[data-key="time"]');
|
||||
const timeMenu = timeWrap.querySelector('.chip-menu');
|
||||
timeWrap.querySelector('.chip').addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const isOpen = timeWrap.classList.contains('open');
|
||||
document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));
|
||||
if (!isOpen) timeWrap.classList.add('open');
|
||||
});
|
||||
timeMenu.addEventListener('click', e => {
|
||||
const mi = e.target.closest('.mi');
|
||||
if (!mi) return;
|
||||
e.stopPropagation();
|
||||
state.time = mi.dataset.value;
|
||||
timeWrap.classList.remove('open');
|
||||
syncTimeChip();
|
||||
applyFilter();
|
||||
});
|
||||
document.addEventListener('click', () => {
|
||||
document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));
|
||||
});
|
||||
// clear filters
|
||||
document.getElementById('tc-clear').addEventListener('click', () => {
|
||||
state.search = '';
|
||||
state.type = 'all';
|
||||
state.time = 'all';
|
||||
document.getElementById('tc-search').value = '';
|
||||
syncTypeChip();
|
||||
syncTimeChip();
|
||||
applyFilter();
|
||||
Shell.toast('已清空筛选');
|
||||
});
|
||||
// view toggle
|
||||
document.querySelectorAll('#tc-view-toggle button').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
document.querySelectorAll('#tc-view-toggle button').forEach(x => x.classList.remove('active'));
|
||||
b.classList.add('active');
|
||||
state.view = b.dataset.view;
|
||||
if (state.view === 'list') { listView.hidden = false; gridView.hidden = true; }
|
||||
else { listView.hidden = true; gridView.hidden = false; }
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- 6. 删除 modal + 同步 localStorage ---------- */
|
||||
const delBg = document.getElementById('tc-del-bg');
|
||||
const delBody = document.getElementById('tc-del-body');
|
||||
const delCancel = document.getElementById('tc-del-cancel');
|
||||
const delOk = document.getElementById('tc-del-ok');
|
||||
let _delTarget = null;
|
||||
|
||||
function openDelConfirm(target) {
|
||||
_delTarget = target;
|
||||
const name = target.dataset.name || '该任务';
|
||||
delBody.innerHTML = '即将删除任务 <span class="mono-acc">' + esc(name) + '</span>。任务记录将清除,已入库的素材不受影响。';
|
||||
delBg.classList.add('show');
|
||||
}
|
||||
function closeDelConfirm() { delBg.classList.remove('show'); _delTarget = null; }
|
||||
delCancel.addEventListener('click', closeDelConfirm);
|
||||
delBg.addEventListener('click', e => { if (e.target === delBg) closeDelConfirm(); });
|
||||
delOk.addEventListener('click', () => {
|
||||
if (!_delTarget) return;
|
||||
const card = _delTarget._card || _delTarget; // 可能传 row 或 card
|
||||
const taskId = card.dataset.taskId;
|
||||
const taskType = card.dataset.type;
|
||||
const name = card.dataset.name;
|
||||
// 从 localStorage 移除对应任务
|
||||
if (taskType && KEY_BY_TYPE[taskType]) {
|
||||
const arr = loadType(taskType).filter(t => t.id !== taskId);
|
||||
saveType(taskType, arr);
|
||||
}
|
||||
closeDelConfirm();
|
||||
Shell.toast('已删除', name);
|
||||
renderAll();
|
||||
});
|
||||
|
||||
// 列表行 删除按钮(事件委托,因为是动态生成)
|
||||
listTbody.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.mi-del-task');
|
||||
if (!btn) return;
|
||||
e.stopPropagation();
|
||||
const tr = btn.closest('tr');
|
||||
if (tr) openDelConfirm(tr);
|
||||
});
|
||||
|
||||
/* ---------- 7. 初始化 ---------- */
|
||||
renderAll();
|
||||
})();
|
||||
</script>
|
||||
<script src="assets/api-bridge.js?v=2026060114"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,94 @@
|
||||
(function () {
|
||||
const PATHS = {
|
||||
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 10v10h14V10"/><path d="M9 20v-6h6v6"/>',
|
||||
layoutDashboard: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
|
||||
package: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>',
|
||||
boxes: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>',
|
||||
clapperboard: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
|
||||
film: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
|
||||
video: '<path d="m16 13 5.2 3.1a.5.5 0 0 0 .8-.4V8.3a.5.5 0 0 0-.8-.4L16 11"/><rect x="2" y="6" width="14" height="12" rx="2"/>',
|
||||
sparkles: '<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3Z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15Z"/>',
|
||||
images: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
|
||||
folder: '<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2h7.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5Z"/><path d="M3 9h18"/>',
|
||||
library: '<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2h7.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5Z"/><path d="M3 9h18"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
wallet: '<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><path d="M16 14h2"/>',
|
||||
creditCard: '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/>',
|
||||
settings: '<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.2a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.2A1.7 1.7 0 0 0 4.7 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1.1-1.5V3a2 2 0 1 1 4 0v.2a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1h.1a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
|
||||
airshelf: '<path d="M4.5 19 12 4.5 19.5 19"/><path d="M8 14h8"/><path d="M7 17h10"/><path d="M9.5 10.3c1.5-1.2 3.5-1.2 5 0"/>',
|
||||
flame: '<path d="M8.5 14.5c0-2 1.5-3.4 3.5-5.5.4 2.2 2.8 3.1 2.8 5.6a3.3 3.3 0 0 1-6.3-.1Z"/><path d="M12 22c4 0 7-2.8 7-7 0-4.7-3.3-7.3-5-12-2 3.3-7 6-7 12a5 5 0 0 0 5 7Z"/>',
|
||||
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
|
||||
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/>',
|
||||
list: '<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
|
||||
chevronLeft: '<path d="m15 18-6-6 6-6"/>',
|
||||
chevronRight: '<path d="m9 18 6-6-6-6"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',
|
||||
productPlus: '<path d="M12 22V12"/><path d="M16 17h6"/><path d="M19 14v6"/><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="m7.5 4.3 9 5.1"/>',
|
||||
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||
arrowLeft: '<path d="M19 12H5"/><path d="m12 19-7-7 7-7"/>',
|
||||
arrowUp: '<path d="M12 19V5"/><path d="m5 12 7-7 7 7"/>',
|
||||
chevronDown: '<path d="m6 9 6 6 6-6"/>',
|
||||
rotateCcw: '<path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/>',
|
||||
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/>',
|
||||
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
|
||||
moreHorizontal: '<circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none"/>',
|
||||
trash: '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="m19 6-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>',
|
||||
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5Z"/>',
|
||||
play: '<path d="m6 4 14 8-14 8Z"/>',
|
||||
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||||
alertCircle: '<circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/>',
|
||||
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 8h.01"/>',
|
||||
shieldCheck: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/>',
|
||||
messageCircle: '<path d="M21 11.5a8.5 8.5 0 0 1-12.5 7.5L3 20l1-5.5A8.5 8.5 0 1 1 21 11.5Z"/>',
|
||||
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
|
||||
lock: '<rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
eye: '<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
|
||||
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
|
||||
grid: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
|
||||
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
save: '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/>',
|
||||
userPlus: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6"/><path d="M22 11h-6"/>',
|
||||
helpCircle: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4"/><path d="M12 17h.01"/>'
|
||||
};
|
||||
|
||||
const ALIASES = {
|
||||
dashboard: 'layoutDashboard',
|
||||
products: 'package',
|
||||
projects: 'clapperboard',
|
||||
assetFactory: 'sparkles',
|
||||
library: 'folder',
|
||||
account: 'creditCard',
|
||||
billing: 'creditCard',
|
||||
team: 'users',
|
||||
queue: 'list',
|
||||
arrow: 'arrowRight',
|
||||
back: 'arrowLeft',
|
||||
close: 'x',
|
||||
rerun: 'rotateCcw',
|
||||
more: 'moreHorizontal',
|
||||
danger: 'alertCircle'
|
||||
};
|
||||
|
||||
function svg(name, opts) {
|
||||
opts = opts || {};
|
||||
const key = ALIASES[name] || name;
|
||||
const body = PATHS[key] || PATHS.helpCircle;
|
||||
const size = opts.size || 16;
|
||||
const strokeWidth = opts.strokeWidth || 1.5;
|
||||
const className = ['ui-icon', opts.className || ''].join(' ').trim();
|
||||
const label = opts.label ? ` role="img" aria-label="${escapeAttr(opts.label)}"` : ' aria-hidden="true"';
|
||||
return `<svg class="${className}" data-icon="${key}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" focusable="false"${label}>${body}</svg>`;
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
window.IconKit = {
|
||||
svg,
|
||||
names: () => Object.keys(PATHS),
|
||||
has: name => !!PATHS[ALIASES[name] || name]
|
||||
};
|
||||
})();
|
||||
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 201 KiB |
@@ -0,0 +1,152 @@
|
||||
(function () {
|
||||
const base = 'assets/mock/';
|
||||
const media = {
|
||||
products: {
|
||||
mask: base + 'product-mask.png',
|
||||
earbuds: base + 'product-earbuds.png',
|
||||
noodle: base + 'product-noodle.png',
|
||||
sunscreen: base + 'product-sunscreen.png',
|
||||
coffee: base + 'product-coffee.png',
|
||||
airFryer: base + 'product-air-fryer.png',
|
||||
yoga: base + 'product-yoga-pants.png',
|
||||
storage: base + 'product-storage.png'
|
||||
},
|
||||
covers: {
|
||||
mask: base + 'cover-mask-v3.png',
|
||||
maskFinal: base + 'cover-mask-final.png',
|
||||
noodle: base + 'cover-noodle.png',
|
||||
sunscreen: base + 'cover-sunscreen.png',
|
||||
coffee: base + 'cover-coffee.png',
|
||||
earbuds: base + 'cover-earbuds.png',
|
||||
yoga: base + 'cover-yoga.png',
|
||||
airFryer: base + 'cover-air-fryer.png'
|
||||
},
|
||||
people: {
|
||||
linxi: base + 'person-linxi.png',
|
||||
ajie: base + 'person-ajie.png',
|
||||
aqiang: base + 'person-aqiang.png',
|
||||
xiaosu: base + 'person-xiaosu.png'
|
||||
},
|
||||
scenes: {
|
||||
bedroom: base + 'scene-bedroom.png',
|
||||
bathroom: base + 'scene-bathroom.png',
|
||||
living: base + 'scene-living.png',
|
||||
kitchen: base + 'scene-kitchen.png',
|
||||
office: base + 'scene-office.png',
|
||||
cafe: base + 'scene-cafe.png',
|
||||
street: base + 'scene-night-street.png',
|
||||
tabletop: base + 'scene-tabletop.png'
|
||||
}
|
||||
};
|
||||
|
||||
function clean(text) {
|
||||
return String(text || '').replace(/\s+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function contextText(el) {
|
||||
const card = el.closest('[data-name], [data-product], [data-project], [data-cat], [data-asset-kind], [data-scene-type]');
|
||||
const bits = [
|
||||
el.textContent,
|
||||
card?.dataset.name,
|
||||
card?.dataset.product,
|
||||
card?.dataset.project,
|
||||
card?.dataset.cat,
|
||||
card?.dataset.assetKind,
|
||||
card?.dataset.sceneType,
|
||||
card?.querySelector('.product-name, .card-name, .asset-name, .prod-name, strong')?.textContent
|
||||
];
|
||||
return clean(bits.filter(Boolean).join(' '));
|
||||
}
|
||||
|
||||
function productFor(t) {
|
||||
if (/蓝牙|耳机|earbud|南卡/.test(t)) return media.products.earbuds;
|
||||
if (/速食|牛肉面|泡面|面条|noodle/.test(t)) return media.products.noodle;
|
||||
if (/防晒|sunscreen/.test(t)) return media.products.sunscreen;
|
||||
if (/咖啡|冻干|coffee/.test(t)) return media.products.coffee;
|
||||
if (/空气炸锅|airfryer|小熊/.test(t)) return media.products.airFryer;
|
||||
if (/瑜伽裤|露露|yoga/.test(t)) return media.products.yoga;
|
||||
if (/收纳|storage|北欧/.test(t)) return media.products.storage;
|
||||
if (/面膜|补水|玻尿酸|mask|透真/.test(t)) return media.products.mask;
|
||||
return '';
|
||||
}
|
||||
|
||||
function coverFor(t) {
|
||||
if (/蓝牙|耳机|earbud|南卡/.test(t)) return media.covers.earbuds;
|
||||
if (/速食|牛肉面|泡面|面条|noodle/.test(t)) return media.covers.noodle;
|
||||
if (/防晒|sunscreen/.test(t)) return media.covers.sunscreen;
|
||||
if (/咖啡|冻干|coffee/.test(t)) return media.covers.coffee;
|
||||
if (/空气炸锅|airfryer|小熊/.test(t)) return media.covers.airFryer;
|
||||
if (/瑜伽裤|露露|yoga/.test(t)) return media.covers.yoga;
|
||||
if (/v1|final|已完成|5\/5|成片|敷面膜|化妆台/.test(t)) return media.covers.maskFinal;
|
||||
if (/面膜|补水|玻尿酸|mask|透真|场1|场2|场3/.test(t)) return media.covers.mask;
|
||||
return '';
|
||||
}
|
||||
|
||||
function personFor(t) {
|
||||
if (/阿杰|通勤男|男青年/.test(t)) return media.people.ajie;
|
||||
if (/阿强|健身男|健身/.test(t)) return media.people.aqiang;
|
||||
if (/小苏|文艺女|短发|阿楠|同事|小七|学生女|闺蜜|妈妈|王姐|豆豆/.test(t)) return media.people.xiaosu;
|
||||
if (/林夕|主播|都市白领|主角|女性|女生/.test(t)) return media.people.linxi;
|
||||
if (/小宇|李爷爷|男性|男/.test(t)) return media.people.ajie;
|
||||
return '';
|
||||
}
|
||||
|
||||
function sceneFor(t) {
|
||||
if (/卧室|床头|bedroom/.test(t)) return media.scenes.bedroom;
|
||||
if (/浴室|梳妆台|bathroom/.test(t)) return media.scenes.bathroom;
|
||||
if (/客厅|living/.test(t)) return media.scenes.living;
|
||||
if (/厨房|中岛|kitchen/.test(t)) return media.scenes.kitchen;
|
||||
if (/办公室|办公桌|会议室|office|深夜办公/.test(t)) return media.scenes.office;
|
||||
if (/咖啡店|窗边|cafe/.test(t)) return media.scenes.cafe;
|
||||
if (/街景|夜|street/.test(t)) return media.scenes.street;
|
||||
if (/平台|套图|布景|tabletop/.test(t)) return media.scenes.tabletop;
|
||||
return '';
|
||||
}
|
||||
|
||||
function imageFor(el) {
|
||||
if (el.classList.contains('missing') || el.querySelector('.spinner, .fail-icon')) return '';
|
||||
const t = contextText(el);
|
||||
const card = el.closest('.asset-card, .asset-card-2, .proj-card, .product-card, .video-card');
|
||||
if (el.id === 'ed-canvas') return media.covers.maskFinal;
|
||||
if (el.id === 'sb-main-img' || el.id === 'vd-main-img') return media.covers.mask;
|
||||
if (el.matches('.video-thumb')) {
|
||||
if (card?.dataset.videoId === 'v2') return media.products.mask;
|
||||
if (card?.dataset.videoId === 'v3') return media.covers.maskFinal;
|
||||
}
|
||||
if (el.matches('.card-thumb, .proj-thumb, .video-thumb') || card?.classList.contains('video')) return coverFor(t);
|
||||
if (el.matches('.product-thumb, .prod-thumb, .pl-thumb')) return productFor(t);
|
||||
if (el.matches('.m-thumb')) return personFor(t);
|
||||
if (el.matches('.thumb-2, .asset-thumb')) {
|
||||
return personFor(t) || sceneFor(t) || productFor(t) || coverFor(t);
|
||||
}
|
||||
if (el.matches('.sb-scene-thumb, .sb-history-thumb, .vd-history-thumb')) return coverFor(t) || media.covers.mask;
|
||||
return productFor(t) || personFor(t) || sceneFor(t) || coverFor(t);
|
||||
}
|
||||
|
||||
function applyOne(el) {
|
||||
if (!el || el.dataset.mockMediaApplied === '1') return;
|
||||
const src = imageFor(el);
|
||||
if (!src) return;
|
||||
el.dataset.mockMediaApplied = '1';
|
||||
el.classList.add('has-mock-media');
|
||||
el.style.setProperty('--mock-media-url', `url("${src}")`);
|
||||
el.style.backgroundImage = `url("${src}")`;
|
||||
}
|
||||
|
||||
function apply() {
|
||||
document.querySelectorAll('.placeholder, #ed-canvas').forEach(applyOne);
|
||||
}
|
||||
|
||||
function boot() {
|
||||
apply();
|
||||
const mo = new MutationObserver(() => apply());
|
||||
mo.observe(document.body, { childList: true, subtree: true });
|
||||
window.MockMedia = { apply, media };
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 678 KiB |
|
After Width: | Height: | Size: 683 KiB |
|
After Width: | Height: | Size: 723 KiB |
|
After Width: | Height: | Size: 738 KiB |
|
After Width: | Height: | Size: 894 KiB |
|
After Width: | Height: | Size: 1011 KiB |
|
After Width: | Height: | Size: 779 KiB |
|
After Width: | Height: | Size: 888 KiB |
|
After Width: | Height: | Size: 997 KiB |
|
After Width: | Height: | Size: 927 KiB |
|
After Width: | Height: | Size: 1008 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 782 KiB |
|
After Width: | Height: | Size: 800 KiB |
|
After Width: | Height: | Size: 1001 KiB |
|
After Width: | Height: | Size: 930 KiB |
|
After Width: | Height: | Size: 865 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 902 KiB |
|
After Width: | Height: | Size: 643 KiB |
@@ -0,0 +1,784 @@
|
||||
/* ============================================================
|
||||
新建商品 · 共享 Drawer 模块
|
||||
----------------------------------------------------------
|
||||
在任意页面只需 <script src="assets/new-product-drawer.js"> 引入,
|
||||
然后调用 NewProductDrawer.open({ onSave: fn }) 即可在当前页之上
|
||||
弹出右侧 Drawer。点击遮罩 / X / 取消 / ESC 关闭后,用户停在原页面。
|
||||
|
||||
提供:
|
||||
window.NewProductDrawer.open(opts?)
|
||||
window.NewProductDrawer.close()
|
||||
opts 字段:
|
||||
onSave(product) — 保存时回调,product = { id, name, cat, target,
|
||||
points: string[], images: { id, dataUrl, name }[] }
|
||||
============================================================ */
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.NewProductDrawer) return; // idempotent
|
||||
|
||||
const DRAWER_ID = 'npd-drawer';
|
||||
const DRAWER_BG_ID = 'npd-drawer-bg';
|
||||
|
||||
/* ---------- 注入样式(独立 namespace 以免与 products.html 冲突) ---------- */
|
||||
|
||||
const CSS = `
|
||||
/* drawer base (相同尺寸/动画) */
|
||||
#${DRAWER_BG_ID} {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(21, 20, 15, .32);
|
||||
display: none; z-index: 1100;
|
||||
}
|
||||
#${DRAWER_BG_ID}.show { display: block; }
|
||||
#${DRAWER_ID} {
|
||||
position: fixed; right: 0; top: 0; bottom: 0;
|
||||
width: 820px; max-width: 100vw;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border-faint);
|
||||
z-index: 1101;
|
||||
transform: translateX(100%);
|
||||
transition: transform .25s cubic-bezier(.32, .72, 0, 1);
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: -4px 0 24px rgba(21, 20, 15, .04);
|
||||
}
|
||||
#${DRAWER_ID}.show { transform: translateX(0); }
|
||||
#${DRAWER_ID} .drawer-h {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
#${DRAWER_ID} .drawer-h h3 { font-size: 16px; font-weight: 600; color: var(--accent-black); }
|
||||
#${DRAWER_ID} .drawer-h .x {
|
||||
margin-left: auto; width: 32px; height: 32px;
|
||||
border-radius: var(--r-md);
|
||||
display: grid; place-items: center;
|
||||
color: var(--black-alpha-56); cursor: pointer;
|
||||
background: transparent; border: 0;
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
#${DRAWER_ID} .drawer-h .x:hover { background: var(--black-alpha-4); color: var(--accent-black); }
|
||||
#${DRAWER_ID} .drawer-b { padding: 24px 28px; overflow-y: auto; flex: 1; overscroll-behavior: contain; }
|
||||
#${DRAWER_ID} .drawer-f {
|
||||
padding: 14px 24px;
|
||||
border-top: 1px solid var(--border-faint);
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
background: var(--surface);
|
||||
}
|
||||
#${DRAWER_ID} .drawer-f .btn-guide {
|
||||
margin-right: auto;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; color: var(--black-alpha-56);
|
||||
background: transparent; border: 0; cursor: pointer;
|
||||
padding: 8px 10px; border-radius: var(--r-md);
|
||||
font-family: inherit;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
#${DRAWER_ID} .drawer-f .btn-guide:hover { color: var(--accent-black); background: var(--black-alpha-4); }
|
||||
#${DRAWER_ID} .drawer-f .btn-guide svg { width: 14px; height: 14px; }
|
||||
|
||||
/* form-card */
|
||||
#${DRAWER_ID} .form-h {
|
||||
font-size: 15px; font-weight: 600; color: var(--accent-black);
|
||||
margin-bottom: 18px; padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
#${DRAWER_ID} .field { margin-bottom: 16px; }
|
||||
#${DRAWER_ID} .field:last-child { margin-bottom: 0; }
|
||||
#${DRAWER_ID} .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 16px; }
|
||||
#${DRAWER_ID} .field-label {
|
||||
display: block; font-size: 13px; font-weight: 500;
|
||||
color: var(--accent-black); margin-bottom: 6px;
|
||||
}
|
||||
#${DRAWER_ID} .field-label .req { color: var(--heat); margin-left: 2px; }
|
||||
#${DRAWER_ID} .field-label .opt {
|
||||
color: var(--black-alpha-48); font-weight: 400; font-size: 12px; margin-left: 6px;
|
||||
}
|
||||
#${DRAWER_ID} .input,
|
||||
#${DRAWER_ID} .select {
|
||||
width: 100%; height: 38px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--black-alpha-12);
|
||||
border-radius: var(--r-md);
|
||||
padding: 0 14px;
|
||||
font-size: 13.5px; color: var(--accent-black);
|
||||
outline: none; font-family: inherit;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
#${DRAWER_ID} .input:focus,
|
||||
#${DRAWER_ID} .select:focus {
|
||||
border-color: var(--heat-40);
|
||||
box-shadow: inset 0 0 0 1px var(--heat-40);
|
||||
}
|
||||
|
||||
/* upload */
|
||||
#${DRAWER_ID} .pf-upload-row {
|
||||
display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||
gap: 16px; align-items: stretch;
|
||||
}
|
||||
#${DRAWER_ID} .pf-upload-zone {
|
||||
border: 1.5px dashed var(--black-alpha-24);
|
||||
border-radius: var(--r-md);
|
||||
padding: 28px 20px;
|
||||
background: var(--background-lighter);
|
||||
cursor: pointer; text-align: center;
|
||||
transition: border-color var(--t-base), background var(--t-base);
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
min-height: 180px;
|
||||
}
|
||||
#${DRAWER_ID} .pf-upload-zone:hover { border-color: var(--heat); background: var(--heat-8); }
|
||||
#${DRAWER_ID} .pf-upload-zone .uz-ic {
|
||||
width: 44px; height: 44px;
|
||||
margin: 0 auto 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--heat-20);
|
||||
border-radius: var(--r-md);
|
||||
color: var(--heat);
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
#${DRAWER_ID} .pf-upload-zone .uz-ic svg { width: 20px; height: 20px; }
|
||||
#${DRAWER_ID} .pf-upload-zone .uz-t { font-size: 14px; color: var(--accent-black); font-weight: 500; }
|
||||
#${DRAWER_ID} .pf-upload-zone .uz-t strong { color: var(--heat); font-weight: 600; }
|
||||
#${DRAWER_ID} .pf-upload-zone .uz-d {
|
||||
margin-top: 8px;
|
||||
font-family: var(--font-mono); font-size: 11.5px;
|
||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||
}
|
||||
#${DRAWER_ID} .pf-example {
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
padding: 16px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
#${DRAWER_ID} .pf-example .ex-h { font-size: 13px; font-weight: 600; color: var(--accent-black); }
|
||||
#${DRAWER_ID} .pf-example .ex-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
#${DRAWER_ID} .pf-example .ex-grid .ex-thumb {
|
||||
aspect-ratio: 1;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden; position: relative;
|
||||
display: grid; place-items: center;
|
||||
color: var(--black-alpha-32);
|
||||
}
|
||||
#${DRAWER_ID} .pf-example .ex-grid .ex-thumb svg { width: 22px; height: 22px; }
|
||||
#${DRAWER_ID} .pf-example .ex-grid .ex-thumb::after {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
|
||||
pointer-events: none;
|
||||
}
|
||||
#${DRAWER_ID} .pf-example .ex-d { font-size: 12px; color: var(--black-alpha-56); line-height: 1.5; }
|
||||
#${DRAWER_ID} .pf-grid {
|
||||
display: grid; grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px; margin-top: 12px;
|
||||
}
|
||||
#${DRAWER_ID} .pf-grid:empty { display: none; }
|
||||
#${DRAWER_ID} .pf-thumb {
|
||||
aspect-ratio: 1;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
position: relative; overflow: hidden; cursor: pointer;
|
||||
}
|
||||
#${DRAWER_ID} .pf-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
#${DRAWER_ID} .pf-thumb .pf-x {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
width: 22px; height: 22px;
|
||||
background: rgba(0,0,0,.7); color: var(--accent-white);
|
||||
border: 0; border-radius: 50%; cursor: pointer;
|
||||
display: grid; place-items: center;
|
||||
opacity: 0; transition: opacity var(--t-base);
|
||||
}
|
||||
#${DRAWER_ID} .pf-thumb:hover .pf-x { opacity: 1; }
|
||||
#${DRAWER_ID} .pf-thumb .pf-x svg { width: 11px; height: 11px; }
|
||||
|
||||
/* bullet-list */
|
||||
#${DRAWER_ID} .bullet-list { list-style: none; padding: 0; margin: 0; }
|
||||
#${DRAWER_ID} .bullet-list .bl-item,
|
||||
#${DRAWER_ID} .bullet-list .bl-add {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
margin-bottom: 6px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
#${DRAWER_ID} .bullet-list .bl-add { background: transparent; border-style: dashed; }
|
||||
#${DRAWER_ID} .bullet-list .bl-add-row {
|
||||
margin: 8px 0 0;
|
||||
display: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
#${DRAWER_ID} .bullet-list .bl-add-row.show { display: flex; justify-content: flex-start; }
|
||||
#${DRAWER_ID} .bl-add-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 14px;
|
||||
background: var(--heat); color: #fff;
|
||||
border: 0; border-radius: var(--r-md);
|
||||
font-size: 13px; font-weight: 600;
|
||||
cursor: pointer; font-family: inherit;
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
#${DRAWER_ID} .bl-add-btn:hover { filter: brightness(.94); }
|
||||
#${DRAWER_ID} .bl-add-btn svg { width: 12px; height: 12px; }
|
||||
#${DRAWER_ID} .bullet-list .num {
|
||||
width: 22px; height: 22px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px; color: var(--heat); font-weight: 700;
|
||||
display: grid; place-items: center; flex-shrink: 0;
|
||||
}
|
||||
#${DRAWER_ID} .bullet-list .bl-text { flex: 1; color: var(--accent-black); }
|
||||
#${DRAWER_ID} .bullet-list .bl-input {
|
||||
flex: 1; background: transparent; border: 0; outline: none;
|
||||
font-size: 13.5px; color: var(--accent-black); font-family: inherit;
|
||||
}
|
||||
#${DRAWER_ID} .bullet-list .bl-x {
|
||||
width: 22px; height: 22px;
|
||||
color: var(--black-alpha-48);
|
||||
cursor: pointer; display: grid; place-items: center;
|
||||
border-radius: var(--r-sm);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
#${DRAWER_ID} .bullet-list .bl-x:hover { color: var(--accent-crimson); background: var(--crimson-bg); }
|
||||
#${DRAWER_ID} .bullet-list .bl-x svg { width: 11px; height: 11px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#${DRAWER_ID} .pf-upload-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* 放弃填写 · 确认弹窗 · 覆盖在 drawer 上 */
|
||||
#npd-confirm-bg {
|
||||
position: fixed; inset: 0; z-index: 1200;
|
||||
background: rgba(21, 20, 15, .42);
|
||||
display: grid; place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
#npd-confirm-bg[hidden] { display: none; }
|
||||
.npd-confirm-modal {
|
||||
width: min(420px, 92vw);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: 0 16px 48px rgba(21, 20, 15, .18);
|
||||
overflow: hidden;
|
||||
}
|
||||
.npd-confirm-modal .dh {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 18px 20px 14px;
|
||||
}
|
||||
.npd-confirm-modal .dh .ic {
|
||||
width: 36px; height: 36px; flex-shrink: 0;
|
||||
border-radius: var(--r-md);
|
||||
background: rgba(180, 30, 30, .08); color: var(--accent-crimson);
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.npd-confirm-modal .dh .ic svg { width: 18px; height: 18px; }
|
||||
.npd-confirm-modal .dh .ti { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.npd-confirm-modal .dh .ti strong { font-size: 14.5px; color: var(--accent-black); font-weight: 600; }
|
||||
.npd-confirm-modal .dh .ti .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; line-height: 1.5; }
|
||||
.npd-confirm-modal .df {
|
||||
display: flex; gap: 8px;
|
||||
padding: 0 20px 18px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.npd-confirm-modal .df button {
|
||||
height: 32px; padding: 0 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
font-size: 12.5px; color: var(--accent-black);
|
||||
font-family: inherit; cursor: pointer;
|
||||
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.npd-confirm-modal .df button:hover { border-color: var(--heat-20); background: var(--heat-12); color: var(--heat); }
|
||||
.npd-confirm-modal .df button.danger { color: var(--accent-crimson); }
|
||||
.npd-confirm-modal .df button.danger:hover {
|
||||
background: rgba(180, 30, 30, .08);
|
||||
border-color: rgba(180, 30, 30, .35);
|
||||
color: var(--accent-crimson);
|
||||
}
|
||||
`;
|
||||
|
||||
const HTML = `
|
||||
<div id="${DRAWER_BG_ID}"></div>
|
||||
<aside id="${DRAWER_ID}" role="dialog" aria-label="新建商品" aria-hidden="true">
|
||||
<div class="drawer-h">
|
||||
<h3>新建商品</h3>
|
||||
<button class="x" type="button" data-act="close" aria-label="关闭">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M6 18L18 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="drawer-b">
|
||||
<div class="form-card">
|
||||
<div class="form-h">基础信息</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">商品名称<span class="req">*</span></label>
|
||||
<input class="input" data-f="name" placeholder="请输入商品名称(必填)" maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div>
|
||||
<label class="field-label">品类<span class="req">*</span></label>
|
||||
<select class="select" data-f="cat">
|
||||
<option>美妆个护</option>
|
||||
<option>服饰内衣</option>
|
||||
<option>食品饮料</option>
|
||||
<option>家居家电</option>
|
||||
<option>数码 3C</option>
|
||||
<option>个护清洁</option>
|
||||
<option>运动户外</option>
|
||||
<option>母婴亲子</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">目标人群<span class="opt">(选填)</span></label>
|
||||
<input class="input" data-f="target" placeholder="例: 22-32 岁女性、敏感肌、办公室通勤">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">商品主图<span class="req">*</span></label>
|
||||
<input type="file" data-f="file" accept="image/*" multiple hidden>
|
||||
<div class="pf-upload-row">
|
||||
<div class="pf-upload-zone" data-act="upload-zone">
|
||||
<div class="uz-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
|
||||
</div>
|
||||
<div class="uz-t">点击上传或<strong>拖拽图片</strong>到此处</div>
|
||||
<div class="uz-d">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>
|
||||
</div>
|
||||
<div class="pf-example">
|
||||
<div class="ex-h">示例图</div>
|
||||
<div class="ex-grid">
|
||||
<div class="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4h10l1 4v12H6V8l1-4z"/><path d="M9 4v3M15 4v3M9 11h6M9 14h6"/></svg></div>
|
||||
<div class="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="5" width="12" height="15" rx="2"/><path d="M9 9h6M9 12h6M9 15h4"/></svg></div>
|
||||
<div class="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3h8l1 5v12H7V8l1-5z"/><circle cx="12" cy="13" r="2.5"/></svg></div>
|
||||
</div>
|
||||
<div class="ex-d">优质的商品图有助于生成更好的素材效果</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-grid" data-f="grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="field" style="margin-bottom: 0;">
|
||||
<label class="field-label">核心卖点<span class="req">*</span></label>
|
||||
<ul class="bullet-list" data-f="bullets">
|
||||
<li class="bl-add"><span class="num">+</span><input class="bl-input" placeholder="添加新卖点 · 回车或点击下方按钮"></li>
|
||||
<li class="bl-add-row" data-f="add-row">
|
||||
<button type="button" class="bl-add-btn" data-act="bl-add">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
添加卖点
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-f">
|
||||
<button class="btn-guide" type="button" data-act="guide">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 4M12 17h.01"/></svg>
|
||||
使用指南
|
||||
</button>
|
||||
<button class="btn" type="button" data-act="cancel">取消</button>
|
||||
<button class="btn btn-primary" type="button" data-act="save">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>
|
||||
创建商品
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 放弃填写 · 确认弹窗(覆盖在 drawer 之上) -->
|
||||
<div id="npd-confirm-bg" hidden>
|
||||
<div class="npd-confirm-modal" role="dialog" aria-label="放弃填写">
|
||||
<div class="dh">
|
||||
<div class="ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>
|
||||
</div>
|
||||
<div class="ti">
|
||||
<strong>放弃当前填写?</strong>
|
||||
<span class="mono">// 已填写的商品名、卖点、图片等内容将不会保留</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="df">
|
||||
<button type="button" data-act="keep">继续填写</button>
|
||||
<button type="button" class="danger" data-act="discard">放弃退出</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
/* ---------- DOM refs (populated by ensureInjected) ---------- */
|
||||
let injected = false;
|
||||
let bg, drawer, $f, $grid, $bullets, $blInput;
|
||||
let confirmBg;
|
||||
let _forceClose = false; // 保存成功后绕过脏检查
|
||||
let _initialCat = ''; // 打开时类目下拉的初始值,用来判断是否被改过
|
||||
let currentOpts = {};
|
||||
const PF_MAX = 5;
|
||||
let pfFiles = []; // { id, dataUrl, name }
|
||||
const blXSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
|
||||
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[<>&"]/g, c => ({ '<':'<','>':'>','&':'&','"':'"' })[c]); }
|
||||
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 5); }
|
||||
function toast(msg, sub) {
|
||||
if (typeof Shell !== 'undefined' && Shell && Shell.toast) Shell.toast(msg, sub);
|
||||
}
|
||||
|
||||
function ensureInjected() {
|
||||
if (injected) return;
|
||||
// style
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = CSS;
|
||||
document.head.appendChild(styleEl);
|
||||
// html
|
||||
const wrap = document.createElement('div');
|
||||
wrap.innerHTML = HTML;
|
||||
while (wrap.firstChild) document.body.appendChild(wrap.firstChild);
|
||||
|
||||
bg = document.getElementById(DRAWER_BG_ID);
|
||||
drawer = document.getElementById(DRAWER_ID);
|
||||
confirmBg = document.getElementById('npd-confirm-bg');
|
||||
$f = {
|
||||
name: drawer.querySelector('[data-f="name"]'),
|
||||
cat: drawer.querySelector('[data-f="cat"]'),
|
||||
target: drawer.querySelector('[data-f="target"]'),
|
||||
file: drawer.querySelector('[data-f="file"]'),
|
||||
};
|
||||
$grid = drawer.querySelector('[data-f="grid"]');
|
||||
$bullets = drawer.querySelector('[data-f="bullets"]');
|
||||
$blInput = $bullets.querySelector('.bl-add .bl-input');
|
||||
|
||||
bindEvents();
|
||||
injected = true;
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
// 关闭交互
|
||||
bg.addEventListener('click', close);
|
||||
drawer.addEventListener('click', e => {
|
||||
const a = e.target.closest('[data-act]');
|
||||
if (!a) return;
|
||||
const act = a.dataset.act;
|
||||
if (act === 'close') return close();
|
||||
if (act === 'cancel') return close();
|
||||
if (act === 'guide') return toast('使用指南', '点击查看完整填写指南');
|
||||
if (act === 'save') return save();
|
||||
if (act === 'upload-zone') return openFilePicker();
|
||||
if (act === 'bl-add') {
|
||||
blAdd($blInput.value);
|
||||
$blInput.value = '';
|
||||
$blInput.focus();
|
||||
updateBlAddBtn();
|
||||
return;
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && drawer.classList.contains('show')) close();
|
||||
});
|
||||
|
||||
// 上传
|
||||
$f.file.addEventListener('change', e => { addFiles(e.target.files); e.target.value = ''; });
|
||||
const zone = drawer.querySelector('[data-act="upload-zone"]');
|
||||
zone.addEventListener('dragover', e => { e.preventDefault(); zone.style.borderColor = 'var(--heat)'; });
|
||||
zone.addEventListener('dragleave', () => { zone.style.borderColor = ''; });
|
||||
zone.addEventListener('drop', e => {
|
||||
e.preventDefault(); zone.style.borderColor = '';
|
||||
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
// 卖点 bullet-list
|
||||
$blInput.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
blAdd($blInput.value);
|
||||
$blInput.value = '';
|
||||
updateBlAddBtn();
|
||||
}
|
||||
});
|
||||
$blInput.addEventListener('input', updateBlAddBtn);
|
||||
}
|
||||
|
||||
function updateBlAddBtn() {
|
||||
const row = $bullets && $bullets.querySelector('[data-f="add-row"]');
|
||||
if (!row) return;
|
||||
row.classList.toggle('show', !!($blInput.value || '').trim());
|
||||
}
|
||||
|
||||
function openFilePicker() { if (pfFiles.length < PF_MAX) $f.file.click(); }
|
||||
|
||||
function pfRender() {
|
||||
$grid.innerHTML = pfFiles.map(u => `
|
||||
<div class="pf-thumb" data-id="${u.id}">
|
||||
<img src="${u.dataUrl}" alt="${esc(u.name)}">
|
||||
<button class="pf-x" type="button" title="删除">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
$grid.querySelectorAll('.pf-thumb .pf-x').forEach(b => {
|
||||
b.onclick = e => {
|
||||
e.stopPropagation();
|
||||
const id = b.closest('.pf-thumb').dataset.id;
|
||||
const i = pfFiles.findIndex(f => f.id === id);
|
||||
if (i >= 0) { pfFiles.splice(i, 1); pfRender(); }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function addFiles(fileList) {
|
||||
const room = PF_MAX - pfFiles.length;
|
||||
if (room <= 0) { toast('已达上限', PF_MAX + ' / ' + PF_MAX + ' 张'); return; }
|
||||
const incoming = [...fileList].filter(f => f.type.startsWith('image/')).slice(0, room);
|
||||
let done = 0;
|
||||
incoming.forEach(f => {
|
||||
const r = new FileReader();
|
||||
r.onload = e => {
|
||||
pfFiles.push({ id: uid(), dataUrl: e.target.result, name: f.name });
|
||||
if (++done === incoming.length) {
|
||||
pfRender();
|
||||
toast('已上传', '+ ' + done + ' 张 · 共 ' + pfFiles.length + ' / ' + PF_MAX);
|
||||
}
|
||||
};
|
||||
r.readAsDataURL(f);
|
||||
});
|
||||
}
|
||||
|
||||
function blRenumber() {
|
||||
[...$bullets.querySelectorAll('.bl-item')].forEach((li, i) => {
|
||||
li.querySelector('.num').textContent = i + 1;
|
||||
});
|
||||
}
|
||||
function blAdd(text) {
|
||||
const t = (text || '').trim();
|
||||
if (!t) return;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'bl-item';
|
||||
li.innerHTML = '<span class="num">0</span><span class="bl-text">' + esc(t) + '</span><span class="bl-x" title="删除">' + blXSvg + '</span>';
|
||||
$bullets.querySelector('.bl-add').before(li);
|
||||
li.querySelector('.bl-x').addEventListener('click', () => {
|
||||
li.style.transition = 'opacity .15s, transform .15s';
|
||||
li.style.opacity = 0;
|
||||
li.style.transform = 'translateX(-8px)';
|
||||
setTimeout(() => { li.remove(); blRenumber(); }, 150);
|
||||
});
|
||||
blRenumber();
|
||||
}
|
||||
function getBullets() {
|
||||
return [...$bullets.querySelectorAll('.bl-item .bl-text')].map(t => t.textContent.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/* ---------- API ---------- */
|
||||
|
||||
function resetForm() {
|
||||
$f.name.value = '';
|
||||
$f.cat.value = $f.cat.options[0].value;
|
||||
$f.target.value = '';
|
||||
pfFiles = [];
|
||||
pfRender();
|
||||
[...$bullets.querySelectorAll('.bl-item')].forEach(li => li.remove());
|
||||
$blInput.value = '';
|
||||
updateBlAddBtn();
|
||||
}
|
||||
|
||||
function lockBody() {
|
||||
// 优先用 Shell 的引用计数实现(避免多 overlay 互相解锁)
|
||||
if (typeof Shell !== 'undefined' && Shell && typeof Shell.lockScroll === 'function') {
|
||||
Shell.lockScroll();
|
||||
return;
|
||||
}
|
||||
// 兜底: Shell 未加载时本地锁
|
||||
const docEl = document.documentElement;
|
||||
const sbw = window.innerWidth - docEl.clientWidth;
|
||||
drawer._lockSnap = {
|
||||
bodyOverflow: document.body.style.overflow,
|
||||
bodyPaddingRight: document.body.style.paddingRight,
|
||||
htmlOverflow: docEl.style.overflow,
|
||||
};
|
||||
document.body.style.overflow = 'hidden';
|
||||
docEl.style.overflow = 'hidden';
|
||||
if (sbw > 0) document.body.style.paddingRight = sbw + 'px';
|
||||
}
|
||||
function unlockBody() {
|
||||
if (typeof Shell !== 'undefined' && Shell && typeof Shell.unlockScroll === 'function') {
|
||||
Shell.unlockScroll();
|
||||
return;
|
||||
}
|
||||
const s = drawer._lockSnap;
|
||||
if (s) {
|
||||
document.body.style.overflow = s.bodyOverflow;
|
||||
document.body.style.paddingRight = s.bodyPaddingRight;
|
||||
document.documentElement.style.overflow = s.htmlOverflow;
|
||||
drawer._lockSnap = null;
|
||||
}
|
||||
}
|
||||
|
||||
function open(opts) {
|
||||
ensureInjected();
|
||||
if (drawer.classList.contains('show')) return; // 已开则不重复锁
|
||||
currentOpts = opts || {};
|
||||
resetForm();
|
||||
_initialCat = $f.cat.value; // 快照「打开时类目」用于脏检查
|
||||
bg.classList.add('show');
|
||||
drawer.classList.add('show');
|
||||
drawer.setAttribute('aria-hidden', 'false');
|
||||
lockBody();
|
||||
setTimeout(() => $f.name.focus(), 280);
|
||||
}
|
||||
|
||||
/* ---------- 脏检查 + 退出确认 ---------- */
|
||||
function isDirty() {
|
||||
if (!injected) return false;
|
||||
if (($f.name.value || '').trim()) return true;
|
||||
if (($f.target.value || '').trim()) return true;
|
||||
if (($blInput.value || '').trim()) return true;
|
||||
if (pfFiles.length > 0) return true;
|
||||
if ($bullets.querySelectorAll('.bl-item').length > 0) return true;
|
||||
if ($f.cat.value !== _initialCat) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function confirmDiscard() {
|
||||
return new Promise(resolve => {
|
||||
confirmBg.hidden = false;
|
||||
const buttons = confirmBg.querySelectorAll('button[data-act]');
|
||||
function done(choice) {
|
||||
confirmBg.hidden = true;
|
||||
buttons.forEach(b => b.onclick = null);
|
||||
confirmBg.onclick = null;
|
||||
document.removeEventListener('keydown', escHandler, true);
|
||||
resolve(choice === 'discard');
|
||||
}
|
||||
function escHandler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
done('keep');
|
||||
}
|
||||
}
|
||||
buttons.forEach(b => b.onclick = () => done(b.dataset.act));
|
||||
confirmBg.onclick = e => { if (e.target === confirmBg) done('keep'); };
|
||||
// 用 capture · 截在外层 Esc 监听器之前
|
||||
document.addEventListener('keydown', escHandler, true);
|
||||
});
|
||||
}
|
||||
|
||||
async function close() {
|
||||
if (!injected) return;
|
||||
if (!drawer.classList.contains('show')) return; // 已关则不重复解锁
|
||||
if (!_forceClose && isDirty()) {
|
||||
const ok = await confirmDiscard();
|
||||
if (!ok) return;
|
||||
}
|
||||
bg.classList.remove('show');
|
||||
drawer.classList.remove('show');
|
||||
drawer.setAttribute('aria-hidden', 'true');
|
||||
unlockBody();
|
||||
if (typeof currentOpts.onClose === 'function') currentOpts.onClose();
|
||||
}
|
||||
|
||||
function closeForce() {
|
||||
_forceClose = true;
|
||||
try { close(); } finally { _forceClose = false; }
|
||||
}
|
||||
|
||||
function save() {
|
||||
// 兜底:用户在卖点输入框敲了字但没回车/没点「添加卖点」就直接点创建,
|
||||
// 这里自动提交一次,避免静默丢失最后一条卖点(常见误操作)
|
||||
if ($blInput && ($blInput.value || '').trim()) {
|
||||
blAdd($blInput.value);
|
||||
$blInput.value = '';
|
||||
updateBlAddBtn();
|
||||
}
|
||||
|
||||
const name = ($f.name.value || '').trim();
|
||||
const cat = $f.cat.value;
|
||||
const target = ($f.target.value || '').trim();
|
||||
const points = getBullets();
|
||||
const images = pfFiles.slice();
|
||||
|
||||
if (!name) {
|
||||
toast('请填写商品名称');
|
||||
$f.name.focus();
|
||||
return;
|
||||
}
|
||||
if (images.length === 0) {
|
||||
toast('请上传商品主图', '至少 1 张');
|
||||
return;
|
||||
}
|
||||
if (points.length === 0) {
|
||||
toast('请添加核心卖点', '至少 1 条');
|
||||
$blInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const product = {
|
||||
id: 'np-' + uid(),
|
||||
name, cat, target,
|
||||
points,
|
||||
images,
|
||||
imgs: images.length,
|
||||
};
|
||||
// 持久化到 sessionStorage('fs-extra-products') · 仅当前标签页生命周期内有效
|
||||
// 关闭标签页/浏览器后自动清空,不会跨会话累积演示残留
|
||||
try {
|
||||
const KEY = 'fs-extra-products';
|
||||
const list = JSON.parse(sessionStorage.getItem(KEY) || '[]');
|
||||
list.push({
|
||||
id: product.id,
|
||||
name, cat, target,
|
||||
tags: '',
|
||||
assets: 0,
|
||||
videos: 0,
|
||||
bullets: points,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
sessionStorage.setItem(KEY, JSON.stringify(list));
|
||||
} catch (e) { /* storage 不可用降级到只跳转 */ }
|
||||
|
||||
// 短期 sessionStorage 缓存完整 product(含图片 dataUrl),供 detail 页 ?id=new
|
||||
// 读出后渲染。读完即清除,避免污染后续操作。
|
||||
try {
|
||||
sessionStorage.setItem('npd-last-created', JSON.stringify(product));
|
||||
} catch (e) { /* dataUrl 可能过大 → 退化为不带图,detail 仍能渲染文本字段 */ }
|
||||
|
||||
if (typeof currentOpts.onSave === 'function') {
|
||||
toast('商品已创建', '+ ' + name);
|
||||
currentOpts.onSave(product);
|
||||
closeForce();
|
||||
return;
|
||||
}
|
||||
// 默认行为: 不 close drawer · 跳转期间 drawer 仍覆盖 host 页面 → 视觉上彻底
|
||||
// 消除"闪 host"(浏览器导航开始后,整页被新页面替换,drawer 自然消失)
|
||||
toast('商品已创建', '+ ' + name);
|
||||
const url = 'product-detail.html?product=' + encodeURIComponent(name) + '&id=new';
|
||||
location.href = url;
|
||||
}
|
||||
|
||||
window.NewProductDrawer = { open, close };
|
||||
|
||||
/* ---------- sessionStorage 自动打开钩子 ---------- */
|
||||
// 任何页面只要在跳转前 sessionStorage.setItem('npd-auto-open','1') 即可,
|
||||
// 落地页加载完模块后,会自动 open() 一次并清掉 flag。
|
||||
// 用于:product-create.html 重定向后让落地页弹出 drawer,而不是用户重新点击。
|
||||
function checkAutoOpen() {
|
||||
try {
|
||||
if (sessionStorage.getItem('npd-auto-open') === '1') {
|
||||
sessionStorage.removeItem('npd-auto-open');
|
||||
// 延后一拍,确保宿主页面自己的 init 已经跑完
|
||||
setTimeout(() => open(), 50);
|
||||
}
|
||||
} catch (e) { /* sessionStorage 不可用就静默放弃 */ }
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', checkAutoOpen);
|
||||
} else {
|
||||
checkAutoOpen();
|
||||
}
|
||||
})();
|
||||
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,183 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>工作台 · Airshelf</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
|
||||
<style>
|
||||
.dash-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 24px; align-items: start; }
|
||||
.recent-row { display: grid; grid-template-columns: 54px 1fr 110px 130px 60px; align-items: center; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border-faint); cursor: pointer; }
|
||||
.recent-row .prog, .recent-row .pill, .recent-row .btn { justify-self: start; }
|
||||
.recent-row:last-child { border-bottom: 0; }
|
||||
.recent-row:hover { background: var(--background-lighter); }
|
||||
.recent-row .thumb { width: 54px; height: 70px; border-radius: var(--r-md); }
|
||||
.recent-meta .name { font-weight: 600; font-size: 13.5px; color: var(--accent-black); }
|
||||
.recent-meta .sub { font-size: 12px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .01em; }
|
||||
.shortcuts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.shortcut { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 16px; display: flex; align-items: flex-start; gap: 12px; cursor: pointer; transition: background var(--t-base); }
|
||||
.shortcut:hover { background: var(--black-alpha-4); }
|
||||
.shortcut .ic { width: 32px; height: 32px; background: var(--heat-12); color: var(--heat); display: grid; place-items: center; border-radius: var(--r-md); flex-shrink: 0; }
|
||||
.shortcut .ic svg { width: 16px; height: 16px; }
|
||||
.shortcut .t { font-size: 13px; font-weight: 600; }
|
||||
.shortcut .d { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .01em; }
|
||||
.tip { background: var(--surface); border: 1px dashed var(--border-faint); padding: 14px 16px; font-size: 12.5px; color: var(--black-alpha-56); line-height: 1.6; border-radius: var(--r-md); }
|
||||
.tip strong { color: var(--accent-black); font-weight: 600; display: block; margin-bottom: 4px; }
|
||||
.tip .mono { font-family: var(--font-mono); color: var(--heat); background: var(--heat-12); padding: 1px 5px; border-radius: var(--r-sm); font-size: 11.5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page">
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>欢迎回来,小李</h1>
|
||||
<div class="sub">
|
||||
<span class="mono">// 05.14 · 周三</span>
|
||||
<span>·</span>
|
||||
<span>你有 <b style="color:var(--accent-black)">3 个项目</b> 正在进行中</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn btn-create" href="javascript:void(0)" onclick="event.preventDefault(); window.NewProductDrawer && NewProductDrawer.open();">
|
||||
<span data-iconkit="productPlus" data-icon-size="16"></span>
|
||||
新建商品
|
||||
</a>
|
||||
<a class="btn btn-primary btn-lg btn-create" href="projects-new.html">
|
||||
<span data-iconkit="clapperboard" data-icon-size="16"></span>
|
||||
新建项目
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats with-corners">
|
||||
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
|
||||
<a class="stat" href="projects.html">
|
||||
<div class="lbl">总项目 <span class="badge">ALL</span></div>
|
||||
<div class="v">8</div>
|
||||
<div class="delta up"><span data-iconkit="arrowUp" data-icon-size="14"></span> 本月 +3</div>
|
||||
</a>
|
||||
<a class="stat" href="projects.html?filter=wip">
|
||||
<div class="lbl">进行中 <span class="badge">WIP</span></div>
|
||||
<div class="v">3</div>
|
||||
<div class="delta">2 个待审核</div>
|
||||
</a>
|
||||
<a class="stat" href="projects.html?filter=done">
|
||||
<div class="lbl">本月成片 <span class="badge">DONE</span></div>
|
||||
<div class="v">3</div>
|
||||
<div class="delta up"><span data-iconkit="arrowUp" data-icon-size="14"></span> 较上月 +33%</div>
|
||||
</a>
|
||||
<a class="stat" href="account.html">
|
||||
<div class="lbl">余额 <span class="badge">¥</span></div>
|
||||
<div class="v">¥327<small>.40</small></div>
|
||||
<div class="bar"><span style="width:33%"></span></div>
|
||||
<div class="sub">已用 ¥162.60 / ¥500</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="dash-grid">
|
||||
<div>
|
||||
<div class="section-h">
|
||||
<h2>最近项目</h2>
|
||||
<a class="more" href="projects.html">[ ALL · 8 ]</a>
|
||||
</div>
|
||||
<div class="card-hard">
|
||||
<a class="recent-row" href="pipeline.html?product=%E9%80%8F%E7%9C%9F%E8%A1%A5%E6%B0%B4%E9%9D%A2%E8%86%9C#stage-3">
|
||||
<div class="placeholder thumb"><span class="ph-frame">9:16</span></div>
|
||||
<div class="recent-meta">
|
||||
<div class="name">补水面膜 · 痛点种草 · v3</div>
|
||||
<div class="sub">透真补水面膜 / AI 全生 / 7 镜</div>
|
||||
</div>
|
||||
<div class="prog"><span class="done"></span><span class="done"></span><span class="cur"></span><span></span><span></span></div>
|
||||
<span class="pill info"><span class="dot"></span>故事板 待确认</span>
|
||||
<span class="btn btn-sm">继续</span>
|
||||
</a>
|
||||
<a class="recent-row" href="pipeline.html?product=%E9%80%8F%E7%9C%9F%E9%98%B2%E6%99%92#stage-5">
|
||||
<div class="placeholder thumb"><span class="ph-frame">9:16</span></div>
|
||||
<div class="recent-meta">
|
||||
<div class="name">透真防晒 · 通勤对比</div>
|
||||
<div class="sub">透真防晒 / AI 全生 / 6 镜</div>
|
||||
</div>
|
||||
<div class="prog"><span class="done"></span><span class="done"></span><span class="done"></span><span class="done"></span><span class="done"></span></div>
|
||||
<span class="pill ok"><span class="dot"></span>已完成</span>
|
||||
<span class="btn btn-sm">打开</span>
|
||||
</a>
|
||||
<a class="recent-row" href="pipeline.html?product=Pro%204%20%E8%93%9D%E7%89%99%E8%80%B3%E6%9C%BA#stage-4">
|
||||
<div class="placeholder thumb"><span class="ph-frame">9:16</span></div>
|
||||
<div class="recent-meta">
|
||||
<div class="name">蓝牙耳机 · 开箱测评</div>
|
||||
<div class="sub">Pro 4 蓝牙耳机 / 自带脚本 / 6 镜</div>
|
||||
</div>
|
||||
<div class="prog"><span class="done"></span><span class="done"></span><span class="done"></span><span class="cur"></span><span></span></div>
|
||||
<span class="pill info"><span class="dot"></span>视频生成 4/6</span>
|
||||
<span class="btn btn-sm">继续</span>
|
||||
</a>
|
||||
<a class="recent-row" href="pipeline.html?product=%E5%87%9D%E5%BD%A9%E7%AB%8B%E4%BD%93%E5%8F%A3%E7%BA%A2#stage-2">
|
||||
<div class="placeholder thumb"><span class="ph-frame">9:16</span></div>
|
||||
<div class="recent-meta">
|
||||
<div class="name">春日新品 · 立体口红</div>
|
||||
<div class="sub">凝彩立体口红 / 一句话 / 5 镜</div>
|
||||
</div>
|
||||
<div class="prog"><span class="done"></span><span class="cur"></span><span></span><span></span><span></span></div>
|
||||
<span class="pill info"><span class="dot"></span>资产生成中</span>
|
||||
<span class="btn btn-sm">继续</span>
|
||||
</a>
|
||||
<a class="recent-row" href="pipeline.html?product=%E5%86%B7%E8%90%83%E5%92%96%E5%95%A1%E5%86%BB%E5%B9%B2#stage-3">
|
||||
<div class="placeholder thumb"><span class="ph-frame">9:16</span></div>
|
||||
<div class="recent-meta">
|
||||
<div class="name">咖啡冻干 · 早八</div>
|
||||
<div class="sub">冷萃咖啡冻干 / 一句话 / 5 镜</div>
|
||||
</div>
|
||||
<div class="prog"><span class="done"></span><span class="done"></span><span class="fail"></span><span></span><span></span></div>
|
||||
<span class="pill err"><span class="dot"></span>故事板失败</span>
|
||||
<span class="btn btn-sm">查看</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:24px">
|
||||
<div>
|
||||
<div class="section-h"><h2>快捷入口</h2><span class="more">[ /shortcuts ]</span></div>
|
||||
<div class="shortcuts">
|
||||
<a class="shortcut" href="products.html">
|
||||
<div class="ic" data-iconkit="package"></div>
|
||||
<div><div class="t">商品库</div><div class="d">7 SKU</div></div>
|
||||
</a>
|
||||
<a class="shortcut" href="library.html">
|
||||
<div class="ic" data-iconkit="images"></div>
|
||||
<div><div class="t">资产库</div><div class="d">人 8 · 景 14 · 片 8</div></div>
|
||||
</a>
|
||||
<a class="shortcut" href="account.html">
|
||||
<div class="ic" data-iconkit="creditCard"></div>
|
||||
<div><div class="t">充值</div><div class="d">¥327.40</div></div>
|
||||
</a>
|
||||
<a class="shortcut" href="projects.html">
|
||||
<div class="ic" data-iconkit="clapperboard"></div>
|
||||
<div><div class="t">所有项目</div><div class="d">8 个</div></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="section-h"><h2>提示</h2><span class="more">[ FAQ ]</span></div>
|
||||
<div class="tip">
|
||||
<strong>扣费规则</strong>
|
||||
生成失败、超时、用户重跑 — 均不扣费。仅在你点 <span class="mono">[ 确认通过 ]</span> 时按 token 实际结算。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="assets/icons.js?v=2026052608"></script>
|
||||
<script src="assets/shell.js?v=2026060101"></script>
|
||||
<script src="assets/new-product-drawer.js?v=202605211643"></script>
|
||||
<script>
|
||||
document.querySelectorAll('[data-iconkit]').forEach(el => {
|
||||
el.innerHTML = IconKit.svg(el.dataset.iconkit, { size: Number(el.dataset.iconSize || 16) });
|
||||
});
|
||||
Shell.render({ active: 'dashboard', crumbs: [{ label: '工作台' }] });
|
||||
</script>
|
||||
<script src="assets/api-bridge.js?v=2026060114"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,220 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>登录 · Airshelf</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; background: var(--background-base); display: grid; place-items: center; padding: 32px 24px; }
|
||||
|
||||
.auth-wrap { width: 100%; max-width: 980px; display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 0; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); position: relative; overflow: hidden; min-height: 560px; }
|
||||
|
||||
/* 4 装订线 */
|
||||
.auth-wrap::before, .auth-wrap::after,
|
||||
.auth-wrap > .corner-tr, .auth-wrap > .corner-bl {
|
||||
content: ''; position: absolute; width: 14px; height: 14px;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center;
|
||||
background-size: contain; pointer-events: none; z-index: 2;
|
||||
}
|
||||
.auth-wrap::before { top: -7px; left: -7px; }
|
||||
.auth-wrap::after { bottom: -7px; right: -7px; }
|
||||
.auth-wrap > .corner-tr { top: -7px; right: -7px; }
|
||||
.auth-wrap > .corner-bl { bottom: -7px; left: -7px; }
|
||||
|
||||
/* 左侧品牌区 · 深色 */
|
||||
.auth-brand { background: var(--accent-black); color: var(--accent-white); padding: 40px 44px; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||||
.auth-brand .logo { display: flex; align-items: center; height: 56px; }
|
||||
.auth-brand .logo-img { display: block; width: 196px; height: auto; margin: -14px 0 -10px -14px; object-fit: contain; }
|
||||
.auth-brand .tag { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.5); letter-spacing: .08em; text-transform: uppercase; margin-top: 4px; }
|
||||
|
||||
.auth-brand .hero { margin-top: auto; margin-bottom: auto; }
|
||||
.auth-brand .hero h1 { font-size: 30px; font-weight: 700; letter-spacing: -.02em; line-height: 1.2; margin: 0 0 14px; }
|
||||
.auth-brand .hero h1 .h { color: var(--heat); }
|
||||
.auth-brand .hero p { font-size: 13.5px; color: rgba(255,255,255,.62); line-height: 1.7; max-width: 320px; margin: 0; }
|
||||
|
||||
/* ASCII 装饰行 */
|
||||
.auth-brand .ascii { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.32); letter-spacing: .04em; line-height: 1.8; margin-top: 28px; }
|
||||
.auth-brand .ascii .ln .k { color: rgba(255,255,255,.5); }
|
||||
.auth-brand .ascii .ln .v { color: var(--heat); }
|
||||
|
||||
.auth-brand .foot { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.32); letter-spacing: .04em; margin-top: 22px; display: flex; gap: 14px; }
|
||||
.auth-brand .foot a { color: rgba(255,255,255,.5); text-decoration: none; }
|
||||
.auth-brand .foot a:hover { color: var(--heat); }
|
||||
|
||||
/* 右侧表单区 */
|
||||
.auth-form { padding: 44px 44px 36px; display: flex; flex-direction: column; }
|
||||
.auth-form .h-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
|
||||
.auth-form h2 { font-size: 22px; font-weight: 600; letter-spacing: -.012em; margin: 0; }
|
||||
.auth-form .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; }
|
||||
.auth-form .lead { font-size: 13px; color: var(--black-alpha-56); margin: 0 0 28px; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
|
||||
.field-label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; }
|
||||
.field-label .req { color: var(--accent-crimson); }
|
||||
.field-input-wrap { position: relative; }
|
||||
.field-input-wrap .ic-l { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--black-alpha-32); width: 14px; height: 14px; pointer-events: none; }
|
||||
.field input { width: 100%; box-sizing: border-box; padding: 11px 12px 11px 36px; font-size: 13.5px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); font-family: inherit; color: var(--accent-black); transition: border-color var(--t-base), box-shadow var(--t-base); }
|
||||
.field input:focus { outline: none; border-color: var(--heat); box-shadow: 0 0 0 3px var(--heat-12); }
|
||||
.field input::placeholder { color: var(--black-alpha-32); }
|
||||
.field .toggle-pwd { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: var(--black-alpha-48); cursor: pointer; background: none; border: 0; padding: 0; display: grid; place-items: center; }
|
||||
.field .toggle-pwd:hover { color: var(--accent-black); }
|
||||
|
||||
.row-between { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 22px; font-size: 12.5px; }
|
||||
.row-between label { display: inline-flex; align-items: center; gap: 6px; color: var(--black-alpha-56); cursor: pointer; user-select: none; }
|
||||
.row-between label input { width: 13px; height: 13px; accent-color: var(--heat); }
|
||||
.row-between a { color: var(--heat); text-decoration: none; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .02em; }
|
||||
.row-between a:hover { text-decoration: underline; }
|
||||
|
||||
.btn-cta { background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-md); padding: 12px 14px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; transition: box-shadow var(--t-base), transform var(--t-base); display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
.btn-cta:hover { box-shadow: 0 4px 14px rgba(250,93,25,.28); }
|
||||
.btn-cta:active { transform: translateY(1px); }
|
||||
.btn-cta svg { width: 15px; height: 15px; }
|
||||
|
||||
.divider { display: flex; align-items: center; gap: 10px; margin: 22px 0 18px; }
|
||||
.divider .line { flex: 1; height: 1px; background: var(--border-faint); }
|
||||
.divider .txt { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-32); letter-spacing: .08em; text-transform: uppercase; }
|
||||
|
||||
.sso-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.sso-btn { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 10px 12px; font-size: 12.5px; color: var(--accent-black); cursor: pointer; font-family: inherit; display: inline-flex; align-items: center; justify-content: center; gap: 8px; transition: border-color var(--t-base); }
|
||||
.sso-btn:hover { border-color: var(--black-alpha-32); }
|
||||
.sso-btn svg { width: 14px; height: 14px; }
|
||||
|
||||
.switch-row { margin-top: auto; padding-top: 28px; text-align: center; font-size: 12.5px; color: var(--black-alpha-56); }
|
||||
.switch-row a { color: var(--heat); text-decoration: none; font-weight: 500; }
|
||||
.switch-row a:hover { text-decoration: underline; }
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 820px) {
|
||||
.auth-wrap { grid-template-columns: 1fr; min-height: 0; }
|
||||
.auth-brand { padding: 32px 28px; }
|
||||
.auth-brand .ascii { display: none; }
|
||||
.auth-form { padding: 32px 28px; }
|
||||
}
|
||||
|
||||
/* 顶部小返回链(可选) */
|
||||
.top-back { position: fixed; top: 20px; left: 24px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); text-decoration: none; letter-spacing: .04em; }
|
||||
.top-back:hover { color: var(--heat); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="top-back" href="index.html">← 返回工作台</a>
|
||||
|
||||
<div class="auth-wrap">
|
||||
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
|
||||
|
||||
<!-- 左:品牌 -->
|
||||
<aside class="auth-brand">
|
||||
<div class="logo">
|
||||
<img class="logo-img" src="assets/logo-dark.png" alt="Airshelf">
|
||||
</div>
|
||||
<div class="tag">// SHORT-VIDEO COMMERCE PLATFORM</div>
|
||||
|
||||
<div class="hero">
|
||||
<h1>AI 全流程<br><span class="h">短剧化</span>带货生成</h1>
|
||||
<p>商品 → AI 脚本 → 故事板 → Seedance 视频片段 → 一键导出 9:16 ≤60s 成片。</p>
|
||||
</div>
|
||||
|
||||
<div class="ascii">
|
||||
<div class="ln"><span class="k">// step·1</span> 脚本生成 <span class="v">●●●●●</span></div>
|
||||
<div class="ln"><span class="k">// step·2</span> 基础资产 <span class="v">●●●●○</span></div>
|
||||
<div class="ln"><span class="k">// step·3</span> 故事板 <span class="v">●●●○○</span></div>
|
||||
<div class="ln"><span class="k">// step·4</span> 视频片段 <span class="v">●●○○○</span></div>
|
||||
<div class="ln"><span class="k">// step·5</span> 拼接导出 <span class="v">●○○○○</span></div>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<a href="#">关于</a>
|
||||
<a href="#">定价</a>
|
||||
<a href="#">联系</a>
|
||||
<a href="#">隐私</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右:表单 -->
|
||||
<main class="auth-form">
|
||||
<div class="h-row">
|
||||
<h2>登录</h2>
|
||||
<span class="sub">// /auth/login</span>
|
||||
</div>
|
||||
<p class="lead">使用团队邀请邮箱登录,接受邀请后自动加入对应团队。</p>
|
||||
|
||||
<form id="login-form" autocomplete="off" onsubmit="event.preventDefault(); doLogin();">
|
||||
<div class="field">
|
||||
<label class="field-label" for="auth-email">邮箱 <span class="req">*</span></label>
|
||||
<div class="field-input-wrap">
|
||||
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><path d="m22 6-10 7L2 6"/></svg>
|
||||
<input type="email" id="auth-email" placeholder="name@company.com" value="li@shop.com" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="auth-pwd">密码 <span class="req">*</span></label>
|
||||
<div class="field-input-wrap">
|
||||
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
<input type="password" id="auth-pwd" placeholder="••••••••" value="demo-1234" required>
|
||||
<button type="button" class="toggle-pwd" aria-label="切换密码可见" onclick="togglePwd()">
|
||||
<svg id="pwd-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row-between">
|
||||
<label><input type="checkbox" checked> 记住我 7 天</label>
|
||||
<a href="#" onclick="event.preventDefault();_loginToast('已发送重置邮件','请到 li@shop.com 收件箱查看 · 链接 30 分钟有效');">忘记密码?</a>
|
||||
</div>
|
||||
|
||||
<button class="btn-cta" type="submit">
|
||||
登录
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="divider"><span class="line"></span><span class="txt">OR</span><span class="line"></span></div>
|
||||
|
||||
<div class="sso-row">
|
||||
<button type="button" class="sso-btn" onclick="_loginToast('微信扫码','请在 60s 内用微信扫一扫完成授权 · 内测中,以邮箱登录为准');">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 4C5 4 2 6.5 2 9.5c0 1.7 1 3.2 2.5 4.2L4 16l2.2-1.1c.7.2 1.5.3 2.3.3-.3-.5-.5-1.1-.5-1.7 0-2.8 2.9-5 6.5-5h.5C14.6 6 12 4 9 4zm-2 3.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zM15 9c-3.3 0-6 2.2-6 5s2.7 5 6 5c.7 0 1.4-.1 2-.3L19 20l-.4-1.7c1.4-.9 2.4-2.3 2.4-3.8 0-2.8-2.7-5-6-5zm-2 2.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1z"/></svg>
|
||||
微信扫码
|
||||
</button>
|
||||
<button type="button" class="sso-btn" onclick="_loginToast('飞书 SSO','即将打开企业飞书登录授权页 · 内测中,以邮箱登录为准');">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="18" height="18" rx="3"/></svg>
|
||||
飞书 SSO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="switch-row">
|
||||
还没账号? <a href="register.html">注册团队 →</a>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function togglePwd() {
|
||||
const inp = document.getElementById('auth-pwd');
|
||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
function doLogin() {
|
||||
const btn = document.querySelector('.btn-cta');
|
||||
btn.innerHTML = '<span style="font-family:var(--font-mono);font-size:12px;letter-spacing:.04em;">// 验证中...</span>';
|
||||
btn.disabled = true;
|
||||
setTimeout(() => { location.href = 'index.html'; }, 700);
|
||||
}
|
||||
// 轻量 toast(不依赖 shell.js,免登录页拉重)
|
||||
function _loginToast(t, sub) {
|
||||
let el = document.getElementById('__login-toast');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = '__login-toast';
|
||||
el.style.cssText = 'position:fixed;left:50%;bottom:36px;transform:translateX(-50%) translateY(20px);background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:12px 18px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:flex;flex-direction:column;gap:2px;opacity:0;transition:opacity .2s,transform .2s;z-index:9999;font-family:inherit;max-width:360px;';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.innerHTML = '<div style="font-size:13.5px;font-weight:600;color:#262626;">' + t + '</div>' + (sub ? '<div style="font-size:11.5px;color:rgba(0,0,0,.56);font-family:\'Inter\',system-ui,sans-serif;letter-spacing:.02em;">// ' + sub + '</div>' : '');
|
||||
requestAnimationFrame(() => { el.style.opacity = '1'; el.style.transform = 'translateX(-50%) translateY(0)'; });
|
||||
clearTimeout(el._t);
|
||||
el._t = setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateX(-50%) translateY(20px)'; }, 2800);
|
||||
}
|
||||
</script>
|
||||
<script src="assets/api-bridge.js?v=2026060114"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,743 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>消息中心 · Airshelf</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
|
||||
<style>
|
||||
.msg-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.msg-page .page-head {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.msg-head-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.msg-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
|
||||
min-height: 640px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.msg-panel {
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.msg-inbox,
|
||||
.msg-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.msg-inbox { border-right: 1px solid var(--border-faint); }
|
||||
.msg-panel-h {
|
||||
min-height: 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
.msg-panel-h .ti {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-black);
|
||||
}
|
||||
.msg-panel-h .mono {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--black-alpha-48);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.msg-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
.msg-filter {
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface);
|
||||
color: var(--black-alpha-56);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.msg-filter:hover {
|
||||
border-color: var(--black-alpha-24);
|
||||
color: var(--accent-black);
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
.msg-filter.active {
|
||||
border-color: var(--heat-20);
|
||||
background: var(--heat-12);
|
||||
color: var(--heat);
|
||||
font-weight: 600;
|
||||
}
|
||||
.msg-filter .ct {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.msg-search {
|
||||
position: relative;
|
||||
padding: 0 14px 12px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
.msg-search svg {
|
||||
position: absolute;
|
||||
left: 26px;
|
||||
top: 10px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
color: var(--black-alpha-48);
|
||||
pointer-events: none;
|
||||
}
|
||||
.msg-search input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 12px 0 32px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--background-lighter);
|
||||
color: var(--accent-black);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.msg-search input:focus {
|
||||
background: var(--surface);
|
||||
border-color: var(--heat-40);
|
||||
box-shadow: inset 0 0 0 1px var(--heat-40);
|
||||
}
|
||||
.msg-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.msg-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.msg-item:hover { background: var(--black-alpha-4); }
|
||||
.msg-item.active { background: var(--heat-12); }
|
||||
.msg-item.active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--heat);
|
||||
}
|
||||
.msg-item.read .msg-item-title { color: var(--black-alpha-56); font-weight: 500; }
|
||||
.msg-type-ic {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--background-lighter);
|
||||
color: var(--black-alpha-72);
|
||||
}
|
||||
.msg-type-ic svg { width: 14px; height: 14px; }
|
||||
.msg-type-ic.task { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
|
||||
.msg-type-ic.team { background: var(--black-alpha-4); color: var(--accent-black); }
|
||||
.msg-type-ic.billing { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }
|
||||
.msg-type-ic.system { background: var(--black-alpha-7); color: var(--black-alpha-72); }
|
||||
.msg-item-main { min-width: 0; }
|
||||
.msg-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.msg-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--heat);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.msg-item.read .msg-dot { display: none; }
|
||||
.msg-item-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--accent-black);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.msg-time {
|
||||
flex-shrink: 0;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.msg-brief {
|
||||
margin-top: 4px;
|
||||
color: var(--black-alpha-56);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.msg-item-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.msg-priority {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--background-lighter);
|
||||
color: var(--black-alpha-56);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.msg-priority.ok { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); }
|
||||
.msg-priority.warn { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }
|
||||
.msg-priority.err { background: var(--crimson-bg); border-color: var(--crimson-bd); color: var(--accent-crimson); }
|
||||
.msg-priority.info { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
|
||||
.msg-empty {
|
||||
min-height: 320px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: var(--black-alpha-48);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.msg-empty svg { width: 24px; height: 24px; color: var(--black-alpha-40); }
|
||||
.msg-detail-empty {
|
||||
flex: 1;
|
||||
min-height: 520px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
color: var(--black-alpha-48);
|
||||
text-align: center;
|
||||
}
|
||||
.msg-detail-empty .ic {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
.msg-detail-empty svg { width: 21px; height: 21px; }
|
||||
.msg-detail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 22px 24px 24px;
|
||||
}
|
||||
.msg-detail-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
.msg-detail-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.msg-detail-title h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 1.35;
|
||||
font-weight: 600;
|
||||
letter-spacing: -.012em;
|
||||
color: var(--accent-black);
|
||||
}
|
||||
.msg-detail-title .meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.msg-body-text {
|
||||
margin: 18px 0 0;
|
||||
color: var(--accent-black);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.msg-props {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
gap: 10px 16px;
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
.msg-props .k {
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.msg-props .v {
|
||||
min-width: 0;
|
||||
color: var(--accent-black);
|
||||
font-size: 13px;
|
||||
}
|
||||
.msg-props .v a { color: var(--heat); }
|
||||
.msg-timeline {
|
||||
margin-top: 18px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.msg-timeline-h {
|
||||
margin-bottom: 12px;
|
||||
color: var(--accent-black);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.msg-step {
|
||||
display: grid;
|
||||
grid-template-columns: 68px 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--border-faint);
|
||||
}
|
||||
.msg-step:first-of-type { border-top: 0; padding-top: 0; }
|
||||
.msg-step .t {
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.msg-step .d {
|
||||
color: var(--black-alpha-72);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.msg-log {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--accent-black);
|
||||
color: var(--accent-white);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
letter-spacing: .02em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.msg-detail-f {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid var(--border-faint);
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
.msg-detail-f .spacer { flex: 1; }
|
||||
.msg-foot-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.msg-foot-note a { color: var(--heat); }
|
||||
@media (max-width: 1280px) {
|
||||
.msg-workbench { grid-template-columns: minmax(300px, 340px) minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.msg-workbench { grid-template-columns: 1fr; }
|
||||
.msg-inbox { border-right: 0; border-bottom: 1px solid var(--border-faint); }
|
||||
.msg-list { max-height: 360px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page">
|
||||
<div class="msg-page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>消息中心</h1>
|
||||
<div class="sub"><span class="mono" id="msg-head-sub">// INBOX</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
|
||||
</div>
|
||||
<div class="msg-head-actions">
|
||||
<button class="btn" type="button" id="msg-mark-all">全部标已读</button>
|
||||
<button class="btn" type="button" id="msg-settings">通知设置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="msg-workbench">
|
||||
<section class="msg-panel msg-inbox">
|
||||
<div class="msg-panel-h">
|
||||
<span class="ti">收件箱</span>
|
||||
<span class="mono" id="msg-list-count">// 0 条</span>
|
||||
</div>
|
||||
<div class="msg-filters" id="msg-filters"></div>
|
||||
<div class="msg-search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input id="msg-search" type="text" placeholder="搜索项目、来源、内容">
|
||||
</div>
|
||||
<div class="msg-list" id="msg-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="msg-panel msg-detail" id="msg-detail"></section>
|
||||
</div>
|
||||
|
||||
<div class="msg-foot-note">
|
||||
<span>// 消息保留 90 天 · 高风险任务会同时进入工作台队列</span>
|
||||
<a href="settings.html#sec-notify">管理通知策略 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/icons.js?v=2026052608"></script>
|
||||
<script src="assets/shell.js?v=2026060101"></script>
|
||||
<script>
|
||||
Shell.render({ active: '', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '消息中心' }] });
|
||||
|
||||
(function () {
|
||||
const NOW = new Date('2026-05-28T10:30:00');
|
||||
const $ = sel => document.querySelector(sel);
|
||||
const icon = name => window.IconKit ? window.IconKit.svg(name) : '';
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||||
const mins = n => new Date(NOW.getTime() - n * 60000);
|
||||
const zhType = { all: '全部', unread: '未读', task: '任务', team: '团队', billing: '计费', system: '系统' };
|
||||
const typeIcon = { task: 'clapperboard', team: 'users', billing: 'creditCard', system: 'info' };
|
||||
const priLabel = { ok: '已完成', warn: '需关注', err: '风险', info: '更新' };
|
||||
|
||||
function fmtTime(d) {
|
||||
const diff = Math.round((NOW - d) / 60000);
|
||||
if (diff < 60) return diff + 'm';
|
||||
if (diff < 1440) return Math.floor(diff / 60) + 'h';
|
||||
return Math.floor(diff / 1440) + 'd';
|
||||
}
|
||||
function fmtFull(d) {
|
||||
const z = n => String(n).padStart(2, '0');
|
||||
return d.getFullYear() + '-' + z(d.getMonth() + 1) + '-' + z(d.getDate()) + ' ' + z(d.getHours()) + ':' + z(d.getMinutes());
|
||||
}
|
||||
|
||||
const messages = [
|
||||
{
|
||||
id: 'task-video-ready', type: 'task', priority: 'ok', unread: true,
|
||||
title: '补水面膜 · 痛点种草 v3 成片已完成',
|
||||
brief: '7 镜 · 40 秒 · ¥18.40 已结算,可确认进入投放阶段。',
|
||||
body: 'Seedance 视频生成全部完成,本次输出 3 条 9:16 成片。系统检测到镜头 3 的停留率预测最高,建议优先作为首版投放素材。',
|
||||
source: '视频生成队列', project: '补水面膜', stage: 'Stage 5 · 拼接导出', owner: '李', cost: '¥18.40',
|
||||
time: mins(12), href: 'pipeline.html?product=' + encodeURIComponent('补水面膜'),
|
||||
log: 'render_id=vid-20260528-1030\nstatus=ok\nclips=7\nratio=9:16\ncharge=18.40',
|
||||
timeline: [['10:06', '脚本与镜头配置锁定'], ['10:14', '3 条视频完成生成'], ['10:26', '字幕/BGM 合成完成'], ['10:30', '成片已入库,等待确认投放']],
|
||||
actions: [{ id: 'goto', label: '进入项目', primary: true }, { id: 'preview', label: '查看成片' }, { id: 'rerun', label: '重跑视频' }],
|
||||
},
|
||||
{
|
||||
id: 'task-script-failed', type: 'task', priority: 'err', unread: true,
|
||||
title: '脚本生成失败 · 618 大促',
|
||||
brief: 'Prompt 超过服务限制,本次失败未扣费,可一键重试。',
|
||||
body: '脚本助手收到的商品卖点和参考文案过长,超过当前脚本模型单次上下文限制。系统已保存失败日志,并建议先压缩参考内容再重跑。',
|
||||
source: '脚本助手', project: '618 大促', stage: 'Stage 1 · 脚本', owner: '张', cost: '¥0.00',
|
||||
time: mins(64), href: 'pipeline.html?product=' + encodeURIComponent('618 大促'),
|
||||
log: 'request_id=script-413\nerror=context_limit\nprompt_tokens=8124\nmax_tokens=8000\ncharged=false',
|
||||
timeline: [['09:12', '提交脚本生成'], ['09:13', '模型返回 context_limit'], ['09:13', '费用回滚完成']],
|
||||
actions: [{ id: 'rerun', label: '重试生成', primary: true }, { id: 'log', label: '查看日志' }],
|
||||
},
|
||||
{
|
||||
id: 'task-asset-done', type: 'task', priority: 'ok', unread: true,
|
||||
title: '4 张模特上身图已加入资产库',
|
||||
brief: '祛痘精华 · 通勤白领 · 3:4,可直接进入视频项目。',
|
||||
body: '模特上身图通过基础审核,已同步到资产库「未分类」。建议把其中 2 张加入祛痘精华项目作为 Stage 3 镜头参考。',
|
||||
source: '图片生成', project: '祛痘精华', stage: '图片生成 · 模特上身图', owner: '陈', cost: '¥3.20',
|
||||
time: mins(140), href: 'library.html',
|
||||
timeline: [['08:05', '收到 2 张参考图'], ['08:08', '生成 4 张候选'], ['08:11', '通过筛选并写入资产库']],
|
||||
actions: [{ id: 'goto', label: '查看素材', primary: true }, { id: 'attach', label: '加入项目' }],
|
||||
},
|
||||
{
|
||||
id: 'team-edit-lock', type: 'team', priority: 'info', unread: true,
|
||||
title: '@刘 正在编辑「补水面膜」项目',
|
||||
brief: '你暂时以只读方式查看该项目,避免两人同时改动覆盖。',
|
||||
body: '系统检测到同项目正在被团队成员编辑。为避免脚本、镜头或资产配置被互相覆盖,后进入者会进入只读查看状态。编辑结束后会自动恢复操作权限。',
|
||||
source: '协作锁定', project: '补水面膜', stage: 'Stage 3 · 镜头', owner: '刘', cost: '-',
|
||||
time: mins(210), href: 'pipeline.html?product=' + encodeURIComponent('补水面膜') + '#stage-3',
|
||||
timeline: [['07:00', '刘进入项目编辑'], ['07:01', '系统为后进入成员开启只读状态']],
|
||||
actions: [{ id: 'goto', label: '查看项目', primary: true }, { id: 'ack', label: '我已了解' }],
|
||||
},
|
||||
{
|
||||
id: 'team-member', type: 'team', priority: 'info', unread: false,
|
||||
title: '@王芳 已加入团队',
|
||||
brief: '角色为运营,月限额 ¥800,可读写团队资产。',
|
||||
body: '新成员已完成邮箱验证。她可以查看并编辑团队项目,但不能调整计费、安全与团队角色设置。',
|
||||
source: '@李(超管)', project: '团队', stage: '成员管理', owner: '王', cost: '-',
|
||||
time: mins(320), href: 'team.html',
|
||||
timeline: [['05:02', '邀请邮件送达'], ['05:18', '成员完成验证'], ['05:19', '角色权限生效']],
|
||||
actions: [{ id: 'goto', label: '查看团队', primary: true }, { id: 'settings', label: '调整权限' }],
|
||||
},
|
||||
{
|
||||
id: 'billing-low', type: 'billing', priority: 'warn', unread: false,
|
||||
title: '团队余额低于预警线',
|
||||
brief: '当前余额 ¥87.20,按近 7 天速度预计可支撑 4 个视频项目。',
|
||||
body: '余额预警阈值为 ¥100。若今天继续批量生成视频,建议先充值或临时降低成员月限额。',
|
||||
source: '计费中心', project: '消费', stage: '余额监控', owner: '系统', cost: '¥87.20',
|
||||
time: mins(460), href: 'account.html',
|
||||
timeline: [['02:50', '余额低于 ¥100'], ['02:51', '已发送站内通知']],
|
||||
actions: [{ id: 'goto', label: '前往充值', primary: true }, { id: 'settings', label: '预警设置' }],
|
||||
},
|
||||
{
|
||||
id: 'system-maintenance', type: 'system', priority: 'info', unread: false,
|
||||
title: '视频生成服务今晚例行维护',
|
||||
brief: '23:00 - 01:00 期间提交任务会自动排队。',
|
||||
body: '维护期间已进入生成中的任务不受影响。新提交的视频生成、三视图生成会进入队列,维护结束后按提交时间继续处理。',
|
||||
source: 'Airshelf 运维', project: '系统', stage: '服务公告', owner: '系统', cost: '-',
|
||||
time: mins(720), href: '',
|
||||
timeline: [['昨天', '发布维护窗口'], ['今晚', '队列自动延后处理']],
|
||||
actions: [{ id: 'ack', label: '我已了解', primary: true }],
|
||||
},
|
||||
{
|
||||
id: 'system-review', type: 'system', priority: 'ok', unread: false,
|
||||
title: '祛痘精华首版素材通过平台审核',
|
||||
brief: '4 张图 / 2 条视频审核通过,可进入投放。',
|
||||
body: '素材合规审核耗时 1 小时 12 分钟。系统未发现夸大功效、违禁词或画面遮挡问题。',
|
||||
source: '审核中台', project: '祛痘精华', stage: 'Stage 5 · 投放', owner: '系统', cost: '-',
|
||||
time: mins(1120), href: 'pipeline.html?product=' + encodeURIComponent('祛痘精华'),
|
||||
timeline: [['昨天 16:40', '提交平台审核'], ['昨天 17:52', '审核通过'], ['昨天 17:53', '写入项目状态']],
|
||||
actions: [{ id: 'goto', label: '进入投放', primary: true }],
|
||||
},
|
||||
];
|
||||
|
||||
const state = { tab: 'all', q: '', selectedId: messages[0].id, showLogId: null };
|
||||
|
||||
function counts() {
|
||||
return {
|
||||
unread: messages.filter(m => m.unread).length,
|
||||
task: messages.filter(m => m.type === 'task').length,
|
||||
team: messages.filter(m => m.type === 'team').length,
|
||||
billing: messages.filter(m => m.type === 'billing').length,
|
||||
system: messages.filter(m => m.type === 'system').length,
|
||||
risk: messages.filter(m => m.priority === 'err' || m.priority === 'warn').length,
|
||||
};
|
||||
}
|
||||
function visibleMessages() {
|
||||
const q = state.q.toLowerCase();
|
||||
return messages.filter(m => {
|
||||
if (state.tab === 'unread' && !m.unread) return false;
|
||||
if (!['all', 'unread'].includes(state.tab) && m.type !== state.tab) return false;
|
||||
if (q && ![m.title, m.brief, m.body, m.source, m.project, m.stage].join(' ').toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
const c = counts();
|
||||
$('#msg-head-sub').textContent = '// ' + c.unread + ' 条未读 · ' + messages.length + ' 条总计';
|
||||
}
|
||||
|
||||
function renderFilters() {
|
||||
const c = counts();
|
||||
const filters = [
|
||||
['all', '全部', messages.length],
|
||||
['unread', '未读', c.unread],
|
||||
['task', '任务', c.task],
|
||||
['team', '团队', c.team],
|
||||
['billing', '计费', c.billing],
|
||||
['system', '系统', c.system],
|
||||
];
|
||||
$('#msg-filters').innerHTML = filters.map(([id, label, ct]) => `
|
||||
<button class="msg-filter${state.tab === id ? ' active' : ''}" type="button" data-tab="${id}">
|
||||
${label}<span class="ct">${ct}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
$('#msg-filters').querySelectorAll('[data-tab]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.tab = btn.dataset.tab;
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const list = visibleMessages();
|
||||
$('#msg-list-count').textContent = '// 显示 ' + list.length + ' 条';
|
||||
if (!list.length) {
|
||||
$('#msg-list').innerHTML = `<div class="msg-empty">${icon('search')}<span>没有符合条件的消息</span></div>`;
|
||||
return;
|
||||
}
|
||||
$('#msg-list').innerHTML = list.map(m => `
|
||||
<button class="msg-item${state.selectedId === m.id ? ' active' : ''}${m.unread ? '' : ' read'}" type="button" data-id="${esc(m.id)}">
|
||||
<span class="msg-type-ic ${esc(m.type)}">${icon(typeIcon[m.type] || 'info')}</span>
|
||||
<span class="msg-item-main">
|
||||
<span class="msg-item-row">
|
||||
<span class="msg-dot"></span>
|
||||
<span class="msg-item-title">${esc(m.title)}</span>
|
||||
<span class="msg-time">${fmtTime(m.time)}</span>
|
||||
</span>
|
||||
<span class="msg-brief">${esc(m.brief)}</span>
|
||||
<span class="msg-item-foot">
|
||||
<span class="msg-priority ${esc(m.priority)}">${esc(priLabel[m.priority] || '更新')}</span>
|
||||
<span class="msg-priority">${esc(m.project)}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
`).join('');
|
||||
$('#msg-list').querySelectorAll('[data-id]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.selectedId = btn.dataset.id;
|
||||
const msg = messages.find(m => m.id === state.selectedId);
|
||||
if (msg) msg.unread = false;
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
const m = messages.find(x => x.id === state.selectedId) || visibleMessages()[0] || messages[0];
|
||||
if (!m) {
|
||||
$('#msg-detail').innerHTML = `<div class="msg-detail-empty"><div class="ic">${icon('bell')}</div><div>暂无消息</div></div>`;
|
||||
return;
|
||||
}
|
||||
state.selectedId = m.id;
|
||||
const props = [
|
||||
['来源', m.source],
|
||||
['类别', zhType[m.type]],
|
||||
['项目', m.project],
|
||||
['阶段', m.stage],
|
||||
['负责人', m.owner],
|
||||
['费用', m.cost],
|
||||
['时间', fmtFull(m.time)],
|
||||
['关联资源', m.href ? `<a href="${esc(m.href)}">${esc(m.project)} →</a>` : '无'],
|
||||
].map(([k, v]) => `<span class="k">${k}</span><span class="v">${v}</span>`).join('');
|
||||
const actions = m.actions.map(a => `<button class="btn${a.primary ? ' btn-primary' : ''}" type="button" data-action="${esc(a.id)}">${esc(a.label)}</button>`).join('');
|
||||
$('#msg-detail').innerHTML = `
|
||||
<div class="msg-detail-body">
|
||||
<div class="msg-detail-top">
|
||||
<span class="msg-type-ic ${esc(m.type)}">${icon(typeIcon[m.type] || 'info')}</span>
|
||||
<div class="msg-detail-title">
|
||||
<h2>${esc(m.title)}</h2>
|
||||
<div class="meta"><span>${esc(m.source)}</span><span>// ${esc(zhType[m.type])}</span><span>${fmtFull(m.time)}</span></div>
|
||||
</div>
|
||||
<span class="msg-priority ${esc(m.priority)}">${esc(priLabel[m.priority] || '更新')}</span>
|
||||
</div>
|
||||
<p class="msg-body-text">${esc(m.body)}</p>
|
||||
<div class="msg-props">${props}</div>
|
||||
<div class="msg-timeline">
|
||||
<div class="msg-timeline-h">处理记录</div>
|
||||
${m.timeline.map(([t, d]) => `<div class="msg-step"><span class="t">${esc(t)}</span><span class="d">${esc(d)}</span></div>`).join('')}
|
||||
</div>
|
||||
${state.showLogId === m.id && m.log ? `<pre class="msg-log">${esc(m.log)}</pre>` : ''}
|
||||
</div>
|
||||
<div class="msg-detail-f">
|
||||
<button class="btn btn-ghost" type="button" data-action="archive">归档</button>
|
||||
<button class="btn btn-ghost" type="button" data-action="mute">静音同类</button>
|
||||
<span class="spacer"></span>
|
||||
${actions}
|
||||
</div>
|
||||
`;
|
||||
$('#msg-detail').querySelectorAll('[data-action]').forEach(btn => {
|
||||
btn.addEventListener('click', () => runAction(m, btn.dataset.action));
|
||||
});
|
||||
}
|
||||
|
||||
function runAction(m, act) {
|
||||
if (!m) return;
|
||||
if (act === 'goto') {
|
||||
if (m.href) location.href = m.href;
|
||||
else location.href = 'settings.html#sec-notify';
|
||||
return;
|
||||
}
|
||||
if (act === 'preview') { Shell.toast('打开视频预览', m.project + ' · 成片'); return; }
|
||||
if (act === 'rerun') {
|
||||
m.priority = 'info';
|
||||
m.brief = '任务已重新排队,完成后会再次提醒。';
|
||||
Shell.toast('已重新排队', m.project + ' · ' + m.stage);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (act === 'log') { state.showLogId = state.showLogId === m.id ? null : m.id; renderDetail(); return; }
|
||||
if (act === 'attach') { location.href = 'projects-new.html'; return; }
|
||||
if (act === 'settings') { location.href = 'settings.html#sec-notify'; return; }
|
||||
if (act === 'ack') { m.unread = false; Shell.toast('已确认', m.title); render(); return; }
|
||||
if (act === 'archive') {
|
||||
const i = messages.findIndex(x => x.id === m.id);
|
||||
if (i >= 0) messages.splice(i, 1);
|
||||
state.selectedId = messages[0]?.id || null;
|
||||
Shell.toast('已归档', m.title);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (act === 'mute') { Shell.toast('已静音同类', zhType[m.type] + ' 类提醒可在设置恢复'); return; }
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderSummary();
|
||||
renderFilters();
|
||||
renderList();
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
$('#msg-search').addEventListener('input', e => {
|
||||
state.q = e.target.value.trim();
|
||||
renderList();
|
||||
});
|
||||
$('#msg-mark-all').addEventListener('click', () => {
|
||||
messages.forEach(m => { m.unread = false; });
|
||||
Shell.toast('已全部标为已读', messages.length + ' 条');
|
||||
render();
|
||||
});
|
||||
$('#msg-settings').addEventListener('click', () => { location.href = 'settings.html#sec-notify'; });
|
||||
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
<script src="assets/api-bridge.js?v=2026060114"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,666 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>方案 A · 商品空间 · 模特上身图 · Airshelf</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
|
||||
<style>
|
||||
.app { height: 100vh; overflow: hidden; }
|
||||
main { display: flex; flex-direction: column; min-height: 0; }
|
||||
#page-content { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0; }
|
||||
|
||||
/* ===== 两栏:左侧栏 / 右侧主区 ===== */
|
||||
.dma {
|
||||
flex: 1; min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
}
|
||||
|
||||
/* ── 左侧栏 · 仅商品空间(搜索 + 列表 + 全部入口) ── */
|
||||
.dma-side {
|
||||
border-right: 1px solid var(--border-faint);
|
||||
background: var(--surface);
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.dma-side-h {
|
||||
padding: 14px 14px 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dma-side-h .ti-row {
|
||||
display: flex; align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.dma-side-h .ti {
|
||||
font-size: 11px; font-family: var(--font-mono);
|
||||
color: var(--black-alpha-48); letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dma-side-h .ti-row .add {
|
||||
margin-left: auto;
|
||||
width: 22px; height: 22px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--heat-12); color: var(--heat);
|
||||
border: 0; border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.dma-side-h .add svg { width: 11px; height: 11px; }
|
||||
/* 搜索框 (Q1-A) */
|
||||
.dma-search {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
height: 32px; padding: 0 10px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
transition: border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.dma-search:focus-within { border-color: var(--heat-40); background: var(--surface); }
|
||||
.dma-search svg { width: 13px; height: 13px; color: var(--black-alpha-48); flex-shrink: 0; }
|
||||
.dma-search input {
|
||||
flex: 1; min-width: 0; height: 100%;
|
||||
border: 0; outline: 0; background: transparent;
|
||||
font-size: 12.5px; color: var(--accent-black); font-family: inherit;
|
||||
}
|
||||
|
||||
/* 商品空间列表(flex:1 占据中部所有剩余空间) */
|
||||
.dma-prod-list {
|
||||
flex: 1; min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 10px 10px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.dma-prod {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.dma-prod:hover { background: var(--background-lighter); }
|
||||
.dma-prod.active { background: var(--heat-12); border-color: var(--heat-20); }
|
||||
.dma-prod .thumb {
|
||||
flex-shrink: 0;
|
||||
width: 40px; height: 40px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
background: repeating-linear-gradient(135deg, transparent 0 4px, rgba(0,0,0,.04) 4px 5px);
|
||||
display: grid; place-items: center;
|
||||
font-family: var(--font-mono); font-size: 9px;
|
||||
color: var(--black-alpha-32);
|
||||
}
|
||||
.dma-prod.active .thumb { border-color: var(--heat); }
|
||||
.dma-prod .body { flex: 1; min-width: 0; }
|
||||
.dma-prod .nm {
|
||||
font-size: 12.5px;
|
||||
color: var(--accent-black); font-weight: 500;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.dma-prod.active .nm { color: var(--heat); font-weight: 600; }
|
||||
.dma-prod .sub {
|
||||
margin-top: 2px;
|
||||
font-family: var(--font-mono); font-size: 10px;
|
||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||
}
|
||||
|
||||
/* 全部商品入口(Q1-B · 固定贴底) */
|
||||
.dma-all {
|
||||
flex-shrink: 0;
|
||||
margin: 0 10px 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px dashed var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--black-alpha-72);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.dma-all:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }
|
||||
.dma-all .ct {
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.dma-all svg { width: 12px; height: 12px; }
|
||||
|
||||
/* ── 右侧主区 ── */
|
||||
.dma-main {
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dma-main-h {
|
||||
flex-shrink: 0;
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px 28px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
background: var(--surface);
|
||||
}
|
||||
.dma-main-h .crumb {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--black-alpha-48); letter-spacing: .04em;
|
||||
}
|
||||
.dma-main-h h2 {
|
||||
font-size: 20px; font-weight: 600;
|
||||
letter-spacing: -.015em;
|
||||
color: var(--accent-black);
|
||||
}
|
||||
.dma-main-h .stats {
|
||||
margin-left: auto;
|
||||
display: flex; gap: 6px;
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||
}
|
||||
.dma-main-h .stats b { color: var(--accent-black); font-weight: 600; }
|
||||
.dma-main-h .stats .sep { color: var(--black-alpha-24); }
|
||||
|
||||
/* 主区:参数 + 结果横向二栏 */
|
||||
.dma-body {
|
||||
flex: 1; min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* 左侧参数面板 */
|
||||
.dma-form {
|
||||
border-right: 1px solid var(--border-faint);
|
||||
background: var(--surface);
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.dma-form-scroll {
|
||||
flex: 1; min-height: 0; overflow-y: auto;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.dma-field { margin-bottom: 16px; }
|
||||
.dma-field-h {
|
||||
font-size: 12px; font-weight: 600;
|
||||
color: var(--accent-black);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dma-field-h .opt {
|
||||
font-weight: 400; font-size: 11px;
|
||||
color: var(--black-alpha-48); margin-left: 4px;
|
||||
}
|
||||
.dma-models {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.dma-model {
|
||||
aspect-ratio: 3 / 4;
|
||||
border: 1px solid var(--border-faint);
|
||||
background: var(--background-lighter);
|
||||
border-radius: var(--r-sm);
|
||||
position: relative; cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.dma-model:hover { border-color: var(--black-alpha-32); }
|
||||
.dma-model.selected { border-color: var(--heat); border-width: 2px; }
|
||||
.dma-model .ph {
|
||||
position: absolute; inset: 0;
|
||||
display: grid; place-items: center;
|
||||
font-family: var(--font-mono); font-size: 10px;
|
||||
color: var(--black-alpha-32);
|
||||
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
|
||||
}
|
||||
.dma-model .nm {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
padding: 4px 6px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,.5));
|
||||
font-size: 10px; color: #fff; font-weight: 500;
|
||||
}
|
||||
.dma-model.selected::after {
|
||||
content: ''; position: absolute; top: 4px; right: 4px;
|
||||
width: 14px; height: 14px;
|
||||
background: var(--heat) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") no-repeat center / 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dma-chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.dma-chip {
|
||||
display: inline-flex; align-items: center;
|
||||
height: 28px; padding: 0 11px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px; color: var(--black-alpha-72);
|
||||
font-family: inherit; cursor: pointer;
|
||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.dma-chip:hover { border-color: var(--black-alpha-32); color: var(--accent-black); }
|
||||
.dma-chip.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
|
||||
|
||||
.dma-form-cta {
|
||||
flex-shrink: 0;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--border-faint);
|
||||
background: var(--surface);
|
||||
}
|
||||
.dma-cost {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||
}
|
||||
.dma-cost .v { color: var(--accent-black); font-weight: 600; }
|
||||
.dma-gen {
|
||||
width: 100%; height: 42px;
|
||||
background: var(--heat); color: #fff;
|
||||
border: 1px solid var(--heat); border-radius: var(--r-md);
|
||||
font-size: 14px; font-weight: 600; font-family: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
box-shadow: var(--shadow-cta);
|
||||
}
|
||||
.dma-gen svg { width: 15px; height: 15px; }
|
||||
|
||||
/* 右侧结果区 */
|
||||
.dma-result {
|
||||
background: var(--background-base);
|
||||
min-height: 0; overflow-y: auto;
|
||||
padding: 22px 24px;
|
||||
}
|
||||
.dma-result-h {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.dma-result-h .ti { font-size: 15px; font-weight: 600; color: var(--accent-black); }
|
||||
.dma-result-h .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
|
||||
|
||||
/* 批次卡 */
|
||||
.dma-batch {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.dma-batch-h {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
.dma-batch-h .pic {
|
||||
width: 28px; height: 28px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
display: grid; place-items: center;
|
||||
color: var(--heat);
|
||||
font-family: var(--font-mono); font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.dma-batch-h .meta { flex: 1; min-width: 0; }
|
||||
.dma-batch-h .nm { font-size: 13px; font-weight: 600; color: var(--accent-black); }
|
||||
.dma-batch-h .info { margin-top: 2px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
|
||||
.dma-batch-h .info .sep { color: var(--black-alpha-24); }
|
||||
.dma-batch-h .ops { display: flex; gap: 4px; }
|
||||
.dma-batch-h .ops button {
|
||||
width: 28px; height: 28px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--black-alpha-56); cursor: pointer;
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.dma-batch-h .ops button:hover { border-color: var(--heat-20); color: var(--heat); }
|
||||
.dma-batch-h .ops button svg { width: 13px; height: 13px; }
|
||||
|
||||
.dma-batch-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||||
@media (max-width: 1400px) { .dma-batch-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
.dma-cell {
|
||||
aspect-ratio: 3 / 4;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden; position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dma-cell .ph {
|
||||
position: absolute; inset: 0;
|
||||
display: grid; place-items: center;
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--black-alpha-32);
|
||||
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
|
||||
}
|
||||
.dma-cell .tag {
|
||||
position: absolute; top: 6px; left: 6px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0,0,0,.65);
|
||||
color: #fff;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 10px; font-weight: 500;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 空态 */
|
||||
.dma-empty {
|
||||
height: 100%;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--black-alpha-48);
|
||||
text-align: center;
|
||||
}
|
||||
.dma-empty .ic {
|
||||
width: 56px; height: 56px;
|
||||
background: var(--surface); border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
display: grid; place-items: center;
|
||||
color: var(--heat);
|
||||
}
|
||||
.dma-empty .ic svg { width: 22px; height: 22px; }
|
||||
.dma-empty .mono { font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em; }
|
||||
|
||||
/* 顶部"提示条" · 这是 demo */
|
||||
.dma-banner {
|
||||
margin: 12px 28px 0;
|
||||
padding: 8px 12px;
|
||||
background: var(--heat-12);
|
||||
border: 1px dashed var(--heat-20);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px;
|
||||
color: var(--accent-black);
|
||||
font-family: var(--font-mono); letter-spacing: .02em;
|
||||
}
|
||||
.dma-banner b { color: var(--heat); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page">
|
||||
|
||||
<div class="dma-banner">// DEMO · 方案 A · <b>商品 = 项目空间</b>(Q1: A+B)。左栏仅商品空间:🔍 搜索 + 最近 6 条 + <b>全部商品</b>兜底入口;历史任务已挪进主区。主区:模特卡 + 张数 + 比例 + 立即生成,生成结果自动绑定到当前商品。</div>
|
||||
|
||||
<div class="dma">
|
||||
|
||||
<!-- ===== 左侧栏 · 仅商品空间 (搜索 + 列表 + 全部入口) ===== -->
|
||||
<aside class="dma-side">
|
||||
<div class="dma-side-h">
|
||||
<div class="ti-row">
|
||||
<span class="ti">商品空间</span>
|
||||
<button class="add" type="button" title="新建商品">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Q1-A · 搜索框 -->
|
||||
<div class="dma-search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input type="text" placeholder="搜索商品 / 分类">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 商品列表 · 最近 6 条 -->
|
||||
<div class="dma-prod-list">
|
||||
<div class="dma-prod active">
|
||||
<div class="thumb">主图</div>
|
||||
<div class="body">
|
||||
<div class="nm">透真补水面膜</div>
|
||||
<div class="sub">// 美妆个护 · 6 批</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dma-prod">
|
||||
<div class="thumb">主图</div>
|
||||
<div class="body">
|
||||
<div class="nm">透真清透防晒霜</div>
|
||||
<div class="sub">// 美妆个护 · 3 批</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dma-prod">
|
||||
<div class="thumb">主图</div>
|
||||
<div class="body">
|
||||
<div class="nm">南卡 Lite Pro 蓝牙耳机</div>
|
||||
<div class="sub">// 数码 3C · 2 批</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dma-prod">
|
||||
<div class="thumb">主图</div>
|
||||
<div class="body">
|
||||
<div class="nm">滋啦速食牛肉面</div>
|
||||
<div class="sub">// 食品饮料 · 1 批</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dma-prod">
|
||||
<div class="thumb">主图</div>
|
||||
<div class="body">
|
||||
<div class="nm">三顿半同款冻干咖啡</div>
|
||||
<div class="sub">// 食品饮料 · 1 批</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dma-prod">
|
||||
<div class="thumb">主图</div>
|
||||
<div class="body">
|
||||
<div class="nm">小熊 4L 可视空气炸锅</div>
|
||||
<div class="sub">// 家居家电 · 0 批</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Q1-B · 全部商品入口(贴底) -->
|
||||
<button class="dma-all" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M9 4v16"/></svg>
|
||||
全部商品
|
||||
<span class="ct">24 个</span>
|
||||
<svg style="margin-left:4px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- ===== 右侧主区 ===== -->
|
||||
<section class="dma-main">
|
||||
|
||||
<!-- 顶部 · 商品名 + 统计 -->
|
||||
<div class="dma-main-h">
|
||||
<div>
|
||||
<div class="crumb">// 商品空间</div>
|
||||
<h2>透真补水面膜</h2>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span>本商品 <b>6</b> 批</span>
|
||||
<span class="sep">·</span>
|
||||
<span>累计 <b>22</b> 张图</span>
|
||||
<span class="sep">·</span>
|
||||
<span>最近 <b>3 分钟前</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:参数面板 + 结果 -->
|
||||
<div class="dma-body">
|
||||
|
||||
<!-- 参数面板(单一职责:挑模特 + 张数) -->
|
||||
<div class="dma-form">
|
||||
<div class="dma-form-scroll">
|
||||
|
||||
<div class="dma-field">
|
||||
<div class="dma-field-h">选择模特<span class="opt">(已锁定商品 · 透真补水面膜)</span></div>
|
||||
<div class="dma-models">
|
||||
<div class="dma-model selected">
|
||||
<div class="ph">Ava · 3:4</div>
|
||||
<div class="nm">Ava</div>
|
||||
</div>
|
||||
<div class="dma-model">
|
||||
<div class="ph">Zoe · 3:4</div>
|
||||
<div class="nm">Zoe</div>
|
||||
</div>
|
||||
<div class="dma-model">
|
||||
<div class="ph">Ben · 3:4</div>
|
||||
<div class="nm">Ben</div>
|
||||
</div>
|
||||
<div class="dma-model">
|
||||
<div class="ph">Lin · 3:4</div>
|
||||
<div class="nm">Lin</div>
|
||||
</div>
|
||||
<div class="dma-model">
|
||||
<div class="ph">Mia · 3:4</div>
|
||||
<div class="nm">Mia</div>
|
||||
</div>
|
||||
<div class="dma-model" style="border-style:dashed;display:flex;align-items:center;justify-content:center;color:var(--black-alpha-48);">
|
||||
<span style="font-size:18px">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dma-field">
|
||||
<div class="dma-field-h">生成张数</div>
|
||||
<div class="dma-chip-row">
|
||||
<button class="dma-chip" type="button">1 张</button>
|
||||
<button class="dma-chip" type="button">2 张</button>
|
||||
<button class="dma-chip active" type="button">4 张</button>
|
||||
<button class="dma-chip" type="button">8 张</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dma-field">
|
||||
<div class="dma-field-h">画面比例</div>
|
||||
<div class="dma-chip-row">
|
||||
<button class="dma-chip" type="button">1:1</button>
|
||||
<button class="dma-chip active" type="button">3:4</button>
|
||||
<button class="dma-chip" type="button">9:16</button>
|
||||
<button class="dma-chip" type="button">16:9</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dma-field">
|
||||
<div class="dma-field-h">补充提示词<span class="opt">(选填)</span></div>
|
||||
<textarea style="width:100%;min-height:60px;padding:8px 10px;background:var(--background-lighter);border:1px solid var(--black-alpha-12);border-radius:var(--r-sm);font-family:inherit;font-size:12.5px;outline:none;resize:vertical" placeholder="例:户外阳光、敷面膜的特写、白底产品摄影"></textarea>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="dma-form-cta">
|
||||
<div class="dma-cost">
|
||||
<span>预估扣费 <span class="v">≈ ¥1.20</span></span>
|
||||
<span>余额 ¥327.40</span>
|
||||
</div>
|
||||
<button class="dma-gen" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z"/></svg>
|
||||
立即生成 · 透真补水面膜 × Ava
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧结果 · 当前商品全部批次 -->
|
||||
<div class="dma-result">
|
||||
<div class="dma-result-h">
|
||||
<span class="ti">最近批次 · Ava × 4 张</span>
|
||||
<span class="sub">// 3 分钟前 · 已完成</span>
|
||||
</div>
|
||||
|
||||
<!-- 批次 1 -->
|
||||
<div class="dma-batch">
|
||||
<div class="dma-batch-h">
|
||||
<div class="pic">4×</div>
|
||||
<div class="meta">
|
||||
<div class="nm">Ava × 4 张</div>
|
||||
<div class="info">透真补水面膜 <span class="sep">·</span> 3:4 <span class="sep">·</span> 3 分钟前 <span class="sep">·</span> ¥1.20</div>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<button type="button" title="全部重跑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg></button>
|
||||
<button type="button" title="全部下载"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg></button>
|
||||
<button type="button" title="加入资产库"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dma-batch-grid">
|
||||
<div class="dma-cell"><div class="ph">Ava · #1</div><span class="tag">3:4</span></div>
|
||||
<div class="dma-cell"><div class="ph">Ava · #2</div><span class="tag">3:4</span></div>
|
||||
<div class="dma-cell"><div class="ph">Ava · #3</div><span class="tag">3:4</span></div>
|
||||
<div class="dma-cell"><div class="ph">Ava · #4</div><span class="tag">3:4</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批次 2 -->
|
||||
<div class="dma-batch">
|
||||
<div class="dma-batch-h">
|
||||
<div class="pic">4×</div>
|
||||
<div class="meta">
|
||||
<div class="nm">Zoe × 4 张</div>
|
||||
<div class="info">透真补水面膜 <span class="sep">·</span> 3:4 <span class="sep">·</span> 12 分钟前 <span class="sep">·</span> ¥1.20</div>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<button type="button" title="全部重跑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg></button>
|
||||
<button type="button" title="全部下载"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg></button>
|
||||
<button type="button" title="加入资产库"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dma-batch-grid">
|
||||
<div class="dma-cell"><div class="ph">Zoe · #1</div><span class="tag">3:4</span></div>
|
||||
<div class="dma-cell"><div class="ph">Zoe · #2</div><span class="tag">3:4</span></div>
|
||||
<div class="dma-cell"><div class="ph">Zoe · #3</div><span class="tag">3:4</span></div>
|
||||
<div class="dma-cell"><div class="ph">Zoe · #4</div><span class="tag">3:4</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批次 3 -->
|
||||
<div class="dma-batch">
|
||||
<div class="dma-batch-h">
|
||||
<div class="pic">2×</div>
|
||||
<div class="meta">
|
||||
<div class="nm">Ben × 2 张</div>
|
||||
<div class="info">透真补水面膜 <span class="sep">·</span> 3:4 <span class="sep">·</span> 刚刚 <span class="sep">·</span> 生成中</div>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<button type="button" title="取消"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M6 18L18 6"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dma-batch-grid">
|
||||
<div class="dma-cell"><div class="ph">生成中…</div></div>
|
||||
<div class="dma-cell"><div class="ph">生成中…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="assets/icons.js?v=2026052608"></script>
|
||||
<script src="assets/shell.js?v=2026060101"></script>
|
||||
<script>
|
||||
Shell.render({
|
||||
active: 'asset-factory',
|
||||
crumbs: [
|
||||
{ label: '工作台', href: 'index.html' },
|
||||
{ label: '图片生成', href: 'asset-factory.html' },
|
||||
{ label: '模特上身图 · 方案 A · Demo' }
|
||||
]
|
||||
});
|
||||
|
||||
// 简单交互:点击商品 / 任务切换激活态
|
||||
document.querySelectorAll('.dma-prod').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
document.querySelectorAll('.dma-prod').forEach(x => x.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.dma-task').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
document.querySelectorAll('.dma-task').forEach(x => x.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.dma-model').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
document.querySelectorAll('.dma-model').forEach(x => x.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.dma-chip').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
// 同组互斥
|
||||
el.parentElement.querySelectorAll('.dma-chip').forEach(x => x.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="assets/api-bridge.js?v=2026060114"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,657 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>方案 A · v2 · 模特上身图 · Airshelf</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
|
||||
<style>
|
||||
.app { height: 100vh; overflow: hidden; }
|
||||
main { display: flex; flex-direction: column; min-height: 0; }
|
||||
#page-content { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0; }
|
||||
|
||||
/* ===== 两栏:左栏(纯商品空间) + 主区 ===== */
|
||||
.dmb {
|
||||
flex: 1; min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
}
|
||||
|
||||
/* ── 左栏 · 商品空间 ── */
|
||||
.dmb-side {
|
||||
border-right: 1px solid var(--border-faint);
|
||||
background: var(--surface);
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.dmb-side-h {
|
||||
padding: 14px 16px 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dmb-side-h .ti {
|
||||
font-size: 11px; font-family: var(--font-mono);
|
||||
color: var(--black-alpha-48); letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
/* 搜索框 */
|
||||
.dmb-search {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
height: 32px; padding: 0 10px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.dmb-search:focus-within { border-color: var(--heat-40); background: var(--surface); }
|
||||
.dmb-search svg { width: 13px; height: 13px; color: var(--black-alpha-48); flex-shrink: 0; }
|
||||
.dmb-search input {
|
||||
flex: 1; min-width: 0; height: 100%;
|
||||
border: 0; outline: 0; background: transparent;
|
||||
font-size: 12.5px; color: var(--accent-black); font-family: inherit;
|
||||
}
|
||||
|
||||
/* 商品列表 */
|
||||
.dmb-list {
|
||||
flex: 1; min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px 8px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.dmb-prod {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.dmb-prod:hover { background: var(--background-lighter); }
|
||||
.dmb-prod.active { background: var(--heat-12); border-color: var(--heat-20); }
|
||||
.dmb-prod .thumb {
|
||||
flex-shrink: 0;
|
||||
width: 44px; height: 44px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden; position: relative;
|
||||
display: grid; place-items: center;
|
||||
font-family: var(--font-mono); font-size: 9px;
|
||||
color: var(--black-alpha-32);
|
||||
background: repeating-linear-gradient(135deg, transparent 0 4px, rgba(0,0,0,.04) 4px 5px);
|
||||
}
|
||||
.dmb-prod.active .thumb { border-color: var(--heat); }
|
||||
.dmb-prod .body { flex: 1; min-width: 0; }
|
||||
.dmb-prod .nm {
|
||||
font-size: 12.5px;
|
||||
color: var(--accent-black); font-weight: 500;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.dmb-prod.active .nm { color: var(--heat); font-weight: 600; }
|
||||
.dmb-prod .sub {
|
||||
margin-top: 2px;
|
||||
font-family: var(--font-mono); font-size: 10px;
|
||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||
}
|
||||
|
||||
/* 底部 · 全部商品入口 */
|
||||
.dmb-all {
|
||||
flex-shrink: 0;
|
||||
margin: 0 10px 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px dashed var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--black-alpha-72);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.dmb-all:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }
|
||||
.dmb-all .ct { color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 10.5px; margin-left: auto; }
|
||||
.dmb-all svg { width: 12px; height: 12px; }
|
||||
|
||||
/* ── 主区 ── */
|
||||
.dmb-main {
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 0; overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.dmb-main-h {
|
||||
flex-shrink: 0;
|
||||
padding: 16px 28px 12px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
background: var(--surface);
|
||||
}
|
||||
.dmb-main-h .crumb {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--black-alpha-48); letter-spacing: .04em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dmb-main-h h2 {
|
||||
font-size: 22px; font-weight: 600;
|
||||
letter-spacing: -.015em;
|
||||
color: var(--accent-black);
|
||||
}
|
||||
.dmb-main-h .row {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.dmb-main-h .stats {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||
display: flex; gap: 4px;
|
||||
}
|
||||
.dmb-main-h .stats b { color: var(--accent-black); font-weight: 600; }
|
||||
.dmb-main-h .stats .sep { color: var(--black-alpha-24); }
|
||||
.dmb-main-h .spacer { flex: 1; }
|
||||
|
||||
/* 主区 toolbar (筛选条 · 仿 image-optimize 顶部) */
|
||||
.dmb-main-tb { display: flex; gap: 8px; align-items: center; }
|
||||
.dmb-main-tb .icbtn {
|
||||
width: 30px; height: 30px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--black-alpha-72);
|
||||
cursor: pointer; display: grid; place-items: center;
|
||||
}
|
||||
.dmb-main-tb .icbtn:hover { border-color: var(--heat-20); color: var(--heat); }
|
||||
.dmb-main-tb .icbtn svg { width: 13px; height: 13px; }
|
||||
.dmb-main-tb .chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 30px; padding: 0 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px; color: var(--black-alpha-72);
|
||||
font-family: inherit; cursor: pointer;
|
||||
}
|
||||
.dmb-main-tb .chip:hover { border-color: var(--heat-20); color: var(--heat); }
|
||||
.dmb-main-tb .chip svg { width: 10px; height: 10px; opacity: .6; }
|
||||
|
||||
/* 主区滚动体 · 任务流 */
|
||||
.dmb-stream {
|
||||
flex: 1; min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 22px 28px 200px; /* 底部留出参数面板高度 */
|
||||
background: var(--background-base);
|
||||
}
|
||||
.dmb-day-h {
|
||||
display: flex; align-items: baseline; gap: 8px;
|
||||
margin: 6px 0 10px;
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--black-alpha-48); letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dmb-day-h::before {
|
||||
content: ''; width: 14px; height: 1px;
|
||||
background: var(--black-alpha-24);
|
||||
display: inline-block; margin-right: 2px;
|
||||
}
|
||||
.dmb-day-h .ct {
|
||||
color: var(--black-alpha-72); font-weight: 500;
|
||||
margin-left: auto;
|
||||
text-transform: none; letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* 批次卡 */
|
||||
.dmb-batch {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.dmb-batch-h {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.dmb-batch-h .pic {
|
||||
flex-shrink: 0;
|
||||
width: 30px; height: 30px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
display: grid; place-items: center;
|
||||
color: var(--heat);
|
||||
font-family: var(--font-mono); font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.dmb-batch-h .meta { flex: 1; min-width: 0; }
|
||||
.dmb-batch-h .nm { font-size: 13.5px; font-weight: 600; color: var(--accent-black); }
|
||||
.dmb-batch-h .info {
|
||||
margin-top: 2px;
|
||||
display: flex; flex-wrap: wrap; gap: 4px;
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||
}
|
||||
.dmb-batch-h .info .sep { color: var(--black-alpha-24); }
|
||||
.dmb-batch-h .ops { display: flex; gap: 4px; }
|
||||
.dmb-batch-h .ops button {
|
||||
width: 28px; height: 28px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--black-alpha-56); cursor: pointer;
|
||||
display: grid; place-items: center;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.dmb-batch-h .ops button:hover { border-color: var(--heat-20); color: var(--heat); }
|
||||
.dmb-batch-h .ops button svg { width: 13px; height: 13px; }
|
||||
|
||||
/* 状态 pill 紧贴标题右侧 */
|
||||
.dmb-batch-h .stat-pill {
|
||||
margin-left: 8px;
|
||||
padding: 2px 7px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.dmb-batch-h .stat-pill .dot { width: 4px; height: 4px; }
|
||||
|
||||
.dmb-batch-grid {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
|
||||
}
|
||||
@media (max-width: 1400px) { .dmb-batch-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
.dmb-cell {
|
||||
aspect-ratio: 3 / 4;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden; position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dmb-cell .ph {
|
||||
position: absolute; inset: 0;
|
||||
display: grid; place-items: center;
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--black-alpha-32);
|
||||
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
|
||||
}
|
||||
.dmb-cell.gen .ph { animation: dmb-pulse 1.4s ease-in-out infinite; }
|
||||
@keyframes dmb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
|
||||
.dmb-cell.err { border-color: var(--accent-crimson, #c43d3d); }
|
||||
.dmb-cell.err .ph { color: var(--accent-crimson, #c43d3d); background: rgba(196,61,61,.05); }
|
||||
.dmb-cell .tag {
|
||||
position: absolute; top: 6px; left: 6px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0,0,0,.65); color: #fff;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 10px; font-weight: 500; backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* ── 底部 fixed 参数面板 ── */
|
||||
.dmb-param-wrap {
|
||||
position: absolute; left: 0; right: 0; bottom: 0;
|
||||
padding: 14px 28px 22px;
|
||||
background: linear-gradient(to bottom, transparent 0, var(--background-base) 24px);
|
||||
z-index: 5;
|
||||
}
|
||||
.dmb-param {
|
||||
max-width: 1180px; margin: 0 auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,.06);
|
||||
}
|
||||
.dmb-param .label-mono {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--black-alpha-48); letter-spacing: .04em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dmb-param .pchip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 30px; padding: 0 12px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 12px; color: var(--black-alpha-72);
|
||||
cursor: pointer; font-family: inherit;
|
||||
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.dmb-param .pchip:hover { background: var(--surface); border-color: var(--border-faint); }
|
||||
.dmb-param .pchip.active { background: var(--heat-12); color: var(--heat); }
|
||||
.dmb-param .pchip svg { width: 10px; height: 10px; opacity: .6; }
|
||||
.dmb-param .pchip .lbl-mono {
|
||||
font-family: var(--font-mono); font-size: 10px;
|
||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||
}
|
||||
.dmb-param .pchip.active .lbl-mono { color: var(--heat); }
|
||||
.dmb-param .spacer { flex: 1; }
|
||||
.dmb-param .meta-right {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||
margin-right: 6px;
|
||||
text-align: right;
|
||||
}
|
||||
.dmb-param .meta-right .v { color: var(--accent-black); font-weight: 600; }
|
||||
.dmb-param .gen-btn {
|
||||
height: 38px; padding: 0 20px;
|
||||
background: var(--heat); color: #fff;
|
||||
border: 0; border-radius: var(--r-md);
|
||||
font-size: 13.5px; font-weight: 600; font-family: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
box-shadow: var(--shadow-cta);
|
||||
}
|
||||
.dmb-param .gen-btn svg { width: 14px; height: 14px; }
|
||||
|
||||
/* 顶部 demo 提示条 */
|
||||
.dmb-banner {
|
||||
margin: 12px 28px 0;
|
||||
padding: 8px 12px;
|
||||
background: var(--heat-12);
|
||||
border: 1px dashed var(--heat-20);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px; color: var(--accent-black);
|
||||
font-family: var(--font-mono); letter-spacing: .02em;
|
||||
}
|
||||
.dmb-banner b { color: var(--heat); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page">
|
||||
|
||||
<div class="dmb-banner">// DEMO v2 · 方案 A · <b>商品空间(A+B) + 任务流主区</b>。左栏只保留商品空间(搜索+最近6条+全部入口),任务列表搬到主区,筛选放主区顶部 toolbar,参数面板底部 fixed 化(类 image-optimize)。</div>
|
||||
|
||||
<div class="dmb">
|
||||
|
||||
<!-- ===== 左栏 · 商品空间 ===== -->
|
||||
<aside class="dmb-side">
|
||||
<div class="dmb-side-h">
|
||||
<div class="ti">商品空间</div>
|
||||
<div class="dmb-search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input type="text" placeholder="搜索商品 / 分类">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dmb-list">
|
||||
<div class="dmb-prod active">
|
||||
<div class="thumb">主图</div>
|
||||
<div class="body">
|
||||
<div class="nm">透真补水面膜</div>
|
||||
<div class="sub">美妆个护 · 6 批</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dmb-prod">
|
||||
<div class="thumb">主图</div>
|
||||
<div class="body">
|
||||
<div class="nm">透真清透防晒霜</div>
|
||||
<div class="sub">美妆个护 · 3 批</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dmb-prod">
|
||||
<div class="thumb">主图</div>
|
||||
<div class="body">
|
||||
<div class="nm">南卡 Lite Pro 蓝牙耳机</div>
|
||||
<div class="sub">数码 3C · 2 批</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dmb-prod">
|
||||
<div class="thumb">主图</div>
|
||||
<div class="body">
|
||||
<div class="nm">滋啦速食牛肉面</div>
|
||||
<div class="sub">食品饮料 · 1 批</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dmb-prod">
|
||||
<div class="thumb">主图</div>
|
||||
<div class="body">
|
||||
<div class="nm">三顿半同款冻干咖啡</div>
|
||||
<div class="sub">食品饮料 · 1 批</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dmb-prod">
|
||||
<div class="thumb">主图</div>
|
||||
<div class="body">
|
||||
<div class="nm">小熊 4L 可视空气炸锅</div>
|
||||
<div class="sub">家居家电 · 0 批</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="dmb-all" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M9 4v16"/></svg>
|
||||
全部商品
|
||||
<span class="ct">24 个</span>
|
||||
<svg style="margin-left:4px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- ===== 主区 ===== -->
|
||||
<section class="dmb-main">
|
||||
|
||||
<!-- 顶部 标题 + stats + toolbar -->
|
||||
<div class="dmb-main-h">
|
||||
<div class="crumb">// 商品空间 · 模特上身图</div>
|
||||
<h2>透真补水面膜</h2>
|
||||
<div class="row">
|
||||
<div class="stats">
|
||||
<span>美妆个护</span><span class="sep">·</span>
|
||||
<span>本商品 <b>6</b> 批</span><span class="sep">·</span>
|
||||
<span>累计 <b>22</b> 张图</span><span class="sep">·</span>
|
||||
<span>最近 <b>3 分钟前</b></span>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="dmb-main-tb">
|
||||
<button class="icbtn" type="button" title="搜索批次">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
</button>
|
||||
<button class="chip" type="button">时间 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
|
||||
<button class="chip" type="button">状态 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
|
||||
<button class="chip" type="button">模特 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务流 -->
|
||||
<div class="dmb-stream">
|
||||
|
||||
<div class="dmb-day-h">
|
||||
<span>今天</span>
|
||||
<span class="ct">3 批 · 10 张</span>
|
||||
</div>
|
||||
|
||||
<!-- 批次 1 -->
|
||||
<div class="dmb-batch">
|
||||
<div class="dmb-batch-h">
|
||||
<div class="pic">4×</div>
|
||||
<div class="meta">
|
||||
<div class="nm">Ava × 4 张 <span class="pill ok stat-pill"><span class="dot"></span>已完成</span></div>
|
||||
<div class="info">
|
||||
<span>3:4</span><span class="sep">·</span>
|
||||
<span>3 分钟前</span><span class="sep">·</span>
|
||||
<span>¥1.20</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<button type="button" title="全部重跑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg></button>
|
||||
<button type="button" title="全部下载"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg></button>
|
||||
<button type="button" title="加入资产库"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dmb-batch-grid">
|
||||
<div class="dmb-cell"><div class="ph">Ava · #1</div><span class="tag">3:4</span></div>
|
||||
<div class="dmb-cell"><div class="ph">Ava · #2</div><span class="tag">3:4</span></div>
|
||||
<div class="dmb-cell"><div class="ph">Ava · #3</div><span class="tag">3:4</span></div>
|
||||
<div class="dmb-cell"><div class="ph">Ava · #4</div><span class="tag">3:4</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批次 2 -->
|
||||
<div class="dmb-batch">
|
||||
<div class="dmb-batch-h">
|
||||
<div class="pic">4×</div>
|
||||
<div class="meta">
|
||||
<div class="nm">Zoe × 4 张 <span class="pill ok stat-pill"><span class="dot"></span>已完成</span></div>
|
||||
<div class="info">
|
||||
<span>3:4</span><span class="sep">·</span>
|
||||
<span>12 分钟前</span><span class="sep">·</span>
|
||||
<span>¥1.20</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<button type="button" title="全部重跑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg></button>
|
||||
<button type="button" title="全部下载"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg></button>
|
||||
<button type="button" title="加入资产库"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dmb-batch-grid">
|
||||
<div class="dmb-cell"><div class="ph">Zoe · #1</div><span class="tag">3:4</span></div>
|
||||
<div class="dmb-cell"><div class="ph">Zoe · #2</div><span class="tag">3:4</span></div>
|
||||
<div class="dmb-cell"><div class="ph">Zoe · #3</div><span class="tag">3:4</span></div>
|
||||
<div class="dmb-cell"><div class="ph">Zoe · #4</div><span class="tag">3:4</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批次 3 · 生成中 -->
|
||||
<div class="dmb-batch">
|
||||
<div class="dmb-batch-h">
|
||||
<div class="pic">2×</div>
|
||||
<div class="meta">
|
||||
<div class="nm">Ben × 2 张 <span class="pill info stat-pill"><span class="dot"></span>生成中</span></div>
|
||||
<div class="info">
|
||||
<span>3:4</span><span class="sep">·</span>
|
||||
<span>刚刚</span><span class="sep">·</span>
|
||||
<span>¥0.60</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<button type="button" title="取消"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M6 18L18 6"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dmb-batch-grid">
|
||||
<div class="dmb-cell gen"><div class="ph">生成中…</div></div>
|
||||
<div class="dmb-cell gen"><div class="ph">生成中…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 昨天 -->
|
||||
<div class="dmb-day-h">
|
||||
<span>昨天</span>
|
||||
<span class="ct">2 批 · 8 张</span>
|
||||
</div>
|
||||
|
||||
<div class="dmb-batch">
|
||||
<div class="dmb-batch-h">
|
||||
<div class="pic">4×</div>
|
||||
<div class="meta">
|
||||
<div class="nm">Lin × 4 张 <span class="pill ok stat-pill"><span class="dot"></span>已完成</span></div>
|
||||
<div class="info">
|
||||
<span>3:4</span><span class="sep">·</span>
|
||||
<span>昨天 18:24</span><span class="sep">·</span>
|
||||
<span>¥1.20</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<button type="button" title="全部重跑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg></button>
|
||||
<button type="button" title="全部下载"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg></button>
|
||||
<button type="button" title="加入资产库"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dmb-batch-grid">
|
||||
<div class="dmb-cell"><div class="ph">Lin · #1</div><span class="tag">3:4</span></div>
|
||||
<div class="dmb-cell"><div class="ph">Lin · #2</div><span class="tag">3:4</span></div>
|
||||
<div class="dmb-cell"><div class="ph">Lin · #3</div><span class="tag">3:4</span></div>
|
||||
<div class="dmb-cell"><div class="ph">Lin · #4</div><span class="tag">3:4</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 更早 -->
|
||||
<div class="dmb-day-h">
|
||||
<span>更早</span>
|
||||
<span class="ct">1 批 · 2 张 · 含 1 失败</span>
|
||||
</div>
|
||||
|
||||
<div class="dmb-batch">
|
||||
<div class="dmb-batch-h">
|
||||
<div class="pic">2×</div>
|
||||
<div class="meta">
|
||||
<div class="nm">Ava × 2 张 <span class="pill err stat-pill"><span class="dot"></span>失败</span></div>
|
||||
<div class="info">
|
||||
<span>3:4</span><span class="sep">·</span>
|
||||
<span>2 天前</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<button type="button" title="全部重跑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg></button>
|
||||
<button type="button" title="删除"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dmb-batch-grid">
|
||||
<div class="dmb-cell err"><div class="ph">失败 · 点重跑</div></div>
|
||||
<div class="dmb-cell err"><div class="ph">失败 · 点重跑</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 底部 fixed 参数面板 -->
|
||||
<div class="dmb-param-wrap">
|
||||
<div class="dmb-param">
|
||||
<button class="pchip active" type="button">
|
||||
<span class="lbl-mono">模特</span>
|
||||
<span>Ava</span>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<button class="pchip" type="button">
|
||||
<span class="lbl-mono">张数</span>
|
||||
<span>4</span>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<button class="pchip" type="button">
|
||||
<span class="lbl-mono">比例</span>
|
||||
<span>3:4</span>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<button class="pchip" type="button">
|
||||
<span class="lbl-mono">补充提示词</span>
|
||||
<span style="color:var(--black-alpha-48)">+ 添加</span>
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<span class="meta-right">预估 <span class="v">¥1.20</span> · 余额 <span class="v">¥327.40</span></span>
|
||||
<button class="gen-btn" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z"/></svg>
|
||||
生成 · 透真补水面膜 × Ava
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="assets/icons.js?v=2026052608"></script>
|
||||
<script src="assets/shell.js?v=2026060101"></script>
|
||||
<script>
|
||||
Shell.render({
|
||||
active: 'asset-factory',
|
||||
crumbs: [
|
||||
{ label: '工作台', href: 'index.html' },
|
||||
{ label: '图片生成', href: 'asset-factory.html' },
|
||||
{ label: '模特上身图 · 方案 A · v2' }
|
||||
]
|
||||
});
|
||||
|
||||
// 商品 / chip 切换
|
||||
document.querySelectorAll('.dmb-prod').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
document.querySelectorAll('.dmb-prod').forEach(x => x.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.dmb-param .pchip').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
// demo:只切自己 active(不互斥,每个 chip 都可独立切下拉)
|
||||
el.classList.toggle('active');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="assets/api-bridge.js?v=2026060114"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
server_tokens off;
|
||||
charset utf-8;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# 设计稿迭代期:CSS/JS 不带 hash,不能用 immutable,否则改了浏览器仍吃旧版
|
||||
# 缓存 1 小时 + must-revalidate(过期后用 If-Modified-Since 回源验证,服务器没改返回 304,改了取新文件)
|
||||
location ~* \.(css|js)$ {
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
}
|
||||
|
||||
# 图片/字体/媒体:可以缓存稍长(改动频率低),仍允许重新验证
|
||||
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|mp4|webm)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
}
|
||||
|
||||
# HTML:不进浏览器磁盘缓存,每次都问服务器有没有新版本
|
||||
location ~* \.html$ {
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
expires off;
|
||||
}
|
||||
|
||||
# Multi-page (not SPA): try exact file, then .html fallback (friendly URLs), then 404.
|
||||
# NEVER fallback to index.html — that masks 404s for a multi-entry design shelf.
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html =404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>新建商品 · Airshelf</title>
|
||||
<script>
|
||||
// 已废弃 · 新建商品改为 products.html 上的居中弹窗(Shell.openNewProduct)
|
||||
// 直接访问此 URL 时,跳回商品库并自动打开弹窗
|
||||
sessionStorage.setItem('auto-open-new-product', '1');
|
||||
location.replace('products.html');
|
||||
</script>
|
||||
</head>
|
||||
<body><script src="assets/api-bridge.js?v=2026060114"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,55 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>新建商品 · Airshelf</title>
|
||||
<meta http-equiv="Cache-Control" content="no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<!-- 双保险:JS 没跑也能跳走 (1.5s 后强制回 products.html) -->
|
||||
<meta http-equiv="refresh" content="1.5; url=products.html?npd=1">
|
||||
<style>
|
||||
html,body{background:#f9f9f9;margin:0;height:100%;font-family:'Inter',system-ui,sans-serif;}
|
||||
.stub-msg{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;color:#999;font-size:13px;}
|
||||
.stub-msg .ttl{font-size:14px;color:#262626;font-weight:500;}
|
||||
.stub-msg a{color:#fa5d19;text-decoration:underline;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stub-msg">
|
||||
<div class="ttl">正在打开「新建商品」…</div>
|
||||
<div>如未自动跳转,<a href="products.html?npd=1">点击这里</a></div>
|
||||
</div>
|
||||
<script>
|
||||
/* ============================================================
|
||||
新建商品 · 重定向 stub
|
||||
----------------------------------------------------------
|
||||
旧版本是一个 3883 行的全屏 drawer 页(legacy 已归档到 _archive/deprecated-pages-20260528/)。
|
||||
现在「新建商品」改为 drawer 形态在原页面弹出,直接访问此 URL 没有意义。
|
||||
|
||||
行为:
|
||||
1. 设置 sessionStorage 标志,通知落地页自动打开 drawer
|
||||
2. 优先回退到 referrer(用户来源页),否则去 products.html
|
||||
3. cache-bust 参数 ?npd=1 强制浏览器重新拉新版宿主页
|
||||
============================================================ */
|
||||
(function () {
|
||||
try { sessionStorage.setItem('npd-auto-open', '1'); } catch (e) {}
|
||||
try { sessionStorage.setItem('auto-open-new-product', '1'); } catch (e) {}
|
||||
|
||||
const ref = document.referrer || '';
|
||||
const here = location.origin + location.pathname;
|
||||
|
||||
// 同源 referrer 且不是自身才回跳, 否则去 products.html
|
||||
let target = 'products.html';
|
||||
if (ref && ref.indexOf(location.origin) === 0 && ref.split('#')[0].split('?')[0] !== here) {
|
||||
target = ref;
|
||||
}
|
||||
// 加 cache-bust query · 强制浏览器拉新版宿主页,避免再次命中缓存旧版
|
||||
const sep = target.indexOf('?') >= 0 ? '&' : '?';
|
||||
target = target + sep + 'npd=' + Date.now().toString(36);
|
||||
location.replace(target);
|
||||
})();
|
||||
</script>
|
||||
<script src="assets/api-bridge.js?v=2026060114"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,248 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>注册团队 · Airshelf</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; background: var(--background-base); display: grid; place-items: center; padding: 32px 24px; }
|
||||
|
||||
.auth-wrap { width: 100%; max-width: 980px; display: grid; grid-template-columns: minmax(0, 1fr) 460px; gap: 0; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); position: relative; overflow: hidden; min-height: 620px; }
|
||||
|
||||
/* 4 装订线 */
|
||||
.auth-wrap::before, .auth-wrap::after,
|
||||
.auth-wrap > .corner-tr, .auth-wrap > .corner-bl {
|
||||
content: ''; position: absolute; width: 14px; height: 14px;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center;
|
||||
background-size: contain; pointer-events: none; z-index: 2;
|
||||
}
|
||||
.auth-wrap::before { top: -7px; left: -7px; }
|
||||
.auth-wrap::after { bottom: -7px; right: -7px; }
|
||||
.auth-wrap > .corner-tr { top: -7px; right: -7px; }
|
||||
.auth-wrap > .corner-bl { bottom: -7px; left: -7px; }
|
||||
|
||||
/* 左:品牌 + 价值点 */
|
||||
.auth-brand { background: var(--accent-black); color: var(--accent-white); padding: 40px 44px; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||||
.auth-brand .logo { display: flex; align-items: center; height: 56px; }
|
||||
.auth-brand .logo-img { display: block; width: 196px; height: auto; margin: -14px 0 -10px -14px; object-fit: contain; }
|
||||
.auth-brand .tag { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.5); letter-spacing: .08em; text-transform: uppercase; margin-top: 4px; }
|
||||
|
||||
.auth-brand .hero { margin-top: 28px; }
|
||||
.auth-brand .hero h1 { font-size: 28px; font-weight: 700; letter-spacing: -.02em; line-height: 1.25; margin: 0 0 14px; }
|
||||
.auth-brand .hero h1 .h { color: var(--heat); }
|
||||
.auth-brand .hero p { font-size: 13.5px; color: rgba(255,255,255,.62); line-height: 1.7; max-width: 320px; margin: 0; }
|
||||
|
||||
/* 价值点列表 */
|
||||
.val-list { margin: 30px 0 0; display: flex; flex-direction: column; gap: 14px; }
|
||||
.val-item { display: grid; grid-template-columns: 24px minmax(0,1fr); gap: 12px; align-items: start; }
|
||||
.val-item .ic-v { width: 22px; height: 22px; border: 1px solid rgba(255,255,255,.18); border-radius: var(--r-sm); display: grid; place-items: center; color: var(--heat); }
|
||||
.val-item .ic-v svg { width: 12px; height: 12px; }
|
||||
.val-item .txt-v { font-size: 12.5px; color: rgba(255,255,255,.78); line-height: 1.5; }
|
||||
.val-item .txt-v b { color: var(--accent-white); font-weight: 600; }
|
||||
|
||||
.auth-brand .foot { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.32); letter-spacing: .04em; margin-top: auto; padding-top: 24px; display: flex; gap: 14px; }
|
||||
.auth-brand .foot a { color: rgba(255,255,255,.5); text-decoration: none; }
|
||||
.auth-brand .foot a:hover { color: var(--heat); }
|
||||
|
||||
/* 右:表单 */
|
||||
.auth-form { padding: 40px 44px 32px; display: flex; flex-direction: column; }
|
||||
.auth-form .h-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
|
||||
.auth-form h2 { font-size: 22px; font-weight: 600; letter-spacing: -.012em; margin: 0; }
|
||||
.auth-form .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; }
|
||||
.auth-form .lead { font-size: 13px; color: var(--black-alpha-56); margin: 0 0 22px; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
.field-label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; }
|
||||
.field-label .req { color: var(--accent-crimson); }
|
||||
.field-label .hint { margin-left: auto; font-size: 10.5px; color: var(--black-alpha-32); }
|
||||
.field-input-wrap { position: relative; }
|
||||
.field-input-wrap .ic-l { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--black-alpha-32); width: 14px; height: 14px; pointer-events: none; }
|
||||
.field input { width: 100%; box-sizing: border-box; padding: 11px 12px 11px 36px; font-size: 13.5px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); font-family: inherit; color: var(--accent-black); transition: border-color var(--t-base), box-shadow var(--t-base); }
|
||||
.field input:focus { outline: none; border-color: var(--heat); box-shadow: 0 0 0 3px var(--heat-12); }
|
||||
.field input::placeholder { color: var(--black-alpha-32); }
|
||||
.field .toggle-pwd { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: var(--black-alpha-48); cursor: pointer; background: none; border: 0; padding: 0; display: grid; place-items: center; }
|
||||
.field .toggle-pwd:hover { color: var(--accent-black); }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
|
||||
.field-row .field { margin-bottom: 0; }
|
||||
|
||||
.agree { display: flex; align-items: flex-start; gap: 8px; font-size: 12px; color: var(--black-alpha-56); line-height: 1.6; margin: 8px 0 18px; cursor: pointer; user-select: none; }
|
||||
.agree input { margin-top: 3px; width: 13px; height: 13px; accent-color: var(--heat); flex-shrink: 0; }
|
||||
.agree a { color: var(--heat); text-decoration: none; }
|
||||
.agree a:hover { text-decoration: underline; }
|
||||
|
||||
.btn-cta { background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-md); padding: 12px 14px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; transition: box-shadow var(--t-base), transform var(--t-base); display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
.btn-cta:hover { box-shadow: 0 4px 14px rgba(250,93,25,.28); }
|
||||
.btn-cta:active { transform: translateY(1px); }
|
||||
.btn-cta:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
|
||||
.btn-cta svg { width: 15px; height: 15px; }
|
||||
|
||||
.switch-row { margin-top: 22px; text-align: center; font-size: 12.5px; color: var(--black-alpha-56); }
|
||||
.switch-row a { color: var(--heat); text-decoration: none; font-weight: 500; }
|
||||
.switch-row a:hover { text-decoration: underline; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.auth-wrap { grid-template-columns: 1fr; min-height: 0; }
|
||||
.auth-brand { padding: 32px 28px; }
|
||||
.auth-brand .val-list { display: none; }
|
||||
.auth-form { padding: 32px 28px; }
|
||||
}
|
||||
|
||||
.top-back { position: fixed; top: 20px; left: 24px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); text-decoration: none; letter-spacing: .04em; }
|
||||
.top-back:hover { color: var(--heat); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="top-back" href="login.html">← 返回登录</a>
|
||||
|
||||
<div class="auth-wrap">
|
||||
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
|
||||
|
||||
<aside class="auth-brand">
|
||||
<div class="logo">
|
||||
<img class="logo-img" src="assets/logo-dark.png" alt="Airshelf">
|
||||
</div>
|
||||
<div class="tag">// SHORT-VIDEO COMMERCE PLATFORM</div>
|
||||
|
||||
<div class="hero">
|
||||
<h1>开通团队,<br>开始 <span class="h">AI 带货</span> 第一条短剧</h1>
|
||||
<p>个人 / 企业团队 1-2 小时出成片,失败不扣费,确认后扣。</p>
|
||||
</div>
|
||||
|
||||
<div class="val-list">
|
||||
<div class="val-item">
|
||||
<span class="ic-v"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>
|
||||
<span class="txt-v"><b>5 阶段流水线</b> · 脚本 → 资产 → 故事板 → 视频片段 → 拼接导出</span>
|
||||
</div>
|
||||
<div class="val-item">
|
||||
<span class="ic-v"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>
|
||||
<span class="txt-v"><b>失败不扣费</b> · 任务仅在用户通过时扣费,失败 / 超时 / 重跑一律不扣</span>
|
||||
</div>
|
||||
<div class="val-item">
|
||||
<span class="ic-v"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>
|
||||
<span class="txt-v"><b>团队 + 角色 + 四层额度</b> · 超管 / 团管 / 成员,日 / 月 + 团队 / 总四层防超支</span>
|
||||
</div>
|
||||
<div class="val-item">
|
||||
<span class="ic-v"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>
|
||||
<span class="txt-v"><b>跨项目资产库</b> · 主播 / 场景 / 商品图沉淀复用,不重复生成</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<a href="#">关于</a>
|
||||
<a href="#">定价</a>
|
||||
<a href="#">联系</a>
|
||||
<a href="#">隐私</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="auth-form">
|
||||
<div class="h-row">
|
||||
<h2>注册团队</h2>
|
||||
<span class="sub">// /auth/register</span>
|
||||
</div>
|
||||
<p class="lead">填写团队信息开通账户,默认成为团队超管。</p>
|
||||
|
||||
<form id="register-form" autocomplete="off" onsubmit="event.preventDefault(); doRegister();">
|
||||
<div class="field">
|
||||
<label class="field-label" for="reg-team">团队名 <span class="req">*</span></label>
|
||||
<div class="field-input-wrap">
|
||||
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/></svg>
|
||||
<input type="text" id="reg-team" placeholder="例: 小李的店 / XX 文化传媒" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="reg-email">超管邮箱 <span class="req">*</span><span class="hint">用于成员邀请 + 找回密码</span></label>
|
||||
<div class="field-input-wrap">
|
||||
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><path d="m22 6-10 7L2 6"/></svg>
|
||||
<input type="email" id="reg-email" placeholder="name@company.com" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field-label" for="reg-pwd">密码 <span class="req">*</span></label>
|
||||
<div class="field-input-wrap">
|
||||
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
<input type="password" id="reg-pwd" placeholder="至少 8 位" required>
|
||||
<button type="button" class="toggle-pwd" onclick="togglePwd('reg-pwd')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="reg-pwd2">确认密码 <span class="req">*</span></label>
|
||||
<div class="field-input-wrap">
|
||||
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
<input type="password" id="reg-pwd2" placeholder="再输一次" required>
|
||||
<button type="button" class="toggle-pwd" onclick="togglePwd('reg-pwd2')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="reg-invite">邀请码 <span class="hint">可选 · 团队邀请才需要</span></label>
|
||||
<div class="field-input-wrap">
|
||||
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
<input type="text" id="reg-invite" placeholder="例: TEAM-XXXX-XXXX">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="agree">
|
||||
<input type="checkbox" id="reg-agree" checked>
|
||||
<span>我已阅读并同意 <a href="#" onclick="event.preventDefault();_regToast('用户协议','含数据处理 / 内容生成版权 / 计费规则三章 · 完整文本将在正式版接入');">用户协议</a> 与 <a href="#" onclick="event.preventDefault();_regToast('隐私政策','遵循《个人信息保护法》· 团队数据存中国境内 · 默认不用于模型训练');">隐私政策</a>,知悉「失败不扣费 · 确认后扣」的扣费规则。</span>
|
||||
</label>
|
||||
|
||||
<button class="btn-cta" type="submit" id="reg-submit">
|
||||
创建团队 · 开始使用
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="switch-row">
|
||||
已有账号? <a href="login.html">登录 →</a>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function togglePwd(id) {
|
||||
const inp = document.getElementById(id);
|
||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
function doRegister() {
|
||||
const team = document.getElementById('reg-team').value.trim();
|
||||
const email = document.getElementById('reg-email').value.trim();
|
||||
const p1 = document.getElementById('reg-pwd').value;
|
||||
const p2 = document.getElementById('reg-pwd2').value;
|
||||
const agree = document.getElementById('reg-agree').checked;
|
||||
if (!team || !email) return alert('请补全团队名 + 邮箱');
|
||||
if (p1.length < 8) return alert('密码至少 8 位');
|
||||
if (p1 !== p2) return alert('两次密码不一致');
|
||||
if (!agree) return alert('请同意用户协议');
|
||||
const btn = document.getElementById('reg-submit');
|
||||
btn.innerHTML = '<span style="font-family:var(--font-mono);font-size:12px;letter-spacing:.04em;">// 创建团队中...</span>';
|
||||
btn.disabled = true;
|
||||
setTimeout(() => { location.href = 'index.html'; }, 900);
|
||||
}
|
||||
// 轻量 toast(不依赖 shell.js)
|
||||
function _regToast(t, sub) {
|
||||
let el = document.getElementById('__reg-toast');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = '__reg-toast';
|
||||
el.style.cssText = 'position:fixed;left:50%;bottom:36px;transform:translateX(-50%) translateY(20px);background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:12px 18px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:flex;flex-direction:column;gap:2px;opacity:0;transition:opacity .2s,transform .2s;z-index:9999;font-family:inherit;max-width:380px;';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.innerHTML = '<div style="font-size:13.5px;font-weight:600;color:#262626;">' + t + '</div>' + (sub ? '<div style="font-size:11.5px;color:rgba(0,0,0,.56);font-family:\'Inter\',system-ui,sans-serif;letter-spacing:.02em;">// ' + sub + '</div>' : '');
|
||||
requestAnimationFrame(() => { el.style.opacity = '1'; el.style.transform = 'translateX(-50%) translateY(0)'; });
|
||||
clearTimeout(el._t);
|
||||
el._t = setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateX(-50%) translateY(20px)'; }, 3000);
|
||||
}
|
||||
</script>
|
||||
<script src="assets/api-bridge.js?v=2026060114"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,855 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>设置 · Airshelf</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/restraint.css?v=2026060101">
|
||||
<style>
|
||||
/* ─── 设置布局:左 nav + 右 panel ─── */
|
||||
.settings-grid { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 24px; align-items: start; }
|
||||
|
||||
.settings-nav { position: sticky; top: 16px; }
|
||||
.settings-nav .nav-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; padding: 0 12px 8px; }
|
||||
.settings-nav :where(a, button) { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; font: inherit; font-size: 13px; color: var(--accent-black); border-radius: var(--r-md); border: 1px solid transparent; background: transparent; cursor: pointer; text-decoration: none; text-align: left; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); position: relative; }
|
||||
.settings-nav :where(a, button):hover { background: var(--background-lighter); }
|
||||
.settings-nav :where(a, button):focus-visible { outline: 2px solid var(--heat); outline-offset: 2px; }
|
||||
.settings-nav a.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
|
||||
.settings-nav :where(a, button) svg { width: 16px; height: 16px; stroke-width: 1.5; flex: 0 0 auto; }
|
||||
.settings-nav a .nav-badge { margin-left: auto; font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); padding: 1px 6px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-pill); letter-spacing: .02em; line-height: 14px; }
|
||||
.settings-nav a.active .nav-badge { color: var(--heat); background: var(--accent-white); border-color: var(--heat-20); }
|
||||
.settings-nav a .nav-dot { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--heat); display: none; }
|
||||
.settings-nav a.has-changes .nav-dot { display: block; }
|
||||
.settings-nav a.active .nav-dot { right: -4px; }
|
||||
.settings-nav .logout-pill {
|
||||
width: calc(100% - 24px);
|
||||
height: 38px;
|
||||
margin: 4px 12px 0;
|
||||
justify-content: center;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--accent-black);
|
||||
border-color: var(--accent-black);
|
||||
color: var(--accent-white);
|
||||
font-weight: 500;
|
||||
}
|
||||
.settings-nav .logout-pill:hover,
|
||||
.settings-nav .logout-pill:focus-visible {
|
||||
background: var(--black-alpha-88);
|
||||
border-color: var(--black-alpha-88);
|
||||
color: var(--accent-white);
|
||||
}
|
||||
|
||||
/* ─── pane ─── */
|
||||
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 24px; margin-bottom: 16px; }
|
||||
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
||||
.pane .pane-desc { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; margin-bottom: 18px; }
|
||||
|
||||
/* ─── form row ─── */
|
||||
.form-row { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--border-faint); align-items: center; }
|
||||
.form-row:last-child { border-bottom: 0; }
|
||||
.form-row .lbl { font-size: 12.5px; color: var(--black-alpha-56); }
|
||||
.form-row .lbl .req { color: var(--accent-crimson); margin-left: 2px; }
|
||||
.form-row .lbl-sub { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }
|
||||
.form-row .val { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.form-row .val .input, .form-row .val .select { width: 100%; max-width: 380px; }
|
||||
.form-row .val .static { font-size: 13px; color: var(--accent-black); font-variant-numeric: tabular-nums; }
|
||||
.form-row .val .static.mono { font-family: var(--font-mono); font-size: 12.5px; color: var(--black-alpha-56); }
|
||||
.form-row .val .role-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 11px; font-weight: 500; background: var(--heat-12); color: var(--heat); }
|
||||
.form-row .val .role-tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--heat); }
|
||||
|
||||
/* ─── 头像上传 ─── */
|
||||
.avatar-edit { display: flex; align-items: center; gap: 16px; }
|
||||
.avatar-edit .av-big { width: 64px; height: 64px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 24px; font-weight: 600; color: var(--accent-black); }
|
||||
.avatar-edit .av-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* ─── toggle switch ─── */
|
||||
.switch { position: relative; width: 36px; height: 20px; flex: 0 0 36px; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.switch .slider { position: absolute; inset: 0; background: var(--black-alpha-24); border-radius: 20px; cursor: pointer; transition: background var(--t-base); }
|
||||
.switch .slider::before { content: ''; position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; background: var(--accent-white); border-radius: 50%; transition: transform var(--t-base); }
|
||||
.switch input:checked + .slider { background: var(--heat); }
|
||||
.switch input:checked + .slider::before { transform: translateX(16px); }
|
||||
|
||||
/* ─── 偏好选项卡 ─── */
|
||||
.pref-choices { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; max-width: 540px; }
|
||||
.pref-choice { padding: 10px 12px; border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: all var(--t-base); }
|
||||
.pref-choice:hover { background: var(--background-lighter); }
|
||||
.pref-choice.selected { border-color: var(--heat); background: var(--heat-12); }
|
||||
.pref-choice .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); }
|
||||
.pref-choice .d { font-size: 11px; color: var(--black-alpha-48); margin-top: 2px; font-family: var(--font-mono); letter-spacing: .02em; }
|
||||
.pref-choice.selected .t { color: var(--heat); }
|
||||
|
||||
/* ─── 时长档 ─── */
|
||||
.duration-row { display: flex; gap: 8px; }
|
||||
.dur-chip { padding: 6px 14px; border: 1px solid var(--border-faint); border-radius: var(--r-md); font-size: 13px; cursor: pointer; font-family: var(--font-mono); font-variant-numeric: tabular-nums; transition: all var(--t-base); background: var(--surface); }
|
||||
.dur-chip:hover { background: var(--background-lighter); }
|
||||
.dur-chip.selected { border-color: var(--heat); background: var(--heat-12); color: var(--heat); font-weight: 600; }
|
||||
|
||||
/* ─── 设备列表 ─── */
|
||||
.device-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border-faint); }
|
||||
.device-row:last-child { border-bottom: 0; }
|
||||
.device-row .ic { width: 36px; height: 36px; border-radius: var(--r-md); background: var(--background-lighter); display: grid; place-items: center; color: var(--black-alpha-56); }
|
||||
.device-row .ic svg { width: 18px; height: 18px; }
|
||||
.device-row .nm { font-size: 13px; font-weight: 500; }
|
||||
.device-row .meta { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }
|
||||
.device-row .tag-cur { font-family: var(--font-mono); font-size: 10.5px; padding: 1px 6px; background: var(--accent-forest); color: var(--accent-white); border-radius: var(--r-sm); margin-left: 8px; letter-spacing: .04em; font-weight: 600; }
|
||||
.device-row .spacer { margin-left: auto; }
|
||||
|
||||
/* ─── 头像上传 modal · V2.1 Restraint ─── */
|
||||
.av-up-modal { width: min(440px, 92vw); max-width: min(440px, 92vw); }
|
||||
|
||||
/* 预览区:左圆头像 + 右 mono 元数据 · 装订线点划线分隔 */
|
||||
.av-up-preview-row { display: flex; align-items: center; gap: 14px; padding-bottom: 14px; margin-bottom: 14px; position: relative; }
|
||||
.av-up-preview-row::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: repeating-linear-gradient(to right, var(--border-faint) 0, var(--border-faint) 4px, transparent 4px, transparent 8px); }
|
||||
.av-up-preview { width: 64px; height: 64px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 22px; font-weight: 600; color: var(--accent-black); overflow: hidden; flex: 0 0 64px; }
|
||||
.av-up-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.av-up-preview-meta { min-width: 0; }
|
||||
.av-up-preview-meta .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); margin-bottom: 3px; letter-spacing: .01em; }
|
||||
.av-up-preview-meta .d { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.55; }
|
||||
|
||||
/* 规则文本 · 纯 mono, 装订线分隔 */
|
||||
.av-up-rules { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--border-faint); font-size: 11px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.7; }
|
||||
.av-up-rules .li { display: flex; gap: 8px; }
|
||||
.av-up-rules .li::before { content: '//'; color: var(--black-alpha-32); flex: 0 0 auto; }
|
||||
.logout-confirm-modal { width: min(440px, 92vw); max-width: min(440px, 92vw); }
|
||||
.logout-confirm-copy { margin: 0 0 12px; color: var(--black-alpha-72); }
|
||||
.logout-confirm-points {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.logout-confirm-points .li {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
.logout-confirm-points .li::before {
|
||||
content: '//';
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--black-alpha-32);
|
||||
}
|
||||
.logout-unsaved-note {
|
||||
margin-top: 12px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--heat-20);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--heat-12);
|
||||
color: var(--heat);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page">
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>设置</h1>
|
||||
<div class="sub"><span class="mono">// 个人信息 · 偏好 · 通知 · 安全</span></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="save-cancel" disabled style="opacity:.5; cursor:not-allowed;">取消</button>
|
||||
<button class="btn btn-primary" id="save-btn" disabled style="opacity:.5; cursor:not-allowed;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>
|
||||
保存所有变更
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<!-- 左侧 nav -->
|
||||
<aside class="settings-nav" role="tablist" aria-label="设置分区">
|
||||
<div class="nav-h">个人</div>
|
||||
<a href="#sec-profile" class="active" data-jump="sec-profile" role="tab" aria-controls="sec-profile">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="7.5" r="3.5"/><path d="M5 21a7 7 0 0 1 14 0"/></svg>
|
||||
<span>个人信息</span>
|
||||
<span class="nav-dot" aria-hidden="true"></span>
|
||||
</a>
|
||||
<a href="#sec-security" data-jump="sec-security" role="tab" aria-controls="sec-security">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
<span>安全</span>
|
||||
<span class="nav-badge" data-count-source="sec-security">3</span>
|
||||
<span class="nav-dot" aria-hidden="true"></span>
|
||||
</a>
|
||||
<a href="#sec-notify" data-jump="sec-notify" role="tab" aria-controls="sec-notify">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></svg>
|
||||
<span>通知</span>
|
||||
<span class="nav-badge" data-count-source="sec-notify">5</span>
|
||||
<span class="nav-dot" aria-hidden="true"></span>
|
||||
</a>
|
||||
<div class="nav-h" style="margin-top: 16px;">偏好</div>
|
||||
<a href="#sec-pref" data-jump="sec-pref" role="tab" aria-controls="sec-pref">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 4h-7"/><path d="M10 4H3"/><path d="M21 12h-9"/><path d="M8 12H3"/><path d="M21 20h-5"/><path d="M12 20H3"/><path d="M14 2v4"/><path d="M8 10v4"/><path d="M16 18v4"/></svg>
|
||||
<span>创作默认</span>
|
||||
<span class="nav-dot" aria-hidden="true"></span>
|
||||
</a>
|
||||
<a href="#sec-display" data-jump="sec-display" role="tab" aria-controls="sec-display">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
|
||||
<span>显示</span>
|
||||
<span class="nav-dot" aria-hidden="true"></span>
|
||||
</a>
|
||||
<div class="nav-h" style="margin-top: 16px;">账号</div>
|
||||
<button class="logout-pill" type="button" id="settings-logout-btn" onclick="logoutCurrentDevice()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<main>
|
||||
<!-- ─── 个人信息 ─── -->
|
||||
<section class="pane" id="sec-profile">
|
||||
<h3>个人信息</h3>
|
||||
<div class="pane-desc">// 头像、姓名、联系方式 · 邮箱用于接收通知</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="lbl">头像</div>
|
||||
<div class="val">
|
||||
<div class="avatar-edit">
|
||||
<div class="av-big" id="prof-avatar-preview">李</div>
|
||||
<div class="av-actions">
|
||||
<button class="btn btn-sm" type="button" onclick="Shell.openModal('avatar-up-bg')">上传新头像</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" id="prof-avatar-reset">恢复默认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">显示名称<span class="req">*</span></div>
|
||||
<div class="val"><input class="input" id="prof-name" value="小李" data-track></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">登录邮箱</div>
|
||||
<div class="val">
|
||||
<input class="input" id="prof-email" value="li@shop.com" data-track>
|
||||
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('已发送验证邮件', 'li@shop.com')">验证</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">手机号</div>
|
||||
<div class="val">
|
||||
<input class="input" id="prof-phone" value="138****8000" data-track>
|
||||
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('已发送短信验证码', '+86 138****8000')">更换</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">所属团队<div class="lbl-sub">// 一人一团队</div></div>
|
||||
<div class="val">
|
||||
<span class="static">小李的店</span>
|
||||
<span class="role-tag"><span class="dot"></span>超管 · 创建者</span>
|
||||
<a href="team.html" style="font-size: 12px; color: var(--heat); text-decoration: none; margin-left: auto;">管理团队 →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">用户 ID<div class="lbl-sub">// 不可改</div></div>
|
||||
<div class="val"><span class="static mono">USR-2026-A8F2-001</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── 安全 ─── -->
|
||||
<section class="pane" id="sec-security">
|
||||
<h3>安全</h3>
|
||||
<div class="pane-desc">// 登录密码、双因素、在用设备</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="lbl">登录密码</div>
|
||||
<div class="val">
|
||||
<span class="static mono">●●●●●●●●●●</span>
|
||||
<span class="muted-2 mono" style="font-size: 11px; margin-left: auto;">上次修改 2026-04-12</span>
|
||||
<button class="btn btn-sm" onclick="Shell.toast('修改密码', '/settings/password')" style="margin-left: 10px;">修改</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">两步验证<div class="lbl-sub">// 推荐开启</div></div>
|
||||
<div class="val">
|
||||
<label class="switch"><input type="checkbox" id="opt-2fa"><span class="slider"></span></label>
|
||||
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48);">短信 + Authenticator</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top: 24px;">在用设备</h3>
|
||||
<div class="pane-desc">// 不在此列表上的设备登录会触发短信告警</div>
|
||||
<div>
|
||||
<div class="device-row">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="14" rx="2"/><path d="M2 20h20"/></svg></div>
|
||||
<div>
|
||||
<div class="nm">MacBook Pro · Chrome<span class="tag-cur">CURRENT</span></div>
|
||||
<div class="meta">// 上海 · 2026-05-21 14:08 · IP 116.xxx.xxx.42</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="muted-2 mono" style="font-size: 11px;">当前会话</span>
|
||||
</div>
|
||||
<div class="device-row">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M11 18h2"/></svg></div>
|
||||
<div>
|
||||
<div class="nm">iPhone 15 · Safari</div>
|
||||
<div class="meta">// 上海 · 2026-05-20 21:43</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('已下线', 'iPhone 15')">下线</button>
|
||||
</div>
|
||||
<div class="device-row">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="14" rx="2"/><path d="M2 20h20"/></svg></div>
|
||||
<div>
|
||||
<div class="nm">Windows · Edge</div>
|
||||
<div class="meta">// 杭州 · 2026-05-18 09:12</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('已下线', 'Windows Edge')">下线</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 14px;">
|
||||
<button class="btn" onclick="if(confirm('下线所有其他设备?')) Shell.toast('已下线其他设备', '2 个')">下线所有其他设备</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── 通知 ─── -->
|
||||
<section class="pane" id="sec-notify">
|
||||
<h3>通知</h3>
|
||||
<div class="pane-desc">// 邮件、短信、站内提示开关</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="lbl">项目完成通知<div class="lbl-sub">// 视频导出后</div></div>
|
||||
<div class="val">
|
||||
<label class="switch"><input type="checkbox" id="n-export" checked><span class="slider"></span></label>
|
||||
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48);">站内 · 邮件 · 短信</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">任务失败告警</div>
|
||||
<div class="val">
|
||||
<label class="switch"><input type="checkbox" id="n-fail" checked><span class="slider"></span></label>
|
||||
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48);">站内 · 邮件</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">额度不足提醒<div class="lbl-sub">// 团队或个人剩余 < 20%</div></div>
|
||||
<div class="val">
|
||||
<label class="switch"><input type="checkbox" id="n-quota" checked><span class="slider"></span></label>
|
||||
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48);">站内 · 短信</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">异地登录告警</div>
|
||||
<div class="val">
|
||||
<label class="switch"><input type="checkbox" id="n-login" checked><span class="slider"></span></label>
|
||||
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48);">短信</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── 创作默认 ─── -->
|
||||
<section class="pane" id="sec-pref">
|
||||
<h3>创作默认</h3>
|
||||
<div class="pane-desc">// 新建项目时的预填值,可在向导中改</div>
|
||||
|
||||
<div class="form-row" style="grid-template-columns: 160px 1fr; align-items: flex-start;">
|
||||
<div class="lbl" style="padding-top: 4px;">默认模板</div>
|
||||
<div class="val" style="display: block;">
|
||||
<div class="pref-choices" id="pref-template">
|
||||
<div class="pref-choice selected" data-v="pain"><div class="t">痛点种草</div><div class="d">// 30s 默认档</div></div>
|
||||
<div class="pref-choice" data-v="unbox"><div class="t">开箱测评</div><div class="d">// 45s 默认档</div></div>
|
||||
<div class="pref-choice" data-v="compare"><div class="t">对比展示</div><div class="d">// 45s 默认档</div></div>
|
||||
<div class="pref-choice" data-v="howto"><div class="t">教程演示</div><div class="d">// 60s 默认档</div></div>
|
||||
<div class="pref-choice" data-v="drama"><div class="t">剧情带货</div><div class="d">// 60s 默认档</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">默认时长档</div>
|
||||
<div class="val">
|
||||
<div class="duration-row" id="pref-duration">
|
||||
<span class="dur-chip" data-v="30">30s</span>
|
||||
<span class="dur-chip" data-v="45">45s</span>
|
||||
<span class="dur-chip selected" data-v="60">60s</span>
|
||||
</div>
|
||||
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48); margin-left: 10px;">// 60s = 4 段 × 15s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="grid-template-columns: 160px 1fr; align-items: flex-start;">
|
||||
<div class="lbl" style="padding-top: 4px;">默认字幕样式</div>
|
||||
<div class="val" style="display: block;">
|
||||
<div class="pref-choices" id="pref-subtitle">
|
||||
<div class="pref-choice selected" data-v="big-variety"><div class="t">大字综艺</div><div class="d">// 抖音热门</div></div>
|
||||
<div class="pref-choice" data-v="clean-ec"><div class="t">简洁电商</div><div class="d">// 信息清晰</div></div>
|
||||
<div class="pref-choice" data-v="premium"><div class="t">高级排版</div><div class="d">// 居中衬线</div></div>
|
||||
<div class="pref-choice" data-v="bullet"><div class="t">弹幕轻量</div><div class="d">// 滚动出现</div></div>
|
||||
<div class="pref-choice" data-v="emphasis"><div class="t">强调爆款</div><div class="d">// 高对比</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">默认 BGM 库</div>
|
||||
<div class="val">
|
||||
<select class="select" id="pref-bgm" data-track>
|
||||
<option value="kapian">抖音 Top10 卡点曲库</option>
|
||||
<option value="emotion">情绪向 · 治愈/悬念</option>
|
||||
<option value="urban">都市电子 · 通勤场景</option>
|
||||
<option value="none">无 BGM</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">默认转场</div>
|
||||
<div class="val">
|
||||
<select class="select" id="pref-transition" data-track>
|
||||
<option>无转场</option>
|
||||
<option selected>淡入淡出 · 0.3s</option>
|
||||
<option>滑动 · 0.3s</option>
|
||||
<option>缩放 · 0.3s</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">导出水印<div class="lbl-sub">// VIP 可关闭</div></div>
|
||||
<div class="val">
|
||||
<label class="switch"><input type="checkbox" id="opt-watermark" checked disabled><span class="slider"></span></label>
|
||||
<span class="static mono" style="font-size: 11.5px; color: var(--black-alpha-48);">右下角 · Airshelf</span>
|
||||
<a href="account.html" style="font-size: 12px; color: var(--heat); text-decoration: none; margin-left: auto;">升级 VIP →</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── 显示 ─── -->
|
||||
<section class="pane" id="sec-display">
|
||||
<h3>显示</h3>
|
||||
<div class="pane-desc">// 界面外观与语言</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="lbl">外观</div>
|
||||
<div class="val">
|
||||
<select class="select" id="pref-theme" data-track>
|
||||
<option selected>跟随系统</option>
|
||||
<option>浅色</option>
|
||||
<option disabled>深色(V2)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">语言</div>
|
||||
<div class="val">
|
||||
<select class="select" id="pref-lang" data-track>
|
||||
<option selected>简体中文</option>
|
||||
<option disabled>English(V2)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lbl">表格密度</div>
|
||||
<div class="val">
|
||||
<select class="select" id="pref-density" data-track>
|
||||
<option>紧凑</option>
|
||||
<option selected>标准</option>
|
||||
<option>宽松</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="text-align: center; padding: 24px 0 8px; color: var(--black-alpha-32); font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;">
|
||||
// Airshelf · v2.1 · build 20260521
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ─── 退出登录确认 modal ─── -->
|
||||
<div class="modal-bg" id="logout-confirm-bg" onclick="if(event.target===this)Shell.closeModal('logout-confirm-bg')">
|
||||
<div class="modal logout-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="logout-confirm-title" aria-describedby="logout-confirm-desc">
|
||||
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
|
||||
<div class="modal-h">
|
||||
<div class="ic-m">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
|
||||
</div>
|
||||
<div class="ti" id="logout-confirm-title">退出当前账号<span>// LOG OUT CURRENT SESSION</span></div>
|
||||
</div>
|
||||
<div class="modal-b" id="logout-confirm-desc">
|
||||
<p class="logout-confirm-copy">确认后将退出当前设备上的 Airshelf,再次使用需要重新登录。</p>
|
||||
<div class="logout-confirm-points">
|
||||
<div class="li">项目、资产、团队成员与余额数据都会保留</div>
|
||||
<div class="li">仅影响当前浏览器会话,不会下线其他设备</div>
|
||||
</div>
|
||||
<div class="logout-unsaved-note" id="logout-unsaved-note" hidden>当前有未保存的设置变更,退出后这些变更不会保存。</div>
|
||||
</div>
|
||||
<div class="modal-f">
|
||||
<button class="btn" type="button" id="logout-confirm-cancel" onclick="Shell.closeModal('logout-confirm-bg')">取消</button>
|
||||
<button class="btn btn-primary" type="button" id="logout-confirm-ok">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
|
||||
确认退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 上传头像 modal ─── -->
|
||||
<div class="modal-bg" id="avatar-up-bg" onclick="if(event.target===this)Shell.closeModal('avatar-up-bg')">
|
||||
<div class="modal av-up-modal">
|
||||
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
|
||||
<div class="modal-h">
|
||||
<div class="ic-m">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/></svg>
|
||||
</div>
|
||||
<div class="ti">上传头像<span>// 用于个人主页、评论与团队展示</span></div>
|
||||
</div>
|
||||
<div class="modal-b np-body">
|
||||
|
||||
<div class="av-up-preview-row">
|
||||
<div class="av-up-preview" id="av-up-preview">李</div>
|
||||
<div class="av-up-preview-meta">
|
||||
<div class="t" id="av-up-preview-name">当前头像 · 默认</div>
|
||||
<div class="d" id="av-up-preview-info">// 系统生成 · 取姓氏首字</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-zone" id="av-up-zone" tabindex="0" role="button" aria-label="点击或拖入图片上传">
|
||||
<span class="uz-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
</span>
|
||||
<div><strong>点击选择</strong> · 或拖入图片</div>
|
||||
<span class="uz-hint">JPG / PNG / WebP · ≤ 2 MB · 推荐 256 × 256</span>
|
||||
<input type="file" id="av-up-file" accept="image/jpeg,image/png,image/webp" hidden>
|
||||
</div>
|
||||
|
||||
<div class="av-up-rules">
|
||||
<div class="li">最大 2 MB · 长宽比建议 1:1 · 系统会自动裁切为圆形</div>
|
||||
<div class="li">不要上传含他人肖像的图片,违规可能导致账号封停</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-f">
|
||||
<button class="btn" type="button" onclick="Shell.closeModal('avatar-up-bg')">取消</button>
|
||||
<button class="btn btn-primary" type="button" id="av-up-confirm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>
|
||||
确认使用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="assets/icons.js?v=2026052608"></script>
|
||||
<script src="assets/shell.js?v=2026060101"></script>
|
||||
<script>
|
||||
Shell.render({
|
||||
active: 'settings',
|
||||
crumbs: [{ label: '工作台', href: 'index.html' }, { label: '设置' }]
|
||||
});
|
||||
|
||||
/* ─── 配置 ─── */
|
||||
const SECTIONS = ['sec-profile', 'sec-security', 'sec-notify', 'sec-pref', 'sec-display'];
|
||||
const navLinks = document.querySelectorAll('.settings-nav a[data-jump]');
|
||||
let suppressLeavePrompt = false;
|
||||
|
||||
function logoutCurrentDevice() {
|
||||
const note = document.getElementById('logout-unsaved-note');
|
||||
if (note) note.hidden = !(typeof dirtyFields !== 'undefined' && dirtyFields.size > 0);
|
||||
Shell.openModal('logout-confirm-bg');
|
||||
requestAnimationFrame(() => document.getElementById('logout-confirm-cancel')?.focus());
|
||||
}
|
||||
|
||||
function confirmLogout() {
|
||||
suppressLeavePrompt = true;
|
||||
Shell.closeModal('logout-confirm-bg');
|
||||
Shell.toast('已退出', '正在跳转登录页');
|
||||
setTimeout(() => location.href = 'login.html', 600);
|
||||
}
|
||||
|
||||
document.getElementById('logout-confirm-ok')?.addEventListener('click', confirmLogout);
|
||||
|
||||
/* ─── 1. 点击 nav → 只显示对应 section,其余隐藏 + 同步 URL hash ─── */
|
||||
function showSection(id) {
|
||||
if (!SECTIONS.includes(id)) id = SECTIONS[0];
|
||||
SECTIONS.forEach(sid => {
|
||||
const el = document.getElementById(sid);
|
||||
if (el) el.style.display = (sid === id) ? '' : 'none';
|
||||
});
|
||||
navLinks.forEach(a => a.classList.toggle('active', a.dataset.jump === id));
|
||||
if (location.hash.slice(1) !== id) {
|
||||
history.replaceState(null, '', '#' + id);
|
||||
}
|
||||
// 切换面板后回到顶端,避免长面板留下的滚动位置错乱
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
}
|
||||
|
||||
navLinks.forEach(a => {
|
||||
a.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
showSection(a.dataset.jump);
|
||||
a.focus();
|
||||
});
|
||||
a.addEventListener('keydown', e => {
|
||||
const idx = [...navLinks].indexOf(a);
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); navLinks[(idx + 1) % navLinks.length].focus(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); navLinks[(idx - 1 + navLinks.length) % navLinks.length].focus(); }
|
||||
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); a.click(); }
|
||||
});
|
||||
});
|
||||
|
||||
/* ─── 2. 初始加载:读 URL hash 显示对应 section(无 hash 时默认 sec-profile) ─── */
|
||||
(function initSection() {
|
||||
const hash = location.hash.slice(1);
|
||||
showSection(SECTIONS.includes(hash) ? hash : 'sec-profile');
|
||||
})();
|
||||
|
||||
/* hash 外部变化(如浏览器前进后退)也跟着切 */
|
||||
window.addEventListener('hashchange', () => {
|
||||
const hash = location.hash.slice(1);
|
||||
if (SECTIONS.includes(hash)) showSection(hash);
|
||||
});
|
||||
|
||||
/* ─── 4. 偏好 chip 选择 ─── */
|
||||
function bindChoice(containerId) {
|
||||
const ct = document.getElementById(containerId);
|
||||
if (!ct) return;
|
||||
ct.querySelectorAll('.pref-choice').forEach(c => {
|
||||
c.addEventListener('click', () => {
|
||||
ct.querySelectorAll('.pref-choice').forEach(x => x.classList.remove('selected'));
|
||||
c.classList.add('selected');
|
||||
markDirty(c);
|
||||
});
|
||||
});
|
||||
}
|
||||
bindChoice('pref-template');
|
||||
bindChoice('pref-subtitle');
|
||||
|
||||
document.querySelectorAll('#pref-duration .dur-chip').forEach(c => {
|
||||
c.addEventListener('click', () => {
|
||||
document.querySelectorAll('#pref-duration .dur-chip').forEach(x => x.classList.remove('selected'));
|
||||
c.classList.add('selected');
|
||||
markDirty(c);
|
||||
});
|
||||
});
|
||||
|
||||
/* ─── 5. dirty state · 追踪改了哪几节 + save btn 显示变更条数 ─── */
|
||||
const dirtyFields = new Set(); // 改过的字段 id 集合
|
||||
const dirtySections = new Set(); // 涉及的 section id 集合
|
||||
const saveBtn = document.getElementById('save-btn');
|
||||
const cancelBtn = document.getElementById('save-cancel');
|
||||
const saveBtnDefaultLabel = saveBtn.innerHTML;
|
||||
|
||||
function sectionOf(el) {
|
||||
const sec = el.closest('section[id]');
|
||||
return sec ? sec.id : null;
|
||||
}
|
||||
|
||||
function syncSaveBtn() {
|
||||
const n = dirtyFields.size;
|
||||
if (n === 0) {
|
||||
saveBtn.disabled = true;
|
||||
cancelBtn.disabled = true;
|
||||
saveBtn.style.opacity = '.5';
|
||||
saveBtn.style.cursor = 'not-allowed';
|
||||
cancelBtn.style.opacity = '.5';
|
||||
cancelBtn.style.cursor = 'not-allowed';
|
||||
saveBtn.innerHTML = saveBtnDefaultLabel;
|
||||
} else {
|
||||
saveBtn.disabled = false;
|
||||
cancelBtn.disabled = false;
|
||||
saveBtn.style.opacity = '';
|
||||
saveBtn.style.cursor = '';
|
||||
cancelBtn.style.opacity = '';
|
||||
cancelBtn.style.cursor = '';
|
||||
saveBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg> 保存所有变更 <span style="opacity:.75;font-family:var(--font-mono);font-size:11px;margin-left:4px;">· ${n} 项</span>`;
|
||||
}
|
||||
// nav 上加 dirty dot
|
||||
navLinks.forEach(a => a.classList.toggle('has-changes', dirtySections.has(a.dataset.jump)));
|
||||
}
|
||||
|
||||
function markDirty(el) {
|
||||
const id = el.id || (el.dataset && el.dataset.v) || ('anon-' + Math.random().toString(36).slice(2, 8));
|
||||
if (dirtyFields.has(id)) return;
|
||||
dirtyFields.add(id);
|
||||
const sec = sectionOf(el);
|
||||
if (sec) dirtySections.add(sec);
|
||||
syncSaveBtn();
|
||||
}
|
||||
function clearDirty() {
|
||||
dirtyFields.clear();
|
||||
dirtySections.clear();
|
||||
syncSaveBtn();
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-track], input[type="checkbox"], select').forEach(el => {
|
||||
el.addEventListener('change', () => markDirty(el));
|
||||
if (el.tagName === 'INPUT' && el.type !== 'checkbox') el.addEventListener('input', () => markDirty(el));
|
||||
});
|
||||
|
||||
/* ─── 6. 表单校验:必填 + 邮箱格式 ─── */
|
||||
const VALIDATORS = {
|
||||
'prof-name': v => v.trim().length === 0 ? '显示名称不能为空' : (v.length > 20 ? '显示名称最多 20 字' : null),
|
||||
'prof-email': v => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()) ? '邮箱格式不正确' : null,
|
||||
'prof-phone': v => !/^\d{3}\*{4}\d{4}$|^1\d{10}$/.test(v.trim()) ? '手机号格式不正确' : null,
|
||||
};
|
||||
function validate() {
|
||||
const errors = [];
|
||||
Object.entries(VALIDATORS).forEach(([id, fn]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const err = fn(el.value);
|
||||
el.classList.toggle('invalid', !!err);
|
||||
if (err) errors.push(`${id}: ${err}`);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
/* ─── 7. 保存 / 取消 ─── */
|
||||
saveBtn.addEventListener('click', () => {
|
||||
if (dirtyFields.size === 0) return;
|
||||
const errors = validate();
|
||||
if (errors.length) {
|
||||
Shell.toast('校验未通过', errors[0].split(': ')[1] + ' · 共 ' + errors.length + ' 项');
|
||||
return;
|
||||
}
|
||||
Shell.toast('设置已保存', `${dirtyFields.size} 项变更已生效 · 涉及 ${dirtySections.size} 个分区`);
|
||||
clearDirty();
|
||||
});
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
if (dirtyFields.size === 0) return;
|
||||
if (confirm(`放弃 ${dirtyFields.size} 项未保存的变更?`)) location.reload();
|
||||
});
|
||||
|
||||
/* ─── 8. 离开页面前提醒(有未保存变更时)─── */
|
||||
window.addEventListener('beforeunload', e => {
|
||||
if (!suppressLeavePrompt && dirtyFields.size > 0) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
/* ─── 9. invalid input 视觉反馈样式 ─── */
|
||||
const _invalidStyle = document.createElement('style');
|
||||
_invalidStyle.textContent = `
|
||||
.input.invalid { border-color: var(--accent-crimson); box-shadow: 0 0 0 3px rgba(235,52,36,.12); }
|
||||
.input.invalid:focus { border-color: var(--accent-crimson); box-shadow: 0 0 0 3px rgba(235,52,36,.18); }
|
||||
`;
|
||||
document.head.appendChild(_invalidStyle);
|
||||
|
||||
/* ─── 10. nav badge 计数自动同步(从 section 内 switch 开启数 / device 数等) ─── */
|
||||
function syncBadges() {
|
||||
const secNotify = document.getElementById('sec-notify');
|
||||
if (secNotify) {
|
||||
const onCount = secNotify.querySelectorAll('input[type="checkbox"]:checked').length;
|
||||
const totalCount = secNotify.querySelectorAll('input[type="checkbox"]').length;
|
||||
const badge = document.querySelector('.settings-nav a[data-jump="sec-notify"] .nav-badge');
|
||||
if (badge) badge.textContent = `${onCount}/${totalCount}`;
|
||||
}
|
||||
const secSecurity = document.getElementById('sec-security');
|
||||
if (secSecurity) {
|
||||
const devCount = secSecurity.querySelectorAll('.device-row').length;
|
||||
const badge = document.querySelector('.settings-nav a[data-jump="sec-security"] .nav-badge');
|
||||
if (badge) badge.textContent = `${devCount} 设备`;
|
||||
}
|
||||
}
|
||||
syncBadges();
|
||||
document.querySelectorAll('#sec-notify input[type="checkbox"]').forEach(cb => cb.addEventListener('change', syncBadges));
|
||||
|
||||
/* ─── 11. 头像上传 modal ─── */
|
||||
const DEFAULT_AVATAR = { kind: 'default', label: '李', name: '默认', info: '// 系统生成 · 取姓氏首字' };
|
||||
let _draftAvatar = { ...DEFAULT_AVATAR };
|
||||
let _currentAvatar = { ...DEFAULT_AVATAR };
|
||||
|
||||
const avPreview = document.getElementById('av-up-preview');
|
||||
const avPreviewName = document.getElementById('av-up-preview-name');
|
||||
const avPreviewInfo = document.getElementById('av-up-preview-info');
|
||||
const avProfPreview = document.getElementById('prof-avatar-preview');
|
||||
const avZone = document.getElementById('av-up-zone');
|
||||
const avFile = document.getElementById('av-up-file');
|
||||
const avConfirm = document.getElementById('av-up-confirm');
|
||||
const avResetBtn = document.getElementById('prof-avatar-reset');
|
||||
|
||||
function paintPreview() {
|
||||
if (_draftAvatar.kind === 'image') {
|
||||
avPreview.innerHTML = `<img src="${_draftAvatar.src}" alt="头像预览">`;
|
||||
} else {
|
||||
avPreview.innerHTML = '';
|
||||
avPreview.textContent = _draftAvatar.label || '李';
|
||||
}
|
||||
avPreviewName.textContent = `预览 · ${_draftAvatar.name}`;
|
||||
avPreviewInfo.textContent = _draftAvatar.info;
|
||||
}
|
||||
|
||||
function applyAvatarTo(el, avatar) {
|
||||
if (avatar.kind === 'image') {
|
||||
el.innerHTML = `<img src="${avatar.src}" alt="头像" style="width:100%;height:100%;object-fit:cover;border-radius:50%;display:block;">`;
|
||||
} else {
|
||||
el.innerHTML = '';
|
||||
el.textContent = avatar.label || '李';
|
||||
}
|
||||
}
|
||||
|
||||
avZone.addEventListener('click', () => avFile.click());
|
||||
avZone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); avFile.click(); } });
|
||||
avZone.addEventListener('dragover', e => { e.preventDefault(); avZone.classList.add('dragover'); });
|
||||
avZone.addEventListener('dragleave', () => avZone.classList.remove('dragover'));
|
||||
avZone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
avZone.classList.remove('dragover');
|
||||
const f = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||
if (f) handleAvatarFile(f);
|
||||
});
|
||||
avFile.addEventListener('change', () => { if (avFile.files[0]) handleAvatarFile(avFile.files[0]); });
|
||||
|
||||
function handleAvatarFile(f) {
|
||||
if (!/^image\/(jpeg|png|webp)$/.test(f.type)) {
|
||||
Shell.toast('格式不支持', '仅支持 JPG / PNG / WebP');
|
||||
return;
|
||||
}
|
||||
if (f.size > 2 * 1024 * 1024) {
|
||||
Shell.toast('文件过大', `${(f.size / 1024 / 1024).toFixed(2)} MB · 限 2 MB`);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(f);
|
||||
_draftAvatar = { kind: 'image', src: url, name: f.name, info: `// ${(f.size / 1024).toFixed(0)} KB · ${f.type.split('/')[1].toUpperCase()}` };
|
||||
paintPreview();
|
||||
}
|
||||
|
||||
avConfirm.addEventListener('click', () => {
|
||||
_currentAvatar = { ..._draftAvatar };
|
||||
applyAvatarTo(avProfPreview, _currentAvatar);
|
||||
Shell.closeModal('avatar-up-bg');
|
||||
Shell.toast('头像已更新', _currentAvatar.name);
|
||||
markDirty(avProfPreview);
|
||||
});
|
||||
|
||||
avResetBtn.addEventListener('click', () => {
|
||||
_currentAvatar = { ...DEFAULT_AVATAR };
|
||||
applyAvatarTo(avProfPreview, _currentAvatar);
|
||||
_draftAvatar = { ...DEFAULT_AVATAR };
|
||||
paintPreview();
|
||||
Shell.toast('已恢复默认头像', _currentAvatar.name);
|
||||
markDirty(avProfPreview);
|
||||
});
|
||||
|
||||
// 打开 modal 时同步当前到 draft + preview
|
||||
document.querySelector('.av-actions button.btn-sm').addEventListener('click', () => {
|
||||
_draftAvatar = { ..._currentAvatar };
|
||||
paintPreview();
|
||||
});
|
||||
|
||||
paintPreview();
|
||||
|
||||
/* ─── 12. 实时邮箱 / 名称 input 校验反馈(blur 时显示错) ─── */
|
||||
['prof-name', 'prof-email', 'prof-phone'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || !VALIDATORS[id]) return;
|
||||
el.addEventListener('blur', () => {
|
||||
const err = VALIDATORS[id](el.value);
|
||||
el.classList.toggle('invalid', !!err);
|
||||
});
|
||||
el.addEventListener('input', () => {
|
||||
if (el.classList.contains('invalid')) {
|
||||
const err = VALIDATORS[id](el.value);
|
||||
if (!err) el.classList.remove('invalid');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="assets/api-bridge.js?v=2026060114"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,644 @@
|
||||
# Airshelf 页面流程定稿记录
|
||||
|
||||
> 本文件用于记录和用户逐页确认后的产品页面流程。
|
||||
> PRD 只作为功能边界参考,页面布局与交互以本文件后续定稿为准。
|
||||
|
||||
维护日期:2026-05-28
|
||||
|
||||
---
|
||||
|
||||
## 0. 总体原则
|
||||
|
||||
- 创作视频是产品主线任务。
|
||||
- 当前版本单条生成视频的时长上限是 15s。
|
||||
- AI 图片创作是附加能力,用于辅助商品图、人物、场景等素材生产,不抢视频主线。
|
||||
- 页面跳转尽量少,能在当前页面完成的操作尽量不跳转。
|
||||
- 操作尽量简单,第一次使用 AI 产品的用户也应该能理解。
|
||||
- 5 个 Stage 是步骤引导,不是强制锁定流程。
|
||||
- 用户可以在脚本、基础资产、故事板、视频、拼接导出之间自由跳转。
|
||||
- 如果某个 Stage 缺少前置内容,页面给提示和快捷补全入口,不做硬锁死。
|
||||
- 暂不做复制项目、归档、审核、待审核、待我确认等复杂项目管理概念。
|
||||
|
||||
---
|
||||
|
||||
## 1. 视频项目页 `/projects`
|
||||
|
||||
### 页面定位
|
||||
|
||||
视频项目页是视频生产任务列表,帮助用户看清每个视频项目做到哪一步,以及下一步能点什么。
|
||||
|
||||
### 不做内容
|
||||
|
||||
- 不做复制项目。
|
||||
- 不做归档。
|
||||
- 不做审核 / 待审核 / 待我确认。
|
||||
- 不做草稿分类。
|
||||
- 不做复杂批量操作。
|
||||
- AI 图片创作不作为顶部主按钮。
|
||||
|
||||
### 状态分类
|
||||
|
||||
只保留:
|
||||
|
||||
- 全部
|
||||
- 进行中
|
||||
- 生成中
|
||||
- 已完成
|
||||
- 失败
|
||||
|
||||
### 页面结构
|
||||
|
||||
1. Page Head
|
||||
- 标题:视频项目
|
||||
- 主按钮:新建视频项目
|
||||
|
||||
2. 状态概览 / Tab
|
||||
- 全部
|
||||
- 进行中
|
||||
- 生成中
|
||||
- 已完成
|
||||
- 失败
|
||||
|
||||
3. 工具栏
|
||||
- 搜索项目 / 商品名
|
||||
- 商品筛选
|
||||
- 状态筛选
|
||||
- 最近更新排序
|
||||
- 可选:列表 / 卡片视图切换
|
||||
|
||||
4. 项目列表
|
||||
- 默认列表行,不默认大卡片。
|
||||
- 每行展示:
|
||||
- 9:16 缩略图
|
||||
- 项目名
|
||||
- 关联商品
|
||||
- 当前阶段
|
||||
- 5 段进度:脚本 / 资产 / 故事板 / 视频 / 导出
|
||||
- 更新时间
|
||||
- 状态
|
||||
- 主操作:继续 / 查看进度 / 查看成片 / 重试
|
||||
|
||||
5. 空状态
|
||||
- 没有商品:引导新建商品。
|
||||
- 有商品但没有项目:引导新建视频项目。
|
||||
|
||||
---
|
||||
|
||||
## 2. 新建视频项目页 `/projects/new`
|
||||
|
||||
### 页面定位
|
||||
|
||||
新建项目页只负责创建项目壳子,并绑定商品。
|
||||
脚本怎么生成、脚本风格、任务设定等全部放到 Stage 1 脚本页。
|
||||
|
||||
### 交互原则
|
||||
|
||||
- 一个页面内完成,不做多步跳转。
|
||||
- 只问用户:为哪个商品创建视频。
|
||||
- 创建后直接进入项目流水线的 Stage 1 脚本页。
|
||||
|
||||
### 字段
|
||||
|
||||
必选:
|
||||
|
||||
- 商品
|
||||
|
||||
可选:
|
||||
|
||||
- 项目名称
|
||||
- 默认自动生成,例如:商品名 + 短视频 + 日期。
|
||||
|
||||
不展示:
|
||||
|
||||
- 创作方式
|
||||
- 脚本风格
|
||||
- 任务设定
|
||||
- 商品卖点选择
|
||||
- 预估消耗
|
||||
|
||||
### 卖点选择归属
|
||||
|
||||
商品卖点不放在新建项目页。
|
||||
商品卖点选择放到 Stage 1 脚本页,作为“本条视频重点”配置项,从商品库自动带出。
|
||||
|
||||
---
|
||||
|
||||
## 3. Stage 1 脚本页
|
||||
|
||||
### 页面定位
|
||||
|
||||
脚本页是 AI 脚本工作台。
|
||||
用户通过 AI 输入和启动卡片完成脚本来源选择、卖点选择、风格设定、任务设定,并在中间区域查看完整读秒流分镜脚本。
|
||||
|
||||
### 核心原则
|
||||
|
||||
- 脚本是用户最关注的内容,必须放在中间最大区域。
|
||||
- 脚本不按 15s 大段展示。
|
||||
- 脚本按完整读秒流分镜展示,适合短视频细看。
|
||||
- AI 聊天不是主内容,而是脚本修改和生成记录。
|
||||
- AI 输入框放页面底部固定,作为当前 Stage 的全局指令入口。
|
||||
|
||||
### 页面布局
|
||||
|
||||
顶部:
|
||||
|
||||
- 项目面包屑
|
||||
- 商品名 / 项目名
|
||||
- 5 Stage 自由切换导航:脚本 / 基础资产 / 故事板 / 视频 / 拼接导出
|
||||
|
||||
主体:
|
||||
|
||||
- 左侧窄栏:商品信息 / 卖点 / 人物 / 场景等辅助信息
|
||||
- 中间主栏:读秒流分镜脚本
|
||||
- 右侧窄栏:AI 对话记录 / 操作历史
|
||||
|
||||
底部固定栏:
|
||||
|
||||
- AI 输入框
|
||||
- 页面操作按钮,例如重新生成全部、进入下一步
|
||||
|
||||
### 空状态引导
|
||||
|
||||
脚本来源不做 Tab。
|
||||
脚本未生成时,中间主区域展示启动卡片:
|
||||
|
||||
- AI 帮我写
|
||||
根据商品信息自动生成完整读秒流分镜。
|
||||
|
||||
- 我有脚本
|
||||
粘贴已有口播稿,AI 帮你整理成分镜结构。
|
||||
|
||||
- 一句话生成
|
||||
输入一句视频方向,AI 生成完整分镜。
|
||||
|
||||
- 复刻爆款
|
||||
暂未开放或后续版本处理。
|
||||
|
||||
用户选择后进入对应模式。生成脚本后,中间区域切换为完整读秒流分镜。
|
||||
|
||||
### 读秒流分镜结构
|
||||
|
||||
每个分镜以卡片 / 行卡展示:
|
||||
|
||||
- 镜头编号
|
||||
- 时间段,例如 0-5s、5-10s
|
||||
- 画面描述
|
||||
- 对白 / 旁白
|
||||
- 可选:镜头类型、场景、人物
|
||||
|
||||
用户可以:
|
||||
|
||||
- 直接编辑某个分镜文字。
|
||||
- 用底部 AI 输入框修改某个分镜。
|
||||
- 重新生成全部。
|
||||
- 调整整体时长、口吻、卖点重点等。
|
||||
|
||||
### 商品卖点
|
||||
|
||||
- 从商品库自动带出。
|
||||
- 在脚本页展示为“本条视频重点”。
|
||||
- 默认选中前 2-3 个,或默认使用全部卖点。
|
||||
- 用户可点选 / 取消。
|
||||
- 用户也可以直接在 AI 输入框里说重点突出哪些卖点。
|
||||
|
||||
---
|
||||
|
||||
## 4. Stage 2 基础资产页
|
||||
|
||||
### 页面定位
|
||||
|
||||
基础资产页用于为当前视频准备视觉资产。
|
||||
它不是资产库,也不是完整 AI 图片创作页。
|
||||
|
||||
### 总体原则
|
||||
|
||||
- 默认 AI 自动生成,用户只在效果不好时调整。
|
||||
- 脚本确定后,AI 自动识别需要的商品、人物、场景,并生成图片提示词。
|
||||
- 页面优先让用户看默认生成效果。
|
||||
- 不满意时再修改提示词、上传参考图、从资产库选择或重新生成。
|
||||
- 商品是最重要的,优先展示商品资产。
|
||||
|
||||
### 页面布局
|
||||
|
||||
- 左侧:资产清单
|
||||
- 中间:当前资产工作台 / 生成结果
|
||||
- 右侧:AI 操作记录 / 生成记录
|
||||
- 底部:AI 输入框
|
||||
|
||||
### 资产顺序
|
||||
|
||||
1. 商品资产
|
||||
2. 人物
|
||||
3. 场景
|
||||
|
||||
### 商品资产
|
||||
|
||||
商品三视图是一张 16:9 图,包含正面、侧面、背面。
|
||||
它不是三张图,也不拆分成三个槽位。
|
||||
|
||||
不提供:
|
||||
|
||||
- 上传三视图
|
||||
- 使用现有商品图
|
||||
|
||||
原因:
|
||||
|
||||
- 用户一般不会有适合生成视频的白底三视图。
|
||||
- 如果不生成三视图,系统本来就只能默认使用商品图继续,不需要多给一个“使用现有商品图”的操作。
|
||||
|
||||
推荐交互:
|
||||
|
||||
- 展示当前商品图。
|
||||
- 展示商品三视图状态:未生成 / 生成中 / 已生成。
|
||||
- 说明:生成 16:9 商品三视图可以提升商品角度稳定性。
|
||||
- 主操作:生成商品三视图。
|
||||
- 不生成三视图也可以继续,只做弱提示。
|
||||
|
||||
生成规则:
|
||||
|
||||
- 商品三视图属于结构转换型。
|
||||
- 默认一次生成 1 张。
|
||||
- 不满意可重新生成或用 AI 输入框调整提示词。
|
||||
- 商品三视图支持保留历史版本,用户可以在历史版本中选择满意的一版采用。
|
||||
|
||||
### 人物资产
|
||||
|
||||
命名为“人物”,不叫“模特”。
|
||||
人物指脚本中需要出现的角色,用户要确定这个角色的形象。
|
||||
|
||||
默认流程:
|
||||
|
||||
1. AI 根据脚本自动识别人物。
|
||||
2. AI 为每个人物自动生成图片提示词。
|
||||
3. 默认生成 4 张人物立绘候选。
|
||||
4. 用户选择 1 张满意的立绘。
|
||||
5. AI 基于选中的立绘生成 1 张 16:9 人物三视图。
|
||||
6. 用户采用此人物资产。
|
||||
|
||||
不满意时,用户可以:
|
||||
|
||||
- 修改描述。
|
||||
- 上传自己的模特 / 参考图。
|
||||
- 从人物库选择。
|
||||
- 重新生成立绘。
|
||||
- 从历史立绘里再选一张生成三视图。
|
||||
- 重新生成人物三视图。
|
||||
- 从人物三视图历史版本中选择满意的一版采用。
|
||||
|
||||
人物调整不放抽屉。
|
||||
点击人物后,中间区域直接切换为人物工作台。
|
||||
|
||||
人物工作台需要一步到位承载:
|
||||
|
||||
- 人物库选择
|
||||
- 上传参考图
|
||||
- 描述生成
|
||||
- 直接使用自己的模特
|
||||
- AI 生成多张立绘
|
||||
- 从立绘生成三视图
|
||||
- 从历史立绘再选再生成三视图
|
||||
|
||||
### 场景资产
|
||||
|
||||
默认流程:
|
||||
|
||||
1. AI 根据脚本识别场景。
|
||||
2. AI 自动生成场景图提示词。
|
||||
3. 默认一次生成 4 张候选。
|
||||
4. 用户选择 1 张。
|
||||
|
||||
不满意时,用户可以:
|
||||
|
||||
- 修改描述。
|
||||
- 重新生成。
|
||||
- 上传替换。
|
||||
|
||||
### 候选生成规则
|
||||
|
||||
创意选择型:一次生成 4 张。
|
||||
|
||||
- 人物立绘
|
||||
- 场景图
|
||||
|
||||
结构转换型:一次生成 1 张。
|
||||
|
||||
- 商品三视图
|
||||
- 人物三视图
|
||||
|
||||
三视图版本规则:
|
||||
|
||||
- 商品三视图和人物三视图都可以重跑。
|
||||
- 每次重跑产生一个历史版本。
|
||||
- 用户可以在历史版本中回选满意的一版。
|
||||
- 采用某一版后,该版本成为当前项目使用版本。
|
||||
|
||||
### 资产复用
|
||||
|
||||
人物默认是项目内资产,不自动进入团队人物库,避免资产库变乱。
|
||||
|
||||
采用人物时可以勾选:
|
||||
|
||||
- 保存到人物库
|
||||
|
||||
保存后:
|
||||
|
||||
- 进入团队共享人物库。
|
||||
- 后续项目可复用。
|
||||
- 记录来源:项目内生成 / AI 生成 / 上传参考图等。
|
||||
|
||||
---
|
||||
|
||||
## 5. Stage 3 故事板页(已确认原则,待细化)
|
||||
|
||||
### 页面定位
|
||||
|
||||
故事板页是可选的预览步骤,不是视频生成的必经步骤。
|
||||
|
||||
### 已确认原则
|
||||
|
||||
- 故事板不是一定要生成。
|
||||
- 用户可以跳过故事板,直接进入视频生成。
|
||||
- 故事板的价值是提前预览视频大致画面和内容。
|
||||
- 故事板有时会固定最终视频画面,反而可能让视频生成效果不如不使用故事板。
|
||||
- 因此故事板不能作为强制前置条件。
|
||||
- Stage 3 应该提供“生成故事板预览”和“跳过,直接生成视频”两条路径。
|
||||
- 如果用户跳过故事板,Stage 4 直接基于脚本和基础资产生成视频。
|
||||
- 故事板提示词直接在原提示词基础上修改。
|
||||
- 暂不做独立 AI 辅助输入框或 AI 聊天式提示词修改。
|
||||
- 故事板绑定资产需要展示,但只做轻量引用说明。
|
||||
- 绑定资产默认不展示缩略图。
|
||||
- 绑定资产用文字标签 / 资产引用标签展示。
|
||||
- Hover / Popover 时展示资产详情。
|
||||
- Stage 3 不直接编辑资产,修改资产需要回到 Stage 2。
|
||||
- 如果绑定资产在 Stage 2 被替换,Stage 3 显示“绑定资产已更新,建议重新生成故事板”。
|
||||
|
||||
### 绑定资产展示
|
||||
|
||||
绑定资产展示的是引用关系,而不是图片预览。
|
||||
|
||||
示例:
|
||||
|
||||
- 商品:补水面膜
|
||||
- 人物:女主、同事
|
||||
- 场景:卫生间、书桌
|
||||
|
||||
或以标签形式展示:
|
||||
|
||||
- 商品 · 补水面膜
|
||||
- 人物 · 女主
|
||||
- 人物 · 同事
|
||||
- 场景 · 卫生间
|
||||
|
||||
Hover 详情示例:
|
||||
|
||||
- 商品 · 补水面膜
|
||||
- 商品图数量
|
||||
- 商品三视图状态
|
||||
- 来源:商品库
|
||||
- 操作:查看 / 去基础资产修改
|
||||
|
||||
- 人物 · 女主
|
||||
- 当前立绘状态
|
||||
- 人物三视图状态
|
||||
- 来源:项目内 / 人物库
|
||||
- 操作:查看 / 去基础资产修改
|
||||
|
||||
### 提示词编辑
|
||||
|
||||
- 用户直接在原本提示词基础上修改。
|
||||
- 提示词就是重新生成故事板的依据。
|
||||
- 第一版不做 AI 辅助修改提示词。
|
||||
- 可保留恢复原始提示词、重新生成等基础操作。
|
||||
|
||||
### 待继续确认
|
||||
|
||||
- 故事板是否进入页面后自动生成,还是由用户点击生成。
|
||||
- 生成视频时,已生成故事板是否默认作为参考,还是由用户选择是否使用。
|
||||
- 故事板历史版本如何展示。
|
||||
|
||||
---
|
||||
|
||||
## 6. 待继续确认页面
|
||||
|
||||
- Stage 5 拼接导出页
|
||||
- 商品库 / 商品详情
|
||||
- 资产库
|
||||
- AI 图片创作
|
||||
- 人物库
|
||||
- 工作台
|
||||
- 团队
|
||||
- 消费
|
||||
- 设置
|
||||
|
||||
---
|
||||
|
||||
## 7. Stage 5 拼接导出页
|
||||
|
||||
### 页面定位
|
||||
|
||||
拼接导出页是最终成片工作台。
|
||||
它负责把 Stage 4 采用的 AI 视频片段、用户上传的视频、资产库视频等素材自由拼接,并完成字幕、BGM、转场和导出。
|
||||
|
||||
### 布局方向
|
||||
|
||||
参考剪映 / CapCut 的基础剪辑布局,但只保留轻量功能。
|
||||
|
||||
页面区域:
|
||||
|
||||
- 顶部:项目名、Stage 导航、导出状态
|
||||
- 左侧:素材池
|
||||
- 中间上方:成片预览播放器
|
||||
- 右侧:属性设置
|
||||
- 底部:时间线
|
||||
|
||||
### 素材池
|
||||
|
||||
素材池包含:
|
||||
|
||||
- AI 生成视频片段
|
||||
- 上传视频
|
||||
- 批量上传视频
|
||||
- 从资产库选择视频
|
||||
|
||||
上传视频时支持勾选:
|
||||
|
||||
- 保存到资产库,供以后复用
|
||||
|
||||
默认不勾选,避免资产库变乱。
|
||||
|
||||
### 时间线
|
||||
|
||||
第一版只做单主轨轻剪辑。
|
||||
|
||||
支持:
|
||||
|
||||
- 自动放入 Stage 4 当前采用的 AI 视频片段,按顺序排列
|
||||
- 拖拽排序
|
||||
- 添加上传视频
|
||||
- 删除片段
|
||||
- 替换片段
|
||||
- 裁剪起止点
|
||||
|
||||
不做:
|
||||
|
||||
- 多轨自由叠加
|
||||
- 关键帧
|
||||
- 复杂音频混音
|
||||
- 速度曲线
|
||||
- 画中画
|
||||
- 蒙版
|
||||
- 贴纸 / 复杂特效
|
||||
|
||||
### 裁剪
|
||||
|
||||
需要支持裁剪。
|
||||
|
||||
选中时间线片段后,右侧属性区显示:
|
||||
|
||||
- 开始时间
|
||||
- 结束时间
|
||||
- 当前片段时长
|
||||
- 应用裁剪
|
||||
|
||||
时间线片段两侧可保留轻量拖拽手柄。
|
||||
|
||||
### 字幕
|
||||
|
||||
字幕默认自动添加。
|
||||
|
||||
规则:
|
||||
|
||||
- 进入 Stage 5 后,系统根据脚本台词 / 旁白自动生成字幕。
|
||||
- 字幕默认开启。
|
||||
- 用户可以关闭字幕。
|
||||
- 用户可以编辑字幕文字和时间。
|
||||
- 第一版不强制支持识别用户上传视频语音生成字幕。
|
||||
|
||||
右侧属性区默认展示:
|
||||
|
||||
- 字幕开关
|
||||
- 字幕来源:脚本台词
|
||||
- 字幕样式
|
||||
- 编辑字幕入口
|
||||
|
||||
字幕编辑使用轻量面板,不做复杂字幕工作台。
|
||||
|
||||
示例:
|
||||
|
||||
- 00:00 - 00:03 熬夜后皮肤干到起皮?
|
||||
- 00:03 - 00:06 这片补水面膜我最近一直在用。
|
||||
- 00:06 - 00:10 敷完脸会明显更透亮。
|
||||
|
||||
### BGM / 转场 / 导出
|
||||
|
||||
右侧属性区包含:
|
||||
|
||||
- BGM 开关
|
||||
- BGM 选择
|
||||
- 原声 / BGM 音量
|
||||
- 转场开关
|
||||
- 转场类型
|
||||
- 导出比例,默认 9:16
|
||||
- 清晰度,默认 1080P
|
||||
- 格式,默认 MP4
|
||||
|
||||
BGM 默认开启,用户可修改或关闭。
|
||||
转场默认开启,用户可修改或关闭。
|
||||
|
||||
### 导出成功
|
||||
|
||||
导出成功后不新增独立页面。
|
||||
在 Stage 5 当前页面展示导出成功状态 / 结果面板。
|
||||
|
||||
导出中:
|
||||
|
||||
- 显示导出进度。
|
||||
|
||||
导出成功后展示:
|
||||
|
||||
- 成片预览播放器
|
||||
- 下载 MP4
|
||||
- 复制链接
|
||||
- 查看成片资产
|
||||
- 继续编辑
|
||||
- 返回视频项目
|
||||
|
||||
导出成功后:
|
||||
|
||||
- 项目状态标记为已完成。
|
||||
- 成片自动进入项目资产。
|
||||
|
||||
后续如果接入平台发布能力,再增加发布入口;第一版不做独立发布页。
|
||||
|
||||
---
|
||||
|
||||
## 6. Stage 4 视频页
|
||||
|
||||
### 页面定位
|
||||
|
||||
视频页只用于 AI 生成视频片段。
|
||||
用户上传视频、使用自己的视频、与 AI 视频混合拼接,都放到 Stage 5 拼接导出页处理。
|
||||
|
||||
### 核心原则
|
||||
|
||||
- 视频生成以片段为单位。
|
||||
- 每段视频独立生成。
|
||||
- 单个视频生成片段上限是 15s。
|
||||
- 长视频由多个 15s 内的视频片段组成,后续在 Stage 5 拼接。
|
||||
- 视频一次只生成 1 条候选。
|
||||
- 首次进入视频页后,AI 可以自动生成视频,不需要用户手动点击生成。
|
||||
- 视频有自己的生成提示词。
|
||||
- 用户可以直接修改视频提示词。
|
||||
- 用户可以不改提示词直接重跑。
|
||||
- 每次重跑保留历史版本。
|
||||
- 用户可以从历史版本中回选满意的一版。
|
||||
|
||||
### 视频来源
|
||||
|
||||
Stage 4 只展示和管理 AI 生成视频片段。
|
||||
|
||||
每个 AI 片段的视频来源是:
|
||||
|
||||
- 当前采用的 AI 生成版本
|
||||
- AI 历史版本中的某一版
|
||||
|
||||
### 故事板使用
|
||||
|
||||
如果当前片段有故事板,默认使用故事板生成视频。
|
||||
|
||||
原因:
|
||||
|
||||
- 对小白用户来说,“使用 / 不使用故事板”解释成本高。
|
||||
- 用户既然生成了故事板,视频结果和故事板不一致时容易产生困惑。
|
||||
|
||||
Stage 4 可以允许用户修改是否使用故事板,但默认不主动要求用户选择。
|
||||
|
||||
### 页面结构初稿
|
||||
|
||||
左侧:
|
||||
|
||||
- 片段列表
|
||||
- 每段显示时间范围、来源、生成状态、当前采用版本
|
||||
|
||||
中间:
|
||||
|
||||
- 当前片段视频预览 / 生成状态
|
||||
- 视频播放器
|
||||
- 当前采用版本标识
|
||||
|
||||
右侧:
|
||||
|
||||
- 视频提示词编辑器
|
||||
- 故事板使用状态 / 可修改
|
||||
- 绑定输入资产引用
|
||||
- 视频历史版本
|
||||
|
||||
底部:
|
||||
|
||||
- 重新生成
|
||||
- 采用当前版本
|
||||
- 进入拼接导出
|
||||
|
||||
### 待继续确认
|
||||
|
||||
- 视频历史版本如何展示。
|
||||
@@ -0,0 +1,94 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url));
|
||||
const frontendRoot = join(root, "..");
|
||||
const exactRoot = join(frontendRoot, "public", "exact");
|
||||
const outFile = join(frontendRoot, "src", "routes", "exact-html.ts");
|
||||
const componentRoot = join(frontendRoot, "src", "routes", "exact-pages");
|
||||
|
||||
const pages = [
|
||||
["account", "account.html"],
|
||||
["assetFactory", "asset-factory.html"],
|
||||
["dashboard", "index.html"],
|
||||
["imageOptimize", "image-optimize.html"],
|
||||
["library", "library.html"],
|
||||
["login", "login.html"],
|
||||
["messages", "messages.html"],
|
||||
["modelPhoto", "model-photo.html"],
|
||||
["modelPhotoDemoA", "model-photo-demo-a.html"],
|
||||
["modelPhotoDemoB", "model-photo-demo-b.html"],
|
||||
["pipeline", "pipeline.html"],
|
||||
["platformCover", "platform-cover.html"],
|
||||
["productCreate", "product-create.html"],
|
||||
["productCreateUpload", "product-create-upload.html"],
|
||||
["productDetail", "product-detail.html"],
|
||||
["products", "products.html"],
|
||||
["projectWizard", "projects-new.html"],
|
||||
["projects", "projects.html"],
|
||||
["register", "register.html"],
|
||||
["settings", "settings.html"],
|
||||
["team", "team.html"]
|
||||
];
|
||||
|
||||
function normalizeDocument(html, fileName) {
|
||||
const withMeta = html.replace(
|
||||
/<head>/i,
|
||||
`<head>\n<meta name="x-airshelf-exact-source" content="${basename(fileName)}">`
|
||||
);
|
||||
return withMeta
|
||||
.replace(/(src|href)="assets\//g, '$1="/exact/assets/')
|
||||
.replace(/url\((['"]?)assets\//g, "url($1/exact/assets/")
|
||||
.replace(
|
||||
"history.replaceState(null, '', '#' + id);",
|
||||
"try { window.location.hash = id; } catch (e) {}"
|
||||
);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outFile), { recursive: true });
|
||||
mkdirSync(componentRoot, { recursive: true });
|
||||
|
||||
const entries = pages
|
||||
.map(([key, fileName]) => {
|
||||
const html = normalizeDocument(readFileSync(join(exactRoot, fileName), "utf8"), fileName);
|
||||
return ` ${JSON.stringify(key)}: ${JSON.stringify(html)}`;
|
||||
})
|
||||
.join(",\n");
|
||||
|
||||
const keys = pages.map(([key]) => JSON.stringify(key)).join(" | ");
|
||||
|
||||
writeFileSync(
|
||||
outFile,
|
||||
`/* This file is generated by scripts/generate-exact-html.mjs. Do not edit by hand. */\n` +
|
||||
`export type ExactHtmlKey = ${keys};\n\n` +
|
||||
`export const exactHtmlDocuments: Record<ExactHtmlKey, string> = {\n${entries}\n};\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
function toPascalCase(value) {
|
||||
return value
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.split(/[-_]/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
|
||||
for (const [key] of pages) {
|
||||
const componentName = `Exact${toPascalCase(key)}Page`;
|
||||
const fileName = `${key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()}.tsx`;
|
||||
writeFileSync(
|
||||
join(componentRoot, fileName),
|
||||
`/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */\n` +
|
||||
`import { ExactDocumentPage } from "../exact-document";\n` +
|
||||
`import type { ExactDocumentPageProps } from "../exact-document";\n\n` +
|
||||
`export function ${componentName}(props: Omit<ExactDocumentPageProps, "pageKey">) {\n` +
|
||||
` return <ExactDocumentPage {...props} pageKey=${JSON.stringify(key)} />;\n` +
|
||||
`}\n\n` +
|
||||
`export default ${componentName};\n`,
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`generated ${pages.length} exact documents -> ${outFile}`);
|
||||
@@ -0,0 +1,422 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Bell, CircleDollarSign } from "lucide-react";
|
||||
import { api, getToken, setToken } from "./api";
|
||||
import type {
|
||||
AITask,
|
||||
Asset,
|
||||
BillingSummary,
|
||||
Ledger,
|
||||
ModelConfig,
|
||||
Product,
|
||||
Project,
|
||||
Team,
|
||||
TeamMember,
|
||||
User
|
||||
} from "./types";
|
||||
import { Decorations, Sidebar, ToastLike } from "./components/app-shell";
|
||||
import {
|
||||
AccountPage,
|
||||
AssetFactoryPage,
|
||||
AuthScreen,
|
||||
Dashboard,
|
||||
ImageWorkbenchPage,
|
||||
LibraryPage,
|
||||
MessagesPage,
|
||||
ModelPhotoDemoPage,
|
||||
PipelinePage,
|
||||
ProductCreateUploadPage,
|
||||
ProductDetailPage,
|
||||
ProductsPage,
|
||||
ProjectWizardPage,
|
||||
ProjectsPage,
|
||||
SettingsPage,
|
||||
TeamPage
|
||||
} from "./routes";
|
||||
import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config";
|
||||
import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
|
||||
import { money } from "./routes/stage-config";
|
||||
|
||||
export function App() {
|
||||
const [route, setRoute] = useState<ResolvedRoute>(() => resolveRoute());
|
||||
const page = route.page;
|
||||
const [authMode, setAuthMode] = useState<AuthMode>(route.authMode);
|
||||
const [authed, setAuthed] = useState<boolean>(() => Boolean(getToken()));
|
||||
const [booting, setBooting] = useState<boolean>(() => Boolean(getToken()));
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [team, setTeam] = useState<Team | null>(null);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
|
||||
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
|
||||
const [billing, setBilling] = useState<BillingSummary | null>(null);
|
||||
const [ledgers, setLedgers] = useState<Ledger[]>([]);
|
||||
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
|
||||
|
||||
const [activeProductId, setActiveProductId] = useState(route.productId || "");
|
||||
const [activeProjectId, setActiveProjectId] = useState(route.projectId || "");
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const activeProject = useMemo(
|
||||
() => projects.find((project) => project.id === activeProjectId) || projects[0],
|
||||
[projects, activeProjectId]
|
||||
);
|
||||
const activeProduct = useMemo(
|
||||
() => products.find((product) => product.id === activeProductId) || products[0],
|
||||
[products, activeProductId]
|
||||
);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const [productData, projectData, assetData, billingData, ledgerData, memberData, modelData, taskData] =
|
||||
await Promise.all([
|
||||
api.products(),
|
||||
api.projects(),
|
||||
api.assets(),
|
||||
api.billingSummary().catch(() => null),
|
||||
api.ledgers().catch(() => []),
|
||||
api.teamMembers().catch(() => []),
|
||||
api.modelConfigs().catch(() => null),
|
||||
api.aiTasks().catch(() => null)
|
||||
]);
|
||||
setProducts(productData.results);
|
||||
setProjects(projectData.results);
|
||||
setAssets(assetData.results);
|
||||
setTeamMembers(memberData);
|
||||
setModelConfigs(modelData?.results || []);
|
||||
setAiTasks(taskData?.results || []);
|
||||
if (billingData) setBilling(billingData);
|
||||
setLedgers(ledgerData);
|
||||
setActiveProjectId((current) => current || projectData.results[0]?.id || "");
|
||||
setActiveProductId((current) => current || productData.results[0]?.id || "");
|
||||
}, []);
|
||||
|
||||
// Boot: validate token, hydrate identity + data.
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
setBooting(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const identity = await api.me();
|
||||
if (cancelled) return;
|
||||
setUser(identity.user);
|
||||
setTeam(identity.team);
|
||||
await loadData();
|
||||
} catch {
|
||||
setToken(null);
|
||||
if (!cancelled) setAuthed(false);
|
||||
} finally {
|
||||
if (!cancelled) setBooting(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadData]);
|
||||
|
||||
// Keep route in sync with browser navigation.
|
||||
useEffect(() => {
|
||||
function syncRouteFromHistory() {
|
||||
const next = resolveRoute();
|
||||
setRoute(next);
|
||||
setAuthMode(next.authMode);
|
||||
if (next.productId !== undefined) setActiveProductId(next.productId);
|
||||
if (next.projectId !== undefined) setActiveProjectId(next.projectId);
|
||||
}
|
||||
window.addEventListener("popstate", syncRouteFromHistory);
|
||||
return () => window.removeEventListener("popstate", syncRouteFromHistory);
|
||||
}, []);
|
||||
|
||||
// Load full project detail when entering the pipeline.
|
||||
useEffect(() => {
|
||||
if (!authed || page !== "pipeline" || !activeProjectId) {
|
||||
if (page !== "pipeline") setProjectDetail(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api
|
||||
.project(activeProjectId)
|
||||
.then((detail) => {
|
||||
if (!cancelled) setProjectDetail(detail);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authed, page, activeProjectId]);
|
||||
|
||||
function navigate(next: Page, options: NavigateOptions = {}) {
|
||||
const productId = options.productId ?? activeProductId;
|
||||
const projectId = options.projectId ?? activeProjectId;
|
||||
if (options.productId !== undefined) setActiveProductId(options.productId);
|
||||
if (options.projectId !== undefined) setActiveProjectId(options.projectId);
|
||||
const hash = options.hash?.replace(/^#/, "");
|
||||
setRoute({ page: next, authMode, productId, projectId, hash });
|
||||
const path = `${pathForPage(next, { productId, projectId })}${hash ? `#${hash}` : ""}`;
|
||||
if (`${window.location.pathname}${window.location.hash}` !== path || window.location.search) {
|
||||
const method = options.replace ? "replaceState" : "pushState";
|
||||
window.history[method](null, "", path);
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}
|
||||
|
||||
async function refreshProjectDetail() {
|
||||
if (!activeProjectId) return;
|
||||
const detail = await api.project(activeProjectId).catch(() => null);
|
||||
if (detail) setProjectDetail(detail);
|
||||
}
|
||||
|
||||
async function action<T>(work: () => Promise<T>, successText: string): Promise<T | null> {
|
||||
setLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await work();
|
||||
setNotice({ type: "success", text: successText });
|
||||
await loadData();
|
||||
await refreshProjectDetail();
|
||||
return result;
|
||||
} catch (error) {
|
||||
setNotice({ type: "error", text: error instanceof Error ? error.message : "操作失败" });
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onAuthed(payload: { token: string; user: User; team: Team }) {
|
||||
setToken(payload.token);
|
||||
setUser(payload.user);
|
||||
setTeam(payload.team);
|
||||
setAuthed(true);
|
||||
setBooting(true);
|
||||
loadData().finally(() => setBooting(false));
|
||||
navigate("dashboard", { replace: true });
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api.logout().catch(() => undefined);
|
||||
setToken(null);
|
||||
setAuthed(false);
|
||||
setUser(null);
|
||||
setTeam(null);
|
||||
setAuthMode("login");
|
||||
window.history.replaceState(null, "", "/login");
|
||||
}
|
||||
|
||||
// ---- Auth gate ----
|
||||
if (!authed) {
|
||||
return (
|
||||
<AuthScreen
|
||||
initialMode={authMode}
|
||||
onModeChange={(next) => {
|
||||
setAuthMode(next);
|
||||
window.history.pushState(null, "", next === "register" ? "/register" : "/login");
|
||||
}}
|
||||
onAuthed={onAuthed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (booting || !user || !team) {
|
||||
return (
|
||||
<div className="app">
|
||||
<main>
|
||||
<div className="content">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>加载中…</h1>
|
||||
<div className="sub">
|
||||
<span className="mono">// 正在拉取团队数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentUser: User = user;
|
||||
const currentTeam: Team = team;
|
||||
|
||||
function renderPage() {
|
||||
switch (page) {
|
||||
case "dashboard":
|
||||
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} userName={currentUser.username} navigate={navigate} />;
|
||||
case "products":
|
||||
return (
|
||||
<ProductsPage
|
||||
products={products}
|
||||
navigate={navigate}
|
||||
openProduct={(productId) => navigate("productDetail", { productId })}
|
||||
onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")}
|
||||
/>
|
||||
);
|
||||
case "productCreateUpload":
|
||||
return (
|
||||
<ProductCreateUploadPage
|
||||
onCreate={async (payload) => {
|
||||
const created = await action(() => api.createProduct(payload), "商品已创建");
|
||||
if (created) navigate("productDetail", { productId: created.id });
|
||||
}}
|
||||
onBack={() => navigate("products")}
|
||||
/>
|
||||
);
|
||||
case "productDetail":
|
||||
if (!activeProduct) return <ProductsPage products={products} navigate={navigate} openProduct={(productId) => navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} />;
|
||||
return (
|
||||
<ProductDetailPage
|
||||
product={activeProduct}
|
||||
projects={projects.filter((project) => project.product === activeProduct.id)}
|
||||
navigate={navigate}
|
||||
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
|
||||
onDelete={async () => {
|
||||
const ok = await action(() => api.deleteProduct(activeProduct.id), "商品已删除");
|
||||
if (ok !== null) navigate("products");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "projects":
|
||||
return (
|
||||
<ProjectsPage
|
||||
products={products}
|
||||
projects={projects}
|
||||
navigate={navigate}
|
||||
onCreate={(payload) => action(() => api.createProject(payload), "项目已创建")}
|
||||
openPipeline={(projectId) => navigate("pipeline", { projectId })}
|
||||
onDelete={(projectId) => action(() => api.deleteProject(projectId), "项目已删除")}
|
||||
/>
|
||||
);
|
||||
case "projectWizard":
|
||||
return (
|
||||
<ProjectWizardPage
|
||||
products={products}
|
||||
onBack={() => navigate("projects")}
|
||||
onCreate={async (payload) => {
|
||||
const created = await action(() => api.createProject(payload), "项目已创建");
|
||||
if (created) navigate("pipeline", { projectId: created.id });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "pipeline": {
|
||||
const project = projectDetail || activeProject;
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>暂无项目</h1>
|
||||
<div className="sub">
|
||||
<span className="mono">// 先创建一个视频项目</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary" type="button" onClick={() => navigate("projectWizard")}>
|
||||
新建视频项目
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PipelinePage
|
||||
project={project}
|
||||
loading={loading}
|
||||
onRefresh={refreshProjectDetail}
|
||||
onGenerateScript={(prompt) => action(() => api.generateScript(project.id, { prompt }), "脚本已生成")}
|
||||
onAdoptScript={(scriptId) => action(() => api.adoptScript(project.id, scriptId), "脚本已采用")}
|
||||
onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(project.id, { kind, prompt }), "基础资产已生成")}
|
||||
onGenerateStoryboard={(prompt) => action(() => api.generateStoryboard(project.id, { prompt }), "故事板已生成")}
|
||||
onSkipStoryboard={() => action(() => api.skipStoryboard(project.id), "已跳过故事板")}
|
||||
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(project.id, { video_segment_id: segmentId, prompt }), "视频片段已提交")}
|
||||
onPollVideo={(segmentId) => action(() => api.pollVideo(project.id, segmentId), "片段状态已刷新")}
|
||||
onSubmitAllVideos={(prompt) =>
|
||||
action(async () => {
|
||||
const targets = project.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status));
|
||||
for (const segment of targets) {
|
||||
await api.submitVideo(project.id, {
|
||||
video_segment_id: segment.id,
|
||||
prompt: `${prompt} 第 ${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds} 秒`
|
||||
});
|
||||
}
|
||||
return targets.length;
|
||||
}, "60s 多段视频任务已提交")
|
||||
}
|
||||
onPollAllVideos={() =>
|
||||
action(async () => {
|
||||
const targets = project.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
|
||||
for (const segment of targets) {
|
||||
await api.pollVideo(project.id, segment.id).catch(() => undefined);
|
||||
}
|
||||
return targets.length;
|
||||
}, "视频片段状态已刷新")
|
||||
}
|
||||
onSubmitExport={() => action(() => api.submitExport(project.id), "导出任务已提交")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "library":
|
||||
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} />;
|
||||
case "account":
|
||||
return <AccountPage billing={billing} ledgers={ledgers} projects={projects} teamMembers={teamMembers} />;
|
||||
case "team":
|
||||
return <TeamPage team={currentTeam} user={currentUser} members={teamMembers} navigate={navigate} />;
|
||||
case "messages":
|
||||
return <MessagesPage navigate={navigate} />;
|
||||
case "assetFactory":
|
||||
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} />;
|
||||
case "imageOptimize":
|
||||
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
|
||||
case "modelPhoto":
|
||||
return <ImageWorkbenchPage mode="model" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
|
||||
case "platformCover":
|
||||
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
|
||||
case "modelPhotoDemoA":
|
||||
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} />;
|
||||
case "modelPhotoDemoB":
|
||||
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} />;
|
||||
case "settings":
|
||||
return <SettingsPage user={currentUser} team={currentTeam} />;
|
||||
case "settingsNotify":
|
||||
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" />;
|
||||
default:
|
||||
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} navigate={navigate} />;
|
||||
}
|
||||
}
|
||||
|
||||
const avatarChar = (user.username || "U").slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar page={page} navigate={navigate} user={user} products={products} />
|
||||
<main>
|
||||
<Decorations />
|
||||
<header className="topbar">
|
||||
<div className="crumbs">
|
||||
<span className="here">{routeLabels[page]}</span>
|
||||
</div>
|
||||
<div className="right">
|
||||
<button className="balance-chip" type="button" onClick={() => navigate("account")}>
|
||||
<CircleDollarSign size={13} />
|
||||
余额 <strong>{money(billing?.account.balance)}</strong>
|
||||
</button>
|
||||
<button className="icon-btn" type="button" onClick={() => navigate("messages")} aria-label="消息">
|
||||
<Bell size={15} />
|
||||
</button>
|
||||
<button className="topbar-avatar" type="button" onDoubleClick={logout} title="双击退出登录">
|
||||
<span>{avatarChar}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="content" id="page-content">
|
||||
{notice && <ToastLike notice={notice} />}
|
||||
{renderPage()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type {
|
||||
AITask,
|
||||
Asset,
|
||||
AuthPayload,
|
||||
BillingSummary,
|
||||
Ledger,
|
||||
ModelConfig,
|
||||
Paginated,
|
||||
Product,
|
||||
Project,
|
||||
ScriptVersion,
|
||||
Team,
|
||||
TeamMember,
|
||||
User
|
||||
} from "./types";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const TOKEN_KEY = "airshelf_token";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers = new Headers(options.headers);
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (token) headers.set("Authorization", `Token ${token}`);
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new ApiError(response.status, text || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
register(payload: { username: string; password: string; email?: string; team_name?: string }) {
|
||||
return request<AuthPayload>("/api/auth/register/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
login(payload: { username: string; password: string }) {
|
||||
return request<AuthPayload>("/api/auth/login/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
me() {
|
||||
return request<{ user: User; team: Team }>("/api/auth/me/");
|
||||
},
|
||||
logout() {
|
||||
return request<void>("/api/auth/logout/", { method: "POST" });
|
||||
},
|
||||
teamMembers() {
|
||||
return request<TeamMember[]>("/api/auth/team/members/");
|
||||
},
|
||||
products() {
|
||||
return request<Paginated<Product>>("/api/products/");
|
||||
},
|
||||
product(id: string) {
|
||||
return request<Product>(`/api/products/${id}/`);
|
||||
},
|
||||
createProduct(payload: {
|
||||
title?: string;
|
||||
brand?: string;
|
||||
category?: string;
|
||||
target_audience?: string;
|
||||
description?: string;
|
||||
specs?: Record<string, unknown>;
|
||||
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
|
||||
}) {
|
||||
return request<Product>("/api/products/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
updateProduct(id: string, payload: Partial<Product>) {
|
||||
return request<Product>(`/api/products/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
},
|
||||
deleteProduct(id: string) {
|
||||
return request<void>(`/api/products/${id}/`, { method: "DELETE" });
|
||||
},
|
||||
projects() {
|
||||
return request<Paginated<Project>>("/api/projects/");
|
||||
},
|
||||
project(id: string) {
|
||||
return request<Project>(`/api/projects/${id}/`);
|
||||
},
|
||||
createProject(payload: { name: string; product: string }) {
|
||||
return request<Project>("/api/projects/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
deleteProject(id: string) {
|
||||
return request<void>(`/api/projects/${id}/`, { method: "DELETE" });
|
||||
},
|
||||
generateScript(projectId: string, payload: { prompt: string; selling_point_ids?: string[] }) {
|
||||
return request<ScriptVersion>(`/api/projects/${projectId}/generate-script/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
adoptScript(projectId: string, script_version_id: string) {
|
||||
return request<ScriptVersion>(`/api/projects/${projectId}/adopt-script/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ script_version_id })
|
||||
});
|
||||
},
|
||||
generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string }) {
|
||||
return request(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
|
||||
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
generateStoryboard(projectId: string, payload: { prompt: string }) {
|
||||
return request(`/api/projects/${projectId}/generate-storyboard/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
skipStoryboard(projectId: string) {
|
||||
return request<Project>(`/api/projects/${projectId}/skip-storyboard/`, { method: "POST" });
|
||||
},
|
||||
submitVideo(projectId: string, payload: { video_segment_id: string; prompt: string }) {
|
||||
return request<Project>(`/api/projects/${projectId}/submit-video-segment/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
pollVideo(projectId: string, video_segment_id: string) {
|
||||
return request(`/api/projects/${projectId}/poll-video-segment/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ video_segment_id })
|
||||
});
|
||||
},
|
||||
submitExport(projectId: string) {
|
||||
return request(`/api/projects/${projectId}/submit-export/`, { method: "POST" });
|
||||
},
|
||||
assets() {
|
||||
return request<Paginated<Asset>>("/api/assets/");
|
||||
},
|
||||
uploadAsset(formData: FormData) {
|
||||
return request<Asset>("/api/assets/upload/", { method: "POST", body: formData });
|
||||
},
|
||||
billingSummary() {
|
||||
return request<BillingSummary>("/api/billing/summary/");
|
||||
},
|
||||
ledgers() {
|
||||
return request<Ledger[]>("/api/billing/ledgers/");
|
||||
},
|
||||
modelConfigs() {
|
||||
return request<Paginated<ModelConfig>>("/api/ai/models/");
|
||||
},
|
||||
aiTasks() {
|
||||
return request<Paginated<AITask>>("/api/ai/tasks/");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
|
||||
const iconPaths: Record<string, string> = {
|
||||
layoutDashboard: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
|
||||
package: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>',
|
||||
clapperboard: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
|
||||
sparkles: '<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3Z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15Z"/>',
|
||||
images: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
|
||||
folder: '<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2h7.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5Z"/><path d="M3 9h18"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
creditCard: '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/>',
|
||||
settings: '<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.2a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.2A1.7 1.7 0 0 0 4.7 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1.1-1.5V3a2 2 0 1 1 4 0v.2a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1h.1a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
|
||||
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
|
||||
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/>',
|
||||
chevronLeft: '<path d="m15 18-6-6 6-6"/>',
|
||||
chevronRight: '<path d="m9 18 6-6-6-6"/>',
|
||||
productPlus: '<path d="M12 22V12"/><path d="M16 17h6"/><path d="M19 14v6"/><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="m7.5 4.3 9 5.1"/>',
|
||||
arrowUp: '<path d="M12 19V5"/><path d="m5 12 7-7 7 7"/>',
|
||||
helpCircle: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4"/><path d="M12 17h.01"/>'
|
||||
};
|
||||
|
||||
const iconAliases: Record<string, string> = {
|
||||
dashboard: "layoutDashboard",
|
||||
library: "folder"
|
||||
};
|
||||
|
||||
export function IconKitSvg({ name, size = 16, strokeWidth = 1.5 }: { name: string; size?: number; strokeWidth?: number }) {
|
||||
const key = iconAliases[name] || name;
|
||||
return (
|
||||
<svg
|
||||
className="ui-icon"
|
||||
data-icon={key}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
dangerouslySetInnerHTML={{ __html: iconPaths[key] || iconPaths.helpCircle }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Check, Search } from "lucide-react";
|
||||
import type { Product, User } from "../types";
|
||||
import type { NavItem, Notice, Page } from "../routes/route-config";
|
||||
import { mainNav, parentPage, supplementNav } from "../routes/route-config";
|
||||
|
||||
type Navigate = (page: Page) => void;
|
||||
|
||||
export function Sidebar({ page, navigate, user, products }: { page: Page; navigate: Navigate; user: User; products: Product[] }) {
|
||||
const activePage = parentPage(page);
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="flame">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 2c1 3 4 5 4 9a4 4 0 0 1-4 4 4 4 0 0 1-4-4 5 5 0 0 1 1.5-3.5C10.5 6 11.5 4 12 2zm-1 13c0 2 1 3 1 5 1-1 3-2 3-5 0-1.5-1-2-2-3-1 1-2 2-2 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div><div className="name">流·Studio</div></div>
|
||||
</div>
|
||||
<div className="search-box">
|
||||
<Search size={14} />
|
||||
<input id="global-search" placeholder="搜索" />
|
||||
<span className="kbd">⌘K</span>
|
||||
</div>
|
||||
<div className="nav-section">主要</div>
|
||||
<nav>
|
||||
{mainNav.map((item) => (
|
||||
<NavButton key={item.page} item={{ ...item, badge: item.page === "products" ? String(products.length) : item.badge }} active={activePage === item.page} navigate={navigate} />
|
||||
))}
|
||||
</nav>
|
||||
<div className="nav-section">补充页面</div>
|
||||
<nav>
|
||||
{supplementNav.map((item) => <NavButton key={item.page} item={item} active={activePage === item.page} navigate={navigate} />)}
|
||||
</nav>
|
||||
<div className="aside-foot">
|
||||
<button className="user" type="button">
|
||||
<div className="av">{user.username.slice(0, 1).toUpperCase()}</div>
|
||||
<div className="em">{user.username}</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavButton({ item, active, navigate }: { item: NavItem; active: boolean; navigate: Navigate }) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button className={active ? "active" : ""} type="button" onClick={() => navigate(item.page)}>
|
||||
<Icon size={14} />
|
||||
<span>{item.label}</span>
|
||||
{item.badge && <span className="pill-mini">{item.badge}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Decorations() {
|
||||
// Exact 设计稿只用 grid-bg 做底纹;旧版散点/角标是绝对定位写死坐标,会压在
|
||||
// topbar 和 page-head 上(重叠 bug),且不在设计稿里。品牌 mono 签名由各页面 inline
|
||||
// 的 // 注释、[ ALL ] 等承载,这里只保留底纹。
|
||||
return <div className="grid-bg" />;
|
||||
}
|
||||
|
||||
export function ToastLike({ notice }: { notice: NonNullable<Notice> }) {
|
||||
return (
|
||||
<div className={`inline-toast ${notice.type}`}>
|
||||
<div className="ic-t"><Check size={12} /></div>
|
||||
<div className="txt">{notice.text}<span className="mono">[ {notice.type.toUpperCase()} ]</span></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CornerMark({ pos }: { pos: "tl" | "tr" | "bl" | "br" }) {
|
||||
return (
|
||||
<span className={`corner-mark ${pos}`}>
|
||||
<svg viewBox="0 0 22 21" fill="none">
|
||||
<path d="M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z" fill="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Shield, X } from "lucide-react";
|
||||
|
||||
export function SettingRow({ title, desc, action, toggle, checked }: { title: string; desc: string; action?: string; toggle?: boolean; checked?: boolean }) {
|
||||
return (
|
||||
<div className="setting-row">
|
||||
<div><strong>{title}</strong><span>{desc}</span></div>
|
||||
{toggle ? <label className="switch"><input type="checkbox" defaultChecked={checked} /><span className="slider" /></label> : <button className="btn btn-sm" type="button">{action}</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamModal({ open, title, subtitle, icon, close, children, footer }: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: ReactNode;
|
||||
close: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal-bg show" onClick={close}>
|
||||
<div className="modal invite-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h"><div className="ic-m">{icon}</div><div className="ti">{title}<span>{subtitle}</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
||||
<div className="modal-b">{children}</div>
|
||||
<div className="modal-f"><button className="btn" type="button" onClick={close}>取消</button>{footer || <button className="btn btn-primary" type="button" onClick={close}>保存</button>}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmModal({ open, title, detail, confirmText, onCancel, onConfirm }: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
detail: string;
|
||||
confirmText: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void | Promise<unknown>;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal-bg show" onClick={onCancel}>
|
||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h"><div className="ic-m"><Shield size={16} /></div><div className="ti">{title}<span>// CONFIRM</span></div></div>
|
||||
<div className="modal-b">{detail}</div>
|
||||
<div className="modal-f"><button className="btn" type="button" onClick={onCancel}>取消</button><button className="btn btn-primary" type="button" onClick={() => void onConfirm()}>{confirmText}</button></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-bg show" onClick={close} />
|
||||
<aside className="drawer show">
|
||||
<div className="drawer-h"><h3>{title}</h3><button className="x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
||||
<div className="drawer-b">{children}</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyPanel({ title, action, onAction }: { title: string; action: string; onAction: () => void }) {
|
||||
return <div className="empty-state show"><h3>{title}</h3><p>// 先创建资料后进入下一步</p><button className="btn btn-primary" type="button" onClick={onAction}>{action}</button></div>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { stageMeta, stageOrder } from "../routes/stage-config";
|
||||
|
||||
export function Progress({ status }: { status: string }) {
|
||||
const current = Math.max(stageOrder.indexOf(status as never), 0);
|
||||
return (
|
||||
<div className="prog">
|
||||
{stageOrder.map((stage, index) => <span key={stage} className={index < current ? "done" : index === current ? "cur" : ""} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FragmentStageStep({ stage, done, active, lineDone, isLast, onClick }: {
|
||||
stage: string;
|
||||
done: boolean;
|
||||
active: boolean;
|
||||
lineDone: boolean;
|
||||
isLast: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const meta = stageMeta[stage];
|
||||
return (
|
||||
<>
|
||||
<button className={`stage-step ${done ? "done" : ""} ${active ? "active" : ""}`} type="button" onClick={onClick}>
|
||||
<div className="num">{done ? "✓" : meta.no}</div><div className="lbl">{meta.label}</div><div className="st">{done ? "已确认" : active ? "进行中" : "未开始"}</div>
|
||||
</button>
|
||||
{!isLast && <div className={`stage-line ${lineDone ? "done" : ""}`} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/* Exact page-level CSS copied from 电商AI平台/index.html. */
|
||||
.exact-document-route {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: var(--background-base);
|
||||
}
|
||||
.exact-document-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
border: 0;
|
||||
background: var(--background-base);
|
||||
}
|
||||
.app .brand { gap: 0; padding: 0; }
|
||||
.app .search-box .kbd { background: transparent; border: 0; border-radius: 0; padding: 0; }
|
||||
.dash-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 24px; align-items: start; }
|
||||
.recent-row { display: grid; grid-template-columns: 54px 1fr 110px 130px 60px; align-items: center; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border-faint); cursor: pointer; width: auto; text-align: inherit; background: transparent; }
|
||||
.recent-row .prog, .recent-row .pill, .recent-row .btn { justify-self: start; }
|
||||
.recent-row:last-child { border-bottom: 0; }
|
||||
.recent-row:hover { background: var(--background-lighter); }
|
||||
.recent-row .thumb { width: 54px; height: 70px; border-radius: var(--r-md); }
|
||||
.recent-meta .name { font-weight: 600; font-size: 13.5px; color: var(--accent-black); }
|
||||
.recent-meta .sub { font-size: 12px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .01em; }
|
||||
.shortcuts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.shortcut { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 16px; display: flex; align-items: flex-start; gap: 12px; cursor: pointer; transition: background var(--t-base); }
|
||||
.shortcut:hover { background: var(--black-alpha-4); }
|
||||
.shortcut .ic { width: 32px; height: 32px; background: var(--heat-12); color: var(--heat); display: grid; place-items: center; border: 0; border-radius: var(--r-md); flex-shrink: 0; }
|
||||
.shortcut .ic svg { width: 16px; height: 16px; }
|
||||
.shortcut .t { font-size: 13px; font-weight: 600; }
|
||||
.shortcut .d { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .01em; }
|
||||
.tip { background: var(--surface); border: 1px dashed var(--border-faint); padding: 14px 16px; font-size: 12.5px; color: var(--black-alpha-56); line-height: 1.6; border-radius: var(--r-md); }
|
||||
.tip strong { color: var(--accent-black); font-weight: 600; display: block; margin-bottom: 4px; }
|
||||
.tip .mono { font-family: var(--font-mono); color: var(--heat); background: var(--heat-12); padding: 1px 5px; border-radius: var(--r-sm); font-size: 11.5px; }
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
import "./design-restraint.css";
|
||||
import "./exact-pages.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import type { BillingSummary, Ledger, Project, TeamMember } from "../types";
|
||||
import { money, stageMeta, statusPill } from "./stage-config";
|
||||
|
||||
export function AccountPage({ billing, ledgers, projects, teamMembers }: {
|
||||
billing: BillingSummary | null;
|
||||
ledgers: Ledger[];
|
||||
projects: Project[];
|
||||
teamMembers: TeamMember[];
|
||||
}) {
|
||||
const [tab, setTab] = useState<"overview" | "projects" | "members" | "bills">("overview");
|
||||
const [amount, setAmount] = useState(500);
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>消费</h1><div className="sub"><span className="mono">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div></div></div>
|
||||
<div className="top-grid"><div className="balance-banner"><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="balance-hero"><div className="lbl">团队余额</div><div className="v">{money(billing?.account.balance)}</div><div className="meta">// 冻结 {money(billing?.account.reserved_balance)} · 已消费 {money(billing?.charged_total)}</div></div></div><div className="pane topup-pane"><div className="topup-head"><div><h3>快速充值</h3><div className="desc">// 充值后立刻到账,可开发票</div></div><div className="topup-selected">已选 ¥{amount}</div></div><div className="recharge-row">{[100, 500, 1000, 3000].map((item) => <button className={`recharge-card ${amount === item ? "selected" : ""}`} type="button" key={item} onClick={() => setAmount(item)}><div className="amt">¥{item}</div><div className="gift">+ 赠送</div></button>)}</div><button className="btn pay-method-btn" type="button"><CreditCard size={13} />微信支付</button></div></div>
|
||||
<div className="billing-tabs">{[["overview", "总览"], ["projects", "项目"], ["members", "成员"], ["bills", "账单流水"]].map(([key, label]) => <button className={`tab ${tab === key ? "active" : ""}`} type="button" key={key} onClick={() => setTab(key as typeof tab)}>{label}</button>)}</div>
|
||||
{tab === "overview" && <div className="overview-grid"><div className="pane trend-pane"><h3>消费趋势</h3><div className="bars">{Array.from({ length: 14 }).map((_, index) => <span key={index} style={{ height: `${18 + (index % 6) * 10}%` }} />)}</div></div><div className="pane stage-pane"><h3>本月按阶段分布</h3><UsageLine label="视频片段" value="¥98.40" width="60%" color="var(--orange)" /><UsageLine label="故事板" value="¥36.00" width="22%" color="var(--green)" /></div></div>}
|
||||
{tab === "projects" && <table className="billing-table"><thead><tr><th>项目</th><th>当前阶段</th><th>状态</th><th>消耗</th></tr></thead><tbody>{projects.map((project) => <tr key={project.id}><td>{project.name}</td><td>{stageMeta[project.current_stage]?.label || project.current_stage}</td><td><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{project.status}</span></td><td>¥0.00</td></tr>)}</tbody></table>}
|
||||
{tab === "members" && <table className="billing-table"><thead><tr><th>成员</th><th>角色</th><th>额度</th><th>状态</th></tr></thead><tbody>{teamMembers.map((member) => <tr key={member.id}><td>{member.user.username}</td><td>{member.role}</td><td>{money(member.monthly_credit_limit)}</td><td><span className={`pill ${statusPill(member.status)}`}><span className="dot" />{member.status}</span></td></tr>)}</tbody></table>}
|
||||
{tab === "bills" && <table className="billing-table bills"><thead><tr><th>时间</th><th>类型</th><th>详情</th><th>金额</th></tr></thead><tbody>{ledgers.map((item) => <tr key={item.id}><td>{new Date(item.created_at).toLocaleString("zh-CN")}</td><td>{item.ledger_type}</td><td>{item.reason}</td><td>{item.amount}</td></tr>)}</tbody></table>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsageLine({ label, value, width, color }: { label: string; value: string; width: string; color: string }) {
|
||||
return <><div className="usage-line"><span>{label}</span><span className="v">{value}</span></div><div className="usage-bar"><span style={{ width, background: color }} /></div></>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, Check, Grid2X2, List, RefreshCw, Search, WandSparkles } from "lucide-react";
|
||||
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { statusPill } from "./stage-config";
|
||||
|
||||
export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) {
|
||||
const cards = [
|
||||
{ page: "modelPhoto" as Page, tag: "[ MODEL · TRY-ON ]", title: "模特上身图", desc: "选择模特,AI 生成商品模特上身效果图", cost: "≈ ¥0.30 / 张" },
|
||||
{ page: "platformCover" as Page, tag: "[ PLATFORM · KIT ]", title: "平台套图", desc: "选择平台模板,AI 生成电商平台套图", cost: "≈ ¥0.50 / 张" },
|
||||
{ page: "imageOptimize" as Page, tag: "[ IMAGE · STUDIO ]", title: "图片创作", desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写", cost: "≈ ¥0.40 / 组" }
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>图片生成</h1><div className="sub"><span className="mono">// 一键生成</span><span>·</span><span>电商视觉素材,提升内容制作效率</span></div></div></div>
|
||||
<div className="factory-hero">{cards.map((card) => <article className="factory-card with-corners" key={card.title}><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="factory-body"><div className="factory-text"><span className="factory-tag">{card.tag}</span><div className="factory-title">{card.title}</div><div className="factory-desc">{card.desc}</div><div className="factory-cta"><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate(card.page)}>开始生成<ArrowRight size={13} /></button><span className="cost">[ {card.cost} ]</span></div></div><div className="factory-visual"><div className="placeholder main"><span className="ph-frame">{card.title}</span></div></div></div></article>)}</div>
|
||||
<div className="section-h"><h2>任务中心</h2><span className="more">// {aiTasks.length} 个真实 AI 任务</span></div>
|
||||
<div className="toolbar"><div className="search-inline"><Search size={14} /><input className="input" placeholder="搜索任务名" /></div><span className="spacer" /><div className="view-toggle"><button type="button" className="active"><List size={13} />列表</button><button type="button"><Grid2X2 size={13} />网格</button></div></div>
|
||||
<table className="t"><thead><tr><th>任务</th><th>类型</th><th>状态</th><th /></tr></thead><tbody>{aiTasks.slice(0, 8).map((task) => <tr key={task.id}><td>{task.id.slice(0, 8)}</td><td>{task.task_type}</td><td><span className={`pill ${statusPill(task.status)}`}><span className="dot" />{task.status}</span></td><td /></tr>)}</tbody></table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageWorkbenchPage({ mode, products, assets, modelConfigs, onBack, navigate }: {
|
||||
mode: "image" | "model" | "cover";
|
||||
products: Product[];
|
||||
assets: Asset[];
|
||||
modelConfigs: ModelConfig[];
|
||||
onBack: () => void;
|
||||
navigate?: (page: Page) => void;
|
||||
}) {
|
||||
const meta = {
|
||||
image: { title: "图片创作", tag: "[ IMAGE · STUDIO ]", desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写。", ratio: "1:1" },
|
||||
model: { title: "模特上身图", tag: "[ MODEL · TRY-ON ]", desc: "选择模特和商品,生成电商模特上身图。", ratio: "3:4" },
|
||||
cover: { title: "平台套图", tag: "[ PLATFORM · KIT ]", desc: "选择平台模板,一键生成主图 / 封面 / 详情套图。", ratio: "4:5" }
|
||||
}[mode];
|
||||
const [productId, setProductId] = useState(products[0]?.id || "");
|
||||
const [prompt, setPrompt] = useState(`${products[0]?.title || "商品"},电商高转化视觉,干净背景,商品主体清晰`);
|
||||
const product = products.find((item) => item.id === productId) || products[0];
|
||||
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
|
||||
|
||||
useEffect(() => {
|
||||
if (product) setPrompt(`${product.title},电商高转化视觉,干净背景,商品主体清晰`);
|
||||
}, [productId]);
|
||||
|
||||
return (
|
||||
<div className="tool-shell">
|
||||
<div className="tool-topbar"><button className="back-pill" type="button" onClick={onBack}><ArrowLeft size={14} />返回</button><div><h1>{meta.title}</h1><div className="sub"><span className="mono">{meta.tag}</span> {meta.desc}</div></div><span className="spacer" />{mode === "model" && navigate && <button className="btn" type="button" onClick={() => navigate("modelPhotoDemoA")}>方案 A</button>}<button className="btn btn-primary" type="button"><WandSparkles size={13} />生成图片</button></div>
|
||||
<div className="tool-layout"><aside className="tool-rail"><div className="rail-h">商品空间</div>{products.slice(0, 8).map((item) => <button className={`rail-product ${productId === item.id ? "active" : ""}`} type="button" key={item.id} onClick={() => setProductId(item.id)}><div className="placeholder"><span className="ph-frame">{item.title.slice(0, 4)}</span></div><span>{item.title}</span></button>)}</aside><section className="tool-canvas"><div className="tool-toolbar"><button className="chip active">全部</button><span className="spacer" /><span className="mono muted-2">{imageModels[0]?.display_name || "Volcano Image"}</span></div><div className="result-board">{Array.from({ length: 4 }).map((_, index) => <button className="result-tile" type="button" key={index}><div className="placeholder"><span className="ph-frame">{meta.ratio} · v{index + 1}</span></div><div className="result-meta-row"><span>候选 {index + 1}</span><span className="pill info"><span className="dot" />待采用</span></div></button>)}</div><div className="candidate-actions"><button className="btn" type="button"><RefreshCw size={13} />重跑</button><button className="btn btn-primary" type="button"><Check size={13} />采用并入资产库</button></div></section><aside className="tool-side"><div className="pane"><h3>生成参数</h3><textarea className="textarea" value={prompt} onChange={(event) => setPrompt(event.target.value)} /></div><div className="pane"><h3>资产库</h3><div className="asset-mini-grid">{assets.slice(0, 6).map((asset) => <div className="placeholder" key={asset.id}><span className="ph-frame">{asset.asset_type}</span></div>)}</div></div></aside></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A" | "B"; products: Product[]; onBack: () => void }) {
|
||||
return <><div className="page-head"><div><h1>模特图方案 {variant}</h1><div className="sub"><span className="mono">// model-photo-demo-{variant.toLowerCase()}</span> · 原型对比页补齐</div></div><div className="actions"><button className="btn" type="button" onClick={onBack}><ArrowLeft size={13} />返回模特图</button></div></div><div className={`demo-layout demo-${variant.toLowerCase()}`}><aside className="tool-rail"><div className="rail-h">商品空间</div>{products.slice(0, 6).map((product) => <button className="rail-product" type="button" key={product.id}><div className="placeholder"><span className="ph-frame">{product.title.slice(0, 4)}</span></div><span>{product.title}</span></button>)}</aside><section className="pane"><div className="pane-h"><strong>方案 {variant}</strong><span className="spacer" /><span className="mono muted-2">DEMO</span></div><div className="result-board dense">{Array.from({ length: 6 }).map((_, index) => <div className="result-tile" key={index}><div className="placeholder"><span className="ph-frame">MODEL {index + 1}</span></div></div>)}</div></section></div></>;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { api } from "../api";
|
||||
import type { Team, User } from "../types";
|
||||
import type { AuthMode } from "./route-config";
|
||||
|
||||
export function AuthScreen({
|
||||
initialMode,
|
||||
onModeChange,
|
||||
onAuthed
|
||||
}: {
|
||||
initialMode: AuthMode;
|
||||
onModeChange: (mode: AuthMode) => void;
|
||||
onAuthed: (payload: { token: string; user: User; team: Team }) => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<AuthMode>(initialMode);
|
||||
const [username, setUsername] = useState("li@shop.com");
|
||||
const [password, setPassword] = useState("demo-1234");
|
||||
const [teamName, setTeamName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [registerPassword, setRegisterPassword] = useState("");
|
||||
const [registerPassword2, setRegisterPassword2] = useState("");
|
||||
const [agreed, setAgreed] = useState(true);
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => setMode(initialMode), [initialMode]);
|
||||
useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []);
|
||||
|
||||
function showToast(title: string, sub: string) {
|
||||
setToast({ title, sub });
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||
toastTimer.current = setTimeout(() => setToast(null), 2800);
|
||||
}
|
||||
|
||||
function switchMode(next: AuthMode) {
|
||||
setMode(next);
|
||||
setError("");
|
||||
onModeChange(next);
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (mode === "register") {
|
||||
if (registerPassword.length < 8) throw new Error("密码至少 8 位");
|
||||
if (registerPassword !== registerPassword2) throw new Error("两次密码不一致");
|
||||
if (!agreed) throw new Error("请同意用户协议");
|
||||
}
|
||||
const payload =
|
||||
mode === "login"
|
||||
? await api.login({ username, password })
|
||||
: await api.register({
|
||||
username: email,
|
||||
email,
|
||||
password: registerPassword,
|
||||
team_name: teamName
|
||||
});
|
||||
onAuthed(payload);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "登录失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={`auth-exact-page ${mode === "register" ? "auth-register-page" : "auth-login-page"}`}>
|
||||
<a className="top-back" href={mode === "register" ? "/login" : "/dashboard"} onClick={(event) => { if (mode === "register") { event.preventDefault(); switchMode("login"); } }}>
|
||||
{mode === "register" ? "← 返回登录" : "← 返回工作台"}
|
||||
</a>
|
||||
<div className="auth-wrap">
|
||||
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
|
||||
<aside className="auth-brand">
|
||||
<div className="logo"><img className="logo-img" src="/assets/logo-dark.png" alt="Airshelf" /></div>
|
||||
<div className="tag">// SHORT-VIDEO COMMERCE PLATFORM</div>
|
||||
<div className="hero">
|
||||
{mode === "login" ? <><h1>AI 全流程<br /><span className="h">短剧化</span>带货生成</h1><p>商品 → AI 脚本 → 故事板 → Seedance 视频片段 → 一键导出 9:16 ≤60s 成片。</p></> : <><h1>开通团队,<br />开始 <span className="h">AI 带货</span> 第一条短剧</h1><p>个人 / 企业团队 1-2 小时出成片,失败不扣费,确认后扣。</p></>}
|
||||
</div>
|
||||
<div className="ascii">
|
||||
<div className="ln"><span className="k">// step·1</span> 脚本生成 <span className="v">●●●●●</span></div>
|
||||
<div className="ln"><span className="k">// step·2</span> 基础资产 <span className="v">●●●●○</span></div>
|
||||
<div className="ln"><span className="k">// step·3</span> 故事板 <span className="v">●●●○○</span></div>
|
||||
<div className="ln"><span className="k">// step·4</span> 视频片段 <span className="v">●●○○○</span></div>
|
||||
<div className="ln"><span className="k">// step·5</span> 拼接导出 <span className="v">●○○○○</span></div>
|
||||
</div>
|
||||
<div className="foot"><a href="#">关于</a><a href="#">定价</a><a href="#">联系</a><a href="#">隐私</a></div>
|
||||
</aside>
|
||||
<main className="auth-form">
|
||||
<div className="h-row"><h2>{mode === "login" ? "登录" : "注册团队"}</h2><span className="sub">{mode === "login" ? "// /auth/login" : "// /auth/register"}</span></div>
|
||||
<p className="lead">{mode === "login" ? "使用团队邀请邮箱登录,接受邀请后自动加入对应团队。" : "填写团队信息开通账户,默认成为团队超管。"}</p>
|
||||
<form id={mode === "login" ? "login-form" : "register-form"} autoComplete="off" onSubmit={submit}>
|
||||
{mode === "login" ? (
|
||||
<>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="auth-email">邮箱 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" /><path d="m22 6-10 7L2 6" /></svg>
|
||||
<input type="email" id="auth-email" placeholder="name@company.com" value={username} onChange={(event) => setUsername(event.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="auth-pwd">密码 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||
<input type={showPwd ? "text" : "password"} id="auth-pwd" placeholder="••••••••" value={password} onChange={(event) => setPassword(event.target.value)} required />
|
||||
<button type="button" className="toggle-pwd" aria-label="切换密码可见" onClick={() => setShowPwd((value) => !value)}>
|
||||
<svg id="pwd-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z" /><circle cx="12" cy="12" r="3" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<label><input type="checkbox" defaultChecked /> 记住我 7 天</label>
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("已发送重置邮件", "请到 li@shop.com 收件箱查看 · 链接 30 分钟有效"); }}>忘记密码?</a>
|
||||
</div>
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<button className="btn-cta" type="submit" disabled={busy}>
|
||||
{busy ? <span className="busy-copy">// 验证中...</span> : <>登录<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></>}
|
||||
</button>
|
||||
<div className="divider"><span className="line"></span><span className="txt">OR</span><span className="line"></span></div>
|
||||
<div className="sso-row">
|
||||
<button type="button" className="sso-btn" onClick={() => showToast("微信扫码", "请在 60s 内用微信扫一扫完成授权 · 内测中,以邮箱登录为准")}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 4C5 4 2 6.5 2 9.5c0 1.7 1 3.2 2.5 4.2L4 16l2.2-1.1c.7.2 1.5.3 2.3.3-.3-.5-.5-1.1-.5-1.7 0-2.8 2.9-5 6.5-5h.5C14.6 6 12 4 9 4zm-2 3.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zM15 9c-3.3 0-6 2.2-6 5s2.7 5 6 5c.7 0 1.4-.1 2-.3L19 20l-.4-1.7c1.4-.9 2.4-2.3 2.4-3.8 0-2.8-2.7-5-6-5zm-2 2.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1z" /></svg>
|
||||
微信扫码
|
||||
</button>
|
||||
<button type="button" className="sso-btn" onClick={() => showToast("飞书 SSO", "即将打开企业飞书登录授权页 · 内测中,以邮箱登录为准")}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="18" height="18" rx="3" /></svg>
|
||||
飞书 SSO
|
||||
</button>
|
||||
</div>
|
||||
<div className="switch-row">还没账号? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}>注册团队 →</a></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="field"><label className="field-label" htmlFor="reg-team">团队名 <span className="req">*</span></label><div className="field-input-wrap"><input id="reg-team" value={teamName} onChange={(event) => setTeamName(event.target.value)} required /></div></div>
|
||||
<div className="field"><label className="field-label" htmlFor="reg-email">超管邮箱 <span className="req">*</span></label><div className="field-input-wrap"><input type="email" id="reg-email" value={email} onChange={(event) => setEmail(event.target.value)} required /></div></div>
|
||||
<div className="field-row"><div className="field"><label className="field-label" htmlFor="reg-pwd">密码 <span className="req">*</span></label><div className="field-input-wrap"><input type="password" id="reg-pwd" value={registerPassword} onChange={(event) => setRegisterPassword(event.target.value)} required /></div></div><div className="field"><label className="field-label" htmlFor="reg-pwd2">确认密码 <span className="req">*</span></label><div className="field-input-wrap"><input type="password" id="reg-pwd2" value={registerPassword2} onChange={(event) => setRegisterPassword2(event.target.value)} required /></div></div></div>
|
||||
<label className="agree"><input type="checkbox" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} /><span>我已阅读并同意 用户协议 与 隐私政策。</span></label>
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<button className="btn-cta" type="submit" disabled={busy}>{busy ? <span className="busy-copy">// 验证中...</span> : "创建团队 · 开始使用"}</button>
|
||||
<div className="switch-row">已有账号? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}>登录 →</a></div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{toast && <div className="login-toast"><div>{toast.title}</div><div style={{ fontSize: "11.5px", color: "rgba(0,0,0,.56)", fontWeight: 400, letterSpacing: ".02em" }}>// {toast.sub}</div></div>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Film, Home, Library, Package, Plus, Wallet } from "lucide-react";
|
||||
import type { Asset, BillingSummary, Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { money, stageMeta, statusPill } from "./stage-config";
|
||||
import { Progress } from "../components/pipeline-stage";
|
||||
|
||||
export function Dashboard({ products, projects, assets, billing, userName, navigate }: {
|
||||
products: Product[];
|
||||
projects: Project[];
|
||||
assets: Asset[];
|
||||
billing: BillingSummary | null;
|
||||
userName?: string;
|
||||
navigate: (page: Page) => void;
|
||||
}) {
|
||||
const completed = projects.filter((project) => project.status === "completed").length;
|
||||
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
|
||||
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>欢迎回来{userName ? `,${userName}` : ""}</h1><div className="sub"><span className="mono">// {new Date().toLocaleDateString("zh-CN")}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span></div></div><div className="actions"><button className="btn" type="button" onClick={() => navigate("products")}><Plus size={13} />新建商品</button><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate("projectWizard")}><Plus size={13} />新建项目</button></div></div>
|
||||
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projects.length} delta={`↑ 本月 +${Math.max(projects.length, 0)}`} /><KpiStat label="进行中" badge="WIP" value={running} delta="待处理" /><KpiStat label="成片" badge="DONE" value={completed} delta="导出完成" /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl">余额 <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: "38%" }} /></div><div className="sub">已冻结 {money(billing?.account.reserved_balance)}</div></button></div>
|
||||
<div className="dash-grid"><div><div className="section-h"><h2>最近项目</h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projects.length} ] →</button></div><div className="card-hard">{projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI 全生 / 4 镜</div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm">继续</span></button>)}</div></div><div className="dash-side"><div className="section-h"><h2>快捷入口</h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut icon={Package} title="商品库" desc={`${products.length} SKU`} onClick={() => navigate("products")} /><Shortcut icon={Library} title="资产库" desc={`${assets.length} assets`} onClick={() => navigate("library")} /><Shortcut icon={Wallet} title="充值" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut icon={Film} title="所有项目" desc={`${projects.length} 个`} onClick={() => navigate("projects")} /></div></div></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiStat({ label, badge, value, delta }: { label: string; badge: string; value: string | number; delta: string }) {
|
||||
return <div className="stat"><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div><div className="delta up">{delta}</div></div>;
|
||||
}
|
||||
|
||||
export function Shortcut({ icon: Icon, title, desc, onClick }: { icon: typeof Home; title: string; desc: string; onClick: () => void }) {
|
||||
return <button className="shortcut" type="button" onClick={onClick}><div className="ic"><Icon size={15} /></div><div><div className="t">{title}</div><div className="d">{desc}</div></div></button>;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { IconKitSvg } from "../components/IconKitSvg";
|
||||
import { CornerMark } from "../components/app-shell";
|
||||
import type { NavigateFn, Page } from "./route-config";
|
||||
|
||||
export function ExactDashboardApp({ navigate, logout }: { navigate: NavigateFn; logout: () => void }) {
|
||||
const go = (next: Page) => (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
navigate(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-head"><a className="brand" href="/dashboard" onClick={go("dashboard")}><span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span></a></div>
|
||||
<div className="search-box"><IconKitSvg name="search" /><input placeholder="搜索" readOnly /><span className="kbd">Ctrl K</span></div>
|
||||
<div className="nav-section">主要</div>
|
||||
<nav>
|
||||
<a href="/dashboard" className="active" onClick={go("dashboard")}><IconKitSvg name="dashboard" /><span>工作台</span></a>
|
||||
<a href="/products" onClick={go("products")}><IconKitSvg name="package" /><span>商品库</span><span className="pill-mini">7</span></a>
|
||||
<a href="/projects" onClick={go("projects")}><IconKitSvg name="clapperboard" /><span>视频项目</span><span className="pill-mini">8</span></a>
|
||||
<a href="/asset-factory" onClick={go("assetFactory")}><IconKitSvg name="sparkles" /><span>图片生成</span></a>
|
||||
<a href="/library" onClick={go("library")}><IconKitSvg name="library" /><span>资产库</span></a>
|
||||
<a href="/team" onClick={go("team")}><IconKitSvg name="users" /><span>团队</span></a>
|
||||
<a href="/account" onClick={go("account")}><IconKitSvg name="creditCard" /><span>消费</span></a>
|
||||
<a href="/settings" onClick={go("settings")}><IconKitSvg name="settings" /><span>设置</span></a>
|
||||
</nav>
|
||||
<div className="aside-foot"><div className="user"><div className="av">李</div><div className="em">小李的店</div></div></div>
|
||||
</aside>
|
||||
<main>
|
||||
<div className="grid-bg"></div>
|
||||
<header className="topbar">
|
||||
<div className="crumbs"><span className="here">工作台</span></div>
|
||||
<div className="right">
|
||||
<button className="balance-chip" type="button" onClick={() => navigate("account")}><IconKitSvg name="creditCard" />余额 <strong>¥327.40</strong></button>
|
||||
<button className="icon-btn" type="button" onClick={() => navigate("messages")}><IconKitSvg name="bell" /><span className="count-noti">12</span></button>
|
||||
<button className="topbar-avatar" type="button" onDoubleClick={logout}><span>李</span></button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="content" id="page-content">
|
||||
<CornerMark pos="tl" /><CornerMark pos="tr" /><CornerMark pos="bl" /><CornerMark pos="br" />
|
||||
<div className="page-head">
|
||||
<div><h1>欢迎回来,小李</h1><div className="sub"><span className="mono">// 05.14 · 周三</span><span>·</span><span>你有 <b>3 个项目</b> 正在进行中</span></div></div>
|
||||
<div className="actions"><a className="btn btn-create" href="/products/new" onClick={go("productCreateUpload")}><IconKitSvg name="productPlus" />新建商品</a><a className="btn btn-primary btn-lg btn-create" href="/projects/new" onClick={go("projectWizard")}><IconKitSvg name="clapperboard" />新建项目</a></div>
|
||||
</div>
|
||||
<div className="stats with-corners">
|
||||
<span className="corner-tr" /><span className="corner-bl" />
|
||||
<a className="stat" href="/projects" onClick={go("projects")}><div className="lbl">总项目 <span className="badge">ALL</span></div><div className="v">8</div><div className="delta up"><IconKitSvg name="arrowUp" size={14} /> 本月 +3</div></a>
|
||||
<a className="stat" href="/projects" onClick={go("projects")}><div className="lbl">进行中 <span className="badge">WIP</span></div><div className="v">3</div><div className="delta">2 个待审核</div></a>
|
||||
<a className="stat" href="/projects" onClick={go("projects")}><div className="lbl">本月成片 <span className="badge">DONE</span></div><div className="v">3</div><div className="delta up">较上月 +33%</div></a>
|
||||
<a className="stat" href="/account" onClick={go("account")}><div className="lbl">余额 <span className="badge">¥</span></div><div className="v">¥327<small>.40</small></div><div className="bar"><span style={{ width: "33%" }} /></div><div className="sub">已用 ¥162.60 / ¥500</div></a>
|
||||
</div>
|
||||
<div className="dash-grid">
|
||||
<div><div className="section-h"><h2>最近项目</h2><a className="more" href="/projects" onClick={go("projects")}>[ ALL · 8 ]</a></div><div className="card-hard">{["补水面膜 · 痛点种草 · v3", "透真防晒 · 通勤对比", "蓝牙耳机 · 开箱测评", "春日新品 · 立体口红"].map((title) => <ExactRecentRow key={title} title={title} go={go("pipeline")} />)}</div></div>
|
||||
<div className="dash-side"><div className="section-h"><h2>快捷入口</h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts">{[["商品库", "products"], ["资产库", "library"], ["充值", "account"], ["所有项目", "projects"]].map(([label, target]) => <a className="shortcut" href={`/${target}`} onClick={go(target as Page)} key={label}><div className="ic"><IconKitSvg name="package" /></div><div><div className="t">{label}</div><div className="d">快速进入</div></div></a>)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExactRecentRow({ title, go }: { title: string; go: (event: { preventDefault: () => void }) => void }) {
|
||||
return (
|
||||
<a className="recent-row" href="/pipeline" onClick={go}>
|
||||
<div className="placeholder thumb"><span className="ph-frame">9:16</span></div>
|
||||
<div className="recent-meta"><div className="name">{title}</div><div className="sub">AI 全生 / 4 段</div></div>
|
||||
<div className="prog"><span className="done" /><span className="done" /><span className="cur" /><span /><span /></div>
|
||||
<span className="pill info"><span className="dot" />进行中</span>
|
||||
<span className="btn btn-sm">继续</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { exactHtmlDocuments } from "./exact-html";
|
||||
import type { ExactHtmlKey } from "./exact-html";
|
||||
import type { AuthMode, NavigateFn, Page } from "./route-config";
|
||||
|
||||
const fileToPage: Record<string, Page | "login" | "register" | null> = {
|
||||
"account.html": "account",
|
||||
"asset-factory.html": "assetFactory",
|
||||
"image-optimize.html": "imageOptimize",
|
||||
"index.html": "dashboard",
|
||||
"library.html": "library",
|
||||
"login.html": "login",
|
||||
"messages.html": "messages",
|
||||
"model-photo.html": "modelPhoto",
|
||||
"model-photo-demo-a.html": "modelPhotoDemoA",
|
||||
"model-photo-demo-b.html": "modelPhotoDemoB",
|
||||
"pipeline.html": "pipeline",
|
||||
"platform-cover.html": "platformCover",
|
||||
"product-create.html": "productCreateUpload",
|
||||
"product-create-upload.html": "productCreateUpload",
|
||||
"product-detail.html": "productDetail",
|
||||
"products.html": "products",
|
||||
"projects-new.html": "projectWizard",
|
||||
"projects.html": "projects",
|
||||
"register.html": "register",
|
||||
"settings.html": "settings",
|
||||
"team.html": "team"
|
||||
};
|
||||
|
||||
const pageToExactKey: Record<Page, ExactHtmlKey> = {
|
||||
dashboard: "dashboard",
|
||||
products: "products",
|
||||
productDetail: "productDetail",
|
||||
productCreateUpload: "productCreateUpload",
|
||||
projects: "projects",
|
||||
projectWizard: "projectWizard",
|
||||
pipeline: "pipeline",
|
||||
library: "library",
|
||||
account: "account",
|
||||
team: "team",
|
||||
messages: "messages",
|
||||
assetFactory: "assetFactory",
|
||||
imageOptimize: "imageOptimize",
|
||||
modelPhoto: "modelPhoto",
|
||||
modelPhotoDemoA: "modelPhotoDemoA",
|
||||
modelPhotoDemoB: "modelPhotoDemoB",
|
||||
platformCover: "platformCover",
|
||||
settings: "settings",
|
||||
settingsNotify: "settings"
|
||||
};
|
||||
|
||||
const exactKeyToPage: Partial<Record<ExactHtmlKey, Page | "login" | "register">> = {
|
||||
account: "account",
|
||||
assetFactory: "assetFactory",
|
||||
dashboard: "dashboard",
|
||||
imageOptimize: "imageOptimize",
|
||||
library: "library",
|
||||
login: "login",
|
||||
messages: "messages",
|
||||
modelPhoto: "modelPhoto",
|
||||
modelPhotoDemoA: "modelPhotoDemoA",
|
||||
modelPhotoDemoB: "modelPhotoDemoB",
|
||||
pipeline: "pipeline",
|
||||
platformCover: "platformCover",
|
||||
productCreate: "productCreateUpload",
|
||||
productCreateUpload: "productCreateUpload",
|
||||
productDetail: "productDetail",
|
||||
products: "products",
|
||||
projectWizard: "projectWizard",
|
||||
projects: "projects",
|
||||
register: "register",
|
||||
settings: "settings",
|
||||
team: "team"
|
||||
};
|
||||
|
||||
const exactKeyToFile: Record<ExactHtmlKey, string> = {
|
||||
account: "account.html",
|
||||
assetFactory: "asset-factory.html",
|
||||
dashboard: "index.html",
|
||||
imageOptimize: "image-optimize.html",
|
||||
library: "library.html",
|
||||
login: "login.html",
|
||||
messages: "messages.html",
|
||||
modelPhoto: "model-photo.html",
|
||||
modelPhotoDemoA: "model-photo-demo-a.html",
|
||||
modelPhotoDemoB: "model-photo-demo-b.html",
|
||||
pipeline: "pipeline.html",
|
||||
platformCover: "platform-cover.html",
|
||||
productCreate: "product-create.html",
|
||||
productCreateUpload: "product-create-upload.html",
|
||||
productDetail: "product-detail.html",
|
||||
products: "products.html",
|
||||
projectWizard: "projects-new.html",
|
||||
projects: "projects.html",
|
||||
register: "register.html",
|
||||
settings: "settings.html",
|
||||
team: "team.html"
|
||||
};
|
||||
|
||||
const liveHydratePages = new Set<ExactHtmlKey>([
|
||||
"dashboard",
|
||||
"products",
|
||||
"productDetail",
|
||||
"projectWizard",
|
||||
"projects",
|
||||
"pipeline",
|
||||
"library",
|
||||
"account",
|
||||
"settings",
|
||||
"team"
|
||||
]);
|
||||
|
||||
function routeFromHref(rawHref: string | null) {
|
||||
if (!rawHref || rawHref === "#" || rawHref.startsWith("javascript:")) return null;
|
||||
const url = new URL(rawHref, "https://airshelf.local/exact/");
|
||||
const fileName = url.pathname.split("/").filter(Boolean).pop() || "index.html";
|
||||
const page = fileToPage[fileName];
|
||||
if (!page) return null;
|
||||
const params = new URLSearchParams(url.search);
|
||||
return {
|
||||
page,
|
||||
hash: url.hash || "",
|
||||
search: url.search || "",
|
||||
productId: params.get("product_id") || undefined,
|
||||
projectId: params.get("project_id") || undefined
|
||||
};
|
||||
}
|
||||
|
||||
function routeFromInlineAction(action: string | null) {
|
||||
if (!action) return null;
|
||||
const hrefMatch = action.match(/location\.href\s*=\s*['"]([^'"]+)/);
|
||||
if (hrefMatch) return routeFromHref(hrefMatch[1]);
|
||||
const hashMatch = action.match(/location\.hash\s*=\s*['"]([^'"]+)/);
|
||||
if (hashMatch) return { page: null, hash: hashMatch[1], search: "" };
|
||||
return null;
|
||||
}
|
||||
|
||||
function setFrameHeight(frame: HTMLIFrameElement) {
|
||||
frame.style.height = `${Math.max(window.innerHeight, 720)}px`;
|
||||
}
|
||||
|
||||
export type ExactDocumentPageProps = {
|
||||
pageKey: ExactHtmlKey;
|
||||
hash?: string;
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
navigate?: NavigateFn;
|
||||
onAuthModeChange?: (mode: AuthMode) => void;
|
||||
onAuthSubmit?: (mode: AuthMode, event: FormEvent<HTMLFormElement>, form: HTMLFormElement) => void;
|
||||
};
|
||||
|
||||
export function exactKeyForPage(page: Page): ExactHtmlKey {
|
||||
return pageToExactKey[page] || "dashboard";
|
||||
}
|
||||
|
||||
function contextSearch(pageKey: ExactHtmlKey, productId?: string, projectId?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (pageKey === "productDetail" && productId) params.set("product_id", productId);
|
||||
if (pageKey === "pipeline" && projectId) params.set("project_id", projectId);
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
|
||||
export function ExactDocumentPage({
|
||||
pageKey,
|
||||
hash,
|
||||
productId,
|
||||
projectId,
|
||||
navigate,
|
||||
onAuthModeChange,
|
||||
onAuthSubmit
|
||||
}: ExactDocumentPageProps) {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const html = useMemo(() => {
|
||||
const context = {
|
||||
page: exactKeyToFile[pageKey],
|
||||
search: contextSearch(pageKey, productId, projectId),
|
||||
hash: hash ? `#${hash.replace(/^#/, "")}` : "",
|
||||
liveHydrate: liveHydratePages.has(pageKey)
|
||||
};
|
||||
return exactHtmlDocuments[pageKey].replace(
|
||||
"</head>",
|
||||
`<script>window.__AIR_SHELF_EXACT_CONTEXT__=${JSON.stringify(context)};</script></head>`
|
||||
);
|
||||
}, [hash, pageKey, productId, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current;
|
||||
if (!frame) return;
|
||||
const currentFrame: HTMLIFrameElement = frame;
|
||||
|
||||
function onLoad() {
|
||||
const doc = currentFrame.contentDocument;
|
||||
const win = currentFrame.contentWindow;
|
||||
if (!doc || !win) return;
|
||||
|
||||
(win as Window & { __AIR_SHELF_HOST_NAVIGATE__?: (href: string) => void }).__AIR_SHELF_HOST_NAVIGATE__ = (
|
||||
href: string
|
||||
) => {
|
||||
const hostRoute = routeFromHref(href);
|
||||
if (!hostRoute?.page) return;
|
||||
if (hostRoute.page === "login" || hostRoute.page === "register") {
|
||||
onAuthModeChange?.(hostRoute.page);
|
||||
return;
|
||||
}
|
||||
navigate?.(hostRoute.page, {
|
||||
hash: hostRoute.hash || undefined,
|
||||
productId: hostRoute.productId || (hostRoute.page === "productDetail" ? productId : undefined),
|
||||
projectId: hostRoute.projectId || (hostRoute.page === "pipeline" ? projectId : undefined)
|
||||
});
|
||||
};
|
||||
|
||||
const applyFrameHash = (nextHash: string) => {
|
||||
const cleanHash = nextHash.replace(/^#/, "");
|
||||
const stageMatch = cleanHash.match(/^stage-(\d+)$/);
|
||||
const pipelineWindow = win as Window & { activateStage?: (stage: number) => void };
|
||||
if (pageKey === "pipeline" && stageMatch && typeof pipelineWindow.activateStage === "function") {
|
||||
pipelineWindow.activateStage(Number(stageMatch[1]));
|
||||
return;
|
||||
}
|
||||
const settingsWindow = win as Window & { showSection?: (sectionId: string) => void };
|
||||
if (pageKey === "settings" && cleanHash.startsWith("sec-") && typeof settingsWindow.showSection === "function") {
|
||||
settingsWindow.showSection(cleanHash);
|
||||
return;
|
||||
}
|
||||
doc.getElementById(cleanHash)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
if (hash) {
|
||||
setTimeout(() => {
|
||||
applyFrameHash(hash);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const clickHandler = (event: MouseEvent) => {
|
||||
const target = event.target as Element | null;
|
||||
if (!target) return;
|
||||
|
||||
const syncHashOnlyRoute = (nextHash: string) => {
|
||||
const cleanHash = nextHash.replace(/^#/, "");
|
||||
applyFrameHash(cleanHash);
|
||||
if (exactKeyToPage[pageKey]) {
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}#${cleanHash}`;
|
||||
window.history.replaceState(null, "", nextUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const actionNode = target.closest("[onclick]") as HTMLElement | null;
|
||||
const actionRoute = routeFromInlineAction(actionNode?.getAttribute("onclick") || null);
|
||||
if (actionRoute?.hash && actionRoute.page === null) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
syncHashOnlyRoute(actionRoute.hash);
|
||||
return;
|
||||
}
|
||||
if (actionRoute?.page) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
if (actionRoute.page === "login" || actionRoute.page === "register") {
|
||||
onAuthModeChange?.(actionRoute.page);
|
||||
return;
|
||||
}
|
||||
navigate?.(actionRoute.page, {
|
||||
hash: actionRoute.hash || undefined,
|
||||
productId: actionRoute.productId || (actionRoute.page === "productDetail" ? productId || "exact" : undefined),
|
||||
projectId: actionRoute.projectId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = target.closest("a[href]") as HTMLAnchorElement | null;
|
||||
const rawAnchorHref = anchor?.getAttribute("href") || null;
|
||||
if (rawAnchorHref?.startsWith("#")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
syncHashOnlyRoute(rawAnchorHref);
|
||||
return;
|
||||
}
|
||||
const anchorRoute = routeFromHref(rawAnchorHref);
|
||||
if (!anchorRoute) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (anchorRoute.page === "login" || anchorRoute.page === "register") {
|
||||
onAuthModeChange?.(anchorRoute.page);
|
||||
return;
|
||||
}
|
||||
navigate?.(anchorRoute.page, {
|
||||
hash: anchorRoute.hash || undefined,
|
||||
productId: anchorRoute.productId || (anchorRoute.page === "productDetail" ? productId || "exact" : undefined),
|
||||
projectId: anchorRoute.projectId
|
||||
});
|
||||
};
|
||||
|
||||
const submitHandler = (event: SubmitEvent) => {
|
||||
const form = event.target as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
const isLogin = pageKey === "login" && form.id === "login-form";
|
||||
const isRegister = pageKey === "register" && form.id === "register-form";
|
||||
if (!isLogin && !isRegister) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
onAuthSubmit?.(isLogin ? "login" : "register", event as unknown as FormEvent<HTMLFormElement>, form);
|
||||
};
|
||||
|
||||
doc.addEventListener("click", clickHandler, true);
|
||||
doc.addEventListener("submit", submitHandler, true);
|
||||
setFrameHeight(currentFrame);
|
||||
const resizeFrame = () => setFrameHeight(currentFrame);
|
||||
window.addEventListener("resize", resizeFrame);
|
||||
const observer = new ResizeObserver(() => setFrameHeight(currentFrame));
|
||||
observer.observe(doc.documentElement);
|
||||
observer.observe(doc.body);
|
||||
|
||||
const cleanup = () => {
|
||||
doc.removeEventListener("click", clickHandler, true);
|
||||
doc.removeEventListener("submit", submitHandler, true);
|
||||
window.removeEventListener("resize", resizeFrame);
|
||||
observer.disconnect();
|
||||
};
|
||||
currentFrame.dataset.cleanupKey = String(Date.now());
|
||||
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup?.();
|
||||
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup = cleanup;
|
||||
}
|
||||
|
||||
currentFrame.addEventListener("load", onLoad);
|
||||
if (currentFrame.contentDocument?.readyState !== "loading") onLoad();
|
||||
return () => {
|
||||
currentFrame.removeEventListener("load", onLoad);
|
||||
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup?.();
|
||||
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup = undefined;
|
||||
};
|
||||
}, [hash, navigate, onAuthModeChange, onAuthSubmit, pageKey]);
|
||||
|
||||
return (
|
||||
<div className="exact-document-route" data-exact-page={pageKey}>
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
title={`Airshelf ${pageKey}`}
|
||||
className="exact-document-frame"
|
||||
srcDoc={html}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactAccountPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="account" />;
|
||||
}
|
||||
|
||||
export default ExactAccountPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactAssetFactoryPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="assetFactory" />;
|
||||
}
|
||||
|
||||
export default ExactAssetFactoryPage;
|
||||