chore: 全量推送 · 累积页面改动 + Next.js 工程骨架 + v1/ 归档 + 文档
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6s

页面 (电商AI平台/)
- account / team / settings / index / products / projects: 累积迭代
- restraint.css: 设计 token 补充
- login.html / register.html: 新增登录注册页
- _ARCHIVE.md: 归档说明

Next.js 工程骨架
- app/ + components/: 新一代 SPA 雏形 (page / layout / sidebar / topbar / GridBg / Icon)
- package.json / package-lock.json / next.config.mjs / tsconfig.json / postcss.config.mjs / next-env.d.ts

历史归档 / 文档
- v1/: 原 V1 静态稿镜像 (含 mockup-A/B/C)
- PRD.md / deployment-guide.md / _check.html
- ui参考/ / screenshots/

杂项
- .gitignore 调整
- 删除根 README.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
iye 2026-05-21 21:16:46 +08:00
parent 8a783ca36f
commit f420af2069
82 changed files with 15367 additions and 418 deletions

34
.gitignore vendored
View File

@ -1,28 +1,8 @@
# 工程文件 node_modules
node_modules/ .next
.next/ out
.turbo/ .env*.local
dist/
build/
# OS / IDE
.DS_Store .DS_Store
Thumbs.db *.tsbuildinfo
.vscode/ next-env.d.ts.bak
.idea/ _design_src
*.swp
# 日志
*.log
npm-debug.log*
pnpm-debug.log*
# 本地环境
.env
.env.local
.env.*.local
# 临时
*.tmp
*.bak
screenshots/

1150
PRD.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,53 +0,0 @@
# AirShelf · UI 设计稿货架
@zyc / iye 的 UI 设计稿合集。每个子目录是一个独立项目,各自有自己的设计规范和静态稿。
## 货架内容
| 项目 | 风格 | 说明 |
| --- | --- | --- |
| [电商AI平台/](电商AI平台/) | Restraint V2.1 (Firecrawl-aligned) | AI 短视频带货生成平台 · 10 个页面 · 完整 5 阶段流水线 |
---
## 浏览方式
直接 clone + 用浏览器打开任意 `*.html`:
```bash
git clone https://gitea.airlabs.art/zyc/AirShelf.git
cd AirShelf/电商AI平台
# 浏览器直开 index.html · 或本地起 server
npx http-server . -p 8080
```
## 添加新项目
新项目作为根目录下的兄弟文件夹(中文命名 OK),保持各自独立:
```
AirShelf/
├── 电商AI平台/
├── <未来项目 B>/
└── <未来项目 C>/
```
---
## 部署
CI/CD 走 Gitea Actions + 火山引擎 CR + K3s(traefik + cert-manager)。
| 分支 | 环境 | 域名 | Image tag |
| --- | --- | --- | --- |
| `master` | production | `airshelf.airlabs.art` | `prod-YYYYMMDD-<sha7>` |
| `dev` | development | `airshelf.test.airlabs.art` | `dev-YYYYMMDD-<sha7>` |
推到对应分支会自动触发 [.gitea/workflows/deploy.yaml](.gitea/workflows/deploy.yaml):
checkout → docker build/push (`airshelf-web`,无构建阶段、纯 nginx + 静态) → kubectl apply [k8s/](k8s/) → rollout restart。
构建上下文是 `电商AI平台/`,Dockerfile/nginx.conf 都在该子目录。当前仅一个项目,故 image 名固定 `airshelf-web`;若未来加兄弟项目,流水线需要扩展为按项目分别构建。
**Gitea 仓库需要配置的 Secrets:**
- prod: `CR_PROD_PASSWORD` · `VOLCANO_PROD_KUBE_CONFIG`
- dev: `CR_SERVER` · `CR_USERNAME` · `CR_PASSWORD` · `VOLCANO_TEST_KUBE_CONFIG`

983
_check.html Normal file
View File

@ -0,0 +1,983 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>新建项目 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css">
<style>
.wizard { display: grid; grid-template-columns: 200px minmax(0, 1fr) 300px; gap: 36px; align-items: start; max-width: 1400px; }
@media (max-width: 1180px) { .wizard { grid-template-columns: 200px minmax(0, 1fr); } .wiz-preview { display: none; } }
.steps { position: sticky; top: 24px; align-self: start; }
.step { display: flex; gap: 12px; padding: 12px 0; position: relative; }
.step:not(:last-child)::after { content: ''; position: absolute; left: 11px; top: 36px; width: 1px; height: calc(100% - 24px); background: var(--border-faint); }
.step .num { width: 24px; height: 24px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); display: grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--black-alpha-48); flex-shrink: 0; z-index: 1; font-family: var(--font-mono); }
.step.done .num { background: var(--accent-black); border-color: var(--accent-black); color: var(--accent-white); }
.step.active .num { background: var(--heat); border-color: var(--heat); color: var(--accent-white); }
.step .label { font-size: 13.5px; font-weight: 500; color: var(--black-alpha-56); padding-top: 2px; }
.step .desc { font-size: 11.5px; color: var(--black-alpha-48); padding-top: 3px; line-height: 1.4; font-family: var(--font-mono); letter-spacing: .02em; }
.step.active .label { color: var(--accent-black); font-weight: 600; }
.step.done .label { color: var(--black-alpha-56); }
.step.done:not(:last-child)::after { background: var(--accent-black); }
.step.clickable { cursor: pointer; }
.step.clickable:hover .label { color: var(--heat); }
.step.clickable:hover .num { border-color: var(--heat); }
.wiz-pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 22px 24px; margin-bottom: 14px; }
.wiz-pane.active { padding: 26px 28px; position: relative; }
.wiz-pane.active::before, .wiz-pane.active::after { 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; }
.wiz-pane.active::before { top: -7px; left: -7px; }
.wiz-pane.active::after { bottom: -7px; right: -7px; }
.wiz-pane.collapsed { padding: 16px 20px; }
.wiz-pane-h { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.wiz-pane-h h3 { font-size: 14px; font-weight: 600; }
.wiz-step-h { margin-bottom: 18px; }
.wiz-step-h h2 { font-size: 20px; font-weight: 600; letter-spacing: -.015em; }
.wiz-step-h p { font-size: 13px; color: var(--black-alpha-56); margin-top: 6px; }
.opt-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.opt-row.cols-4 { grid-template-columns: repeat(4, 1fr); }
.opt-row.cols-6 { grid-template-columns: repeat(3, 1fr); }
@media (min-width: 1280px) { .opt-row.cols-6 { grid-template-columns: repeat(6, 1fr); } }
.opt-card { border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px; background: var(--surface); cursor: pointer; position: relative; display: flex; flex-direction: column; min-width: 0; transition: background var(--t-base), border-color var(--t-base); }
.opt-card:hover { background: var(--background-lighter); }
.opt-card.selected { border-color: var(--heat); background: var(--heat-12); }
.opt-card.selected::after { content: ''; position: absolute; top: 8px; right: 10px; width: 16px; height: 16px; background-color: var(--heat); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; border-radius: var(--r-sm); }
.opt-card h4 { font-size: 13px; font-weight: 600; }
.opt-card .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 3px; letter-spacing: .02em; }
.opt-card .note { font-size: 11.5px; color: var(--black-alpha-56); margin-top: 6px; line-height: 1.5; }
.opt-card .metric { margin-top: auto; padding-top: 10px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.opt-card .metric .val { color: var(--accent-black); font-weight: 500; }
.opt-card.selected .metric .val { color: var(--heat); }
.opt-card .badge { font-family: var(--font-mono); font-size: 9.5px; padding: 1px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-48); display: inline-block; margin-top: 8px; letter-spacing: .04em; align-self: flex-start; }
.opt-card.selected .badge { color: var(--heat); border-color: var(--heat-20); }
.theme-pill { display: inline-flex; gap: 4px; align-items: center; height: 28px; padding: 0 12px; border: 1px solid var(--border-faint); border-radius: 999px; background: var(--surface); font-size: 12.5px; cursor: pointer; color: var(--black-alpha-56); transition: background var(--t-base), border-color var(--t-base); }
.theme-pill:hover { background: var(--background-lighter); }
.theme-pill.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
.theme-pill svg { width: 12px; height: 12px; }
.reco-bubble { position: relative; margin-top: 10px; padding: 10px 14px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-md); display: flex; align-items: center; gap: 12px; font-size: 12.5px; color: var(--accent-black); }
.reco-bubble::before { content: ''; position: absolute; top: -5px; left: 28px; width: 9px; height: 9px; background: var(--heat-12); border-left: 1px solid var(--heat-20); border-top: 1px solid var(--heat-20); transform: rotate(45deg); }
.reco-bubble .ic { color: var(--heat); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; }
.reco-bubble .ic svg, .reco-bubble .dismiss svg { display: block; }
.reco-bubble .txt { flex: 1; line-height: 1.5; }
.reco-bubble .txt strong { color: var(--heat); font-weight: 600; }
.reco-bubble .txt .meta { display: block; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 2px; letter-spacing: .02em; }
.reco-bubble .btn-apply { height: 28px; padding: 0 12px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-md); font-size: 12px; font-weight: 600; cursor: pointer; flex-shrink: 0; box-shadow: var(--shadow-cta); transition: box-shadow var(--t-base); }
.reco-bubble .btn-apply:hover { box-shadow: var(--shadow-cta-hover); }
.reco-bubble .dismiss { background: transparent; color: var(--black-alpha-48); border: 0; width: 24px; height: 24px; padding: 0; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
.reco-bubble .dismiss:hover { color: var(--accent-black); }
.wiz-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border-faint); }
.btn:disabled, .btn.disabled { opacity: .45; cursor: not-allowed; pointer-events: none; }
/* ── pick toolbar (Step 1) ── */
.pick-toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.pick-toolbar .search-input { position: relative; flex: 1; max-width: 320px; min-width: 200px; }
.pick-toolbar .search-input svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--black-alpha-48); width: 14px; height: 14px; }
.pick-toolbar .search-input input { width: 100%; height: 32px; padding: 0 12px 0 34px; 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; transition: border-color var(--t-base); }
.pick-toolbar .search-input input:focus { outline: none; border-color: var(--heat); }
.cat-chip { height: 32px; padding: 0 12px; border: 1px solid var(--border-faint); background: var(--surface); border-radius: var(--r-md); font-size: 12.5px; color: var(--black-alpha-56); font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-family: inherit; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.cat-chip:hover { background: var(--background-lighter); }
.cat-chip.active { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }
.pick-section-h { display: flex; align-items: baseline; gap: 8px; margin: 14px 0 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; }
.pick-section-h .count { background: var(--background-lighter); border: 1px solid var(--border-faint); padding: 1px 6px; color: var(--black-alpha-48); font-size: 10px; }
/* ── Step 1 · product picker grid ── */
.product-pick-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
@media (max-width: 1100px) { .product-pick-grid { grid-template-columns: repeat(2, 1fr); } }
.product-pick { display: flex; gap: 12px; padding: 12px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); cursor: pointer; position: relative; transition: background var(--t-base), border-color var(--t-base); min-width: 0; }
.product-pick:hover { background: var(--background-lighter); }
.product-pick.selected { border-color: var(--heat); background: var(--heat-12); }
.product-pick.selected::after { content: ''; position: absolute; top: 8px; right: 10px; width: 16px; height: 16px; background-color: var(--heat); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; border-radius: var(--r-sm); }
.product-pick .thumb { width: 56px; height: 72px; flex-shrink: 0; }
.product-pick .body { flex: 1; min-width: 0; padding-right: 18px; }
.product-pick .name { font-weight: 600; font-size: 13px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.product-pick .meta { margin-top: 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.product-pick .meta b { color: var(--accent-black); font-weight: 500; }
.product-pick .tags { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; }
.product-pick .tag-s { font-size: 10.5px; color: var(--black-alpha-56); background: var(--background-lighter); padding: 1px 6px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); }
.product-pick.selected .tag-s { background: var(--surface); border-color: var(--heat-20); color: var(--heat); }
.product-pick.add { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; border-style: dashed; color: var(--black-alpha-48); min-height: 96px; }
.product-pick.add:hover { color: var(--heat); border-color: var(--heat); background: var(--heat-12); }
.product-pick.add .pc { width: 32px; height: 32px; border: 1px solid currentColor; display: grid; place-items: center; border-radius: var(--r-sm); }
.product-pick.add svg { width: 16px; height: 16px; }
/* ── Step 2 · source-type cards ── */
.source-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.source-card { display: flex; flex-direction: column; gap: 8px; padding: 16px 16px 14px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); cursor: pointer; position: relative; transition: background var(--t-base), border-color var(--t-base); min-height: 132px; }
.source-card:hover { background: var(--background-lighter); }
.source-card.selected { border-color: var(--heat); background: var(--heat-12); }
.source-card.selected::after { content: ''; position: absolute; top: 10px; right: 12px; width: 16px; height: 16px; background-color: var(--heat); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; border-radius: var(--r-sm); }
.source-card .src-ic { width: 32px; height: 32px; background: var(--background-lighter); color: var(--black-alpha-56); border: 1px solid var(--border-faint); border-radius: var(--r-sm); display: grid; place-items: center; }
.source-card .src-ic svg { width: 16px; height: 16px; }
.source-card.selected .src-ic { background: var(--surface); color: var(--heat); border-color: var(--heat-20); }
.source-card h4 { font-size: 14px; font-weight: 600; }
.source-card .src-tag { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); letter-spacing: .06em; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 1px 6px; align-self: flex-start; }
.source-card.selected .src-tag { color: var(--heat); border-color: var(--heat-20); }
.source-card .src-desc { font-size: 12px; color: var(--black-alpha-56); line-height: 1.55; margin-top: auto; }
.source-detail { margin-top: 16px; padding: 18px 20px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); }
.source-detail .sd-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; margin-bottom: 10px; }
.source-detail .sd-h b { color: var(--accent-black); font-weight: 500; }
/* ── shared field styles ── */
.field { display: block; margin-bottom: 16px; }
.field-label { display: block; font-size: 12.5px; color: var(--black-alpha-56); font-weight: 500; margin-bottom: 6px; }
.field-label .req { color: var(--heat); margin-left: 2px; }
.field-hint { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 4px; }
.input, .textarea { width: 100%; height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); font-size: 13px; color: var(--accent-black); font-family: inherit; transition: border-color var(--t-base); }
.input:focus, .textarea:focus { outline: none; border-color: var(--heat); }
.textarea { height: auto; padding: 10px 12px; resize: vertical; min-height: 120px; line-height: 1.55; }
/* ── Step 4 · confirm grid / billing / balance / eta / tos ── */
.confirm-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 18px; }
.confirm-card { position: relative; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); padding: 14px 16px; }
.confirm-card .cc-h { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; margin-bottom: 10px; }
.confirm-card .cc-edit { font-size: 11.5px; color: var(--black-alpha-56); letter-spacing: 0; font-family: var(--font-sans, 'Inter'); text-transform: none; padding: 2px 8px; border: 1px solid var(--border-faint); background: var(--surface); cursor: pointer; border-radius: var(--r-sm); }
.confirm-card .cc-edit:hover { color: var(--heat); border-color: var(--heat-20); }
.confirm-card .cc-body { font-size: 13px; color: var(--accent-black); }
.confirm-card .cc-body .ln { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 12.5px; color: var(--black-alpha-56); flex-wrap: wrap; }
.confirm-card .cc-body .ln b { color: var(--accent-black); font-weight: 500; }
.section-sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; margin: 18px 0 10px; }
.bill-list { border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); overflow: hidden; }
.bill-row { display: grid; grid-template-columns: 1fr auto 80px; align-items: baseline; gap: 12px; padding: 11px 16px; border-bottom: 1px solid var(--border-faint); }
.bill-row:last-child { border-bottom: 0; }
.bill-row .l { font-size: 12.5px; color: var(--accent-black); }
.bill-row .l .l-sub { color: var(--black-alpha-48); font-size: 11.5px; margin-left: 6px; }
.bill-row .qty { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; text-align: right; }
.bill-row .amt { font-family: var(--font-mono); font-size: 12.5px; color: var(--accent-black); font-variant-numeric: tabular-nums; text-align: right; }
.bill-row.subtotal { background: var(--background-lighter); }
.bill-row.subtotal .l { color: var(--black-alpha-56); font-size: 12px; }
.bill-row.total { background: var(--background-lighter); border-top: 1px solid var(--black-alpha-12); }
.bill-row.total .l { font-weight: 600; font-size: 13px; }
.bill-row.total .amt { font-size: 16px; font-weight: 600; color: var(--accent-black); }
.bill-row.total .amt small { font-size: 11px; color: var(--black-alpha-48); font-weight: 500; margin-left: 2px; }
.balance-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); margin-top: 10px; }
.balance-row .bl { display: flex; align-items: center; gap: 8px; flex: 1; flex-wrap: wrap; }
.balance-row .bl svg { width: 14px; height: 14px; color: var(--black-alpha-56); }
.balance-row .bl .lbl { font-size: 12.5px; color: var(--black-alpha-56); }
.balance-row .bl .val { font-family: var(--font-mono); font-size: 14px; color: var(--accent-black); font-variant-numeric: tabular-nums; font-weight: 500; }
.balance-row .bl .arrow { color: var(--black-alpha-48); font-family: var(--font-mono); }
.balance-row.low .bl .val.after { color: var(--accent-crimson); }
.balance-row .pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 500; border: 1px solid; white-space: nowrap; margin-left: auto; }
.balance-row .pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.balance-row .pill.ok { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); }
.balance-row .pill.err { background: var(--crimson-bg); border-color: var(--crimson-bd); color: var(--accent-crimson); }
.balance-row .pill.err a { margin-left: 4px; text-decoration: underline; cursor: pointer; }
.eta-block { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
.eta-tile { padding: 14px 16px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); }
.eta-tile .lbl { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; margin-bottom: 6px; }
.eta-tile .v { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
.eta-tile .v small { font-size: 11px; color: var(--black-alpha-48); font-weight: 500; margin-left: 2px; }
.eta-tile .desc { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 6px; }
/* ── SVG checkbox · per design spec (no CSS hack) ── */
.check-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 12.5px; color: var(--black-alpha-56); cursor: pointer; user-select: none; }
.check-row:hover .check-box { border-color: var(--black-alpha-56); }
.check-box { width: 16px; height: 16px; background: var(--surface); border: 1px solid var(--black-alpha-24); flex-shrink: 0; border-radius: var(--r-sm); display: grid; place-items: center; transition: background var(--t-base), border-color var(--t-base); }
.check-row.on .check-box { background: var(--heat); border-color: var(--heat); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 11px 11px; }
.check-row.on .lab { color: var(--accent-black); }
.check-row .lab b { color: var(--accent-black); font-weight: 500; }
.check-row .lab .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); margin-left: 6px; letter-spacing: .02em; }
.tos-row { display: flex; align-items: center; gap: 10px; padding: 14px 16px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); margin-top: 14px; cursor: pointer; font-size: 12.5px; color: var(--black-alpha-56); user-select: none; }
.tos-row:hover .check-box { border-color: var(--black-alpha-56); }
.tos-row.on { background: var(--heat-12); border-color: var(--heat-20); }
.tos-row.on .check-box { background: var(--heat); border-color: var(--heat); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 11px 11px; }
.tos-row.on .lab { color: var(--accent-black); }
.tos-row .lab a { color: var(--heat); text-decoration: underline; cursor: pointer; }
/* preview panel */
.wiz-preview { position: sticky; top: 24px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 18px; }
.wiz-preview::before, .wiz-preview::after { 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; }
.wiz-preview::before { top: -7px; left: -7px; }
.wiz-preview::after { bottom: -7px; right: -7px; }
.pv-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; margin-bottom: 12px; text-transform: uppercase; display: flex; justify-content: space-between; }
.pv-h .live { display: inline-flex; align-items: center; gap: 5px; color: var(--heat); }
.pv-h .live::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--heat); animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: .35 } }
.pv-title { font-size: 14px; font-weight: 600; line-height: 1.3; margin-bottom: 14px; word-break: break-all; }
.pv-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border-faint); border: 1px solid var(--border-faint); margin-bottom: 14px; }
.pv-metric { padding: 10px 12px; background: var(--surface); }
.pv-metric .l { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
.pv-metric .v { font-size: 18px; font-weight: 600; margin-top: 3px; font-variant-numeric: tabular-nums; color: var(--accent-black); }
.pv-metric .v small { font-size: 11px; color: var(--black-alpha-48); font-weight: 500; }
.pv-metric.accent .v { color: var(--heat); }
.pv-section { margin-top: 14px; }
.pv-section .lbl { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 8px; }
.pv-flow { display: flex; flex-wrap: wrap; gap: 4px 0; font-size: 11.5px; color: var(--black-alpha-56); align-items: center; line-height: 1.7; }
.pv-flow .node { padding: 2px 7px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--accent-black); font-weight: 500; }
.pv-flow .arrow { color: var(--heat); margin: 0 5px; display: inline-flex; align-items: center; }
.pv-flow .arrow svg { display: block; }
.pv-list { list-style: none; padding: 0; margin: 0; }
.pv-list li { font-size: 11.5px; color: var(--black-alpha-56); padding: 4px 0; display: flex; align-items: center; gap: 6px; }
.pv-list li::before { content: ''; width: 11px; height: 11px; flex-shrink: 0; background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23FA5D19' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12l5 5L20 6'/%3E%3C/svg%3E") no-repeat center; background-size: contain; }
.pv-foot { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--border-faint); font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); display: flex; justify-content: space-between; }
.pv-foot strong { color: var(--accent-black); font-weight: 500; }
</style>
</head>
<body>
<div id="page">
<div class="page-head">
<div>
<h1>新建项目</h1>
<div class="sub"><span class="mono">// 商品 → 脚本来源 → 配置 → 确认 · 4 步开始生成</span></div>
</div>
<div class="actions">
<a class="btn btn-ghost" href="projects.html">退出</a>
</div>
</div>
<div class="wizard">
<nav class="steps" id="rail"></nav>
<div id="wiz-body"></div>
<aside class="wiz-preview" id="preview"></aside>
</div>
</div>
<script src="assets/shell.js"></script>
<script src="assets/new-product-drawer.js"></script>
<script>Shell.render({ active: 'projects', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '视频项目', href: 'projects.html' }, { label: '新建项目' }] });</script>
<script>
/* ============================================================
新建项目 · 4 步动态向导 (vanilla JS state machine)
============================================================ */
(function () {
'use strict';
/* ---------- data ---------- */
const PRODUCTS = [
{ id: 'mask', name: '透真玻尿酸补水面膜', cat: '美妆个护', price: 39.9, imgs: 3, points: ['透明质酸 + B5', '30g 大容量精华', '0 香精 0 酒精'], tags: ['熬夜党', '敏感肌'] },
{ id: 'earphone', name: '南卡 Lite Pro 蓝牙耳机', cat: '数码 3C', price: 199, imgs: 5, points: ['主动降噪', '32 小时续航', 'IP55 防水'], tags: ['通勤', '运动'] },
{ id: 'noodle', name: '滋啦速食牛肉面 · 6 桶装', cat: '食品饮料', price: 49.9, imgs: 4, points: ['3 分钟出餐', '真材实料牛肉', '0 防腐剂'], tags: ['加班', '独居'] },
{ id: 'sun', name: '透真清透物理防晒霜', cat: '美妆个护', price: 69, imgs: 4, points: ['SPF50 PA+++', '纯物理防晒', '不泛白不假面'], tags: ['SPF50', '通勤'] },
{ id: 'coffee', name: '三顿半同款冻干咖啡粉', cat: '食品饮料', price: 89, imgs: 6, points: ['冷热水秒溶', '意式深烘', '24 颗轻便装'], tags: ['提神', '早八'] },
{ id: 'fryer', name: '小熊 4L 可视空气炸锅', cat: '家居家电', price: 159, imgs: 5, points: ['可视化窗口', '4L 大容量', '低脂少油'], tags: ['小户型', '健康'] },
{ id: 'yoga', name: '露露同款裸感瑜伽裤', cat: '运动户外', price: 119, imgs: 8, points: ['裸感面料', '高弹回弹', '随心动随心穿'], tags: ['健身房', '通勤'] },
];
const RECENT_IDS = ['mask', 'sun', 'coffee', 'earphone'];
const CATS = ['全部', '美妆个护', '数码 3C', '食品饮料', '家居家电', '运动户外'];
const SOURCES = [
{ id: 'ai', name: 'AI 全生', tag: '最常用', desc: 'LLM 全权决定脚本走向,最省事。后续仍可在故事板阶段微调。',
icon: '<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"/><path d="M19 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z"/></svg>' },
{ id: 'theme', name: '一句话主题', tag: '轻引导', desc: '你给一句切入主题,AI 按此扩写。推荐 530 字。',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>' },
{ id: 'manual', name: '自带脚本', tag: '我已有稿', desc: '粘贴或上传完整脚本,系统按镜头自动切分并适配商品。',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M9 13h6M9 17h6"/></svg>' },
];
const DURATIONS = [
{ id: '0-10', label: '0-10 秒', shots: [3, 4], tag: '黄金完播', completion: 52, conversion: 1.6 },
{ id: '0-15', label: '0-15 秒', shots: [4, 5], tag: '完播率最佳', completion: 42, conversion: 1.8 },
{ id: '0-30', label: '0-30 秒', shots: [6, 8], tag: '卖点详解', completion: 32, conversion: 2.1 },
{ id: '0-60', label: '0-60 秒', shots: [10, 12], tag: '故事化', completion: 26, conversion: 2.4 },
];
const STYLES = [
{ id: 'pain', name: '痛点种草', note: '用户痛点切入,以「我懂你」的口吻引出产品。', tag: '最常用', flow: ['痛点', '共鸣', '产品', '效果', '引导'] },
{ id: 'review', name: '开箱测评', note: '朋友式分享,从开箱到使用感受娓娓道来。', flow: ['开箱', '首印象', '试用', '对比', '结论'] },
{ id: 'compare', name: '对比展示', note: '「用前 vs 用后 / 同类 vs 本品」直观呈现。', flow: ['对照', '差距', '本品', '数据', '购买'] },
];
const PERSONAS = [
{ id: 'urban', name: '都市白领女性', sub: '25-30 岁', metric: '大盘消费力', defaults: { duration: '0-15', style: 'pain' } },
{ id: 'bestie', name: '闺蜜种草', sub: '邻家女孩', metric: '复购最高', defaults: { duration: '0-15', style: 'pain' } },
{ id: 'ceo', name: '总裁亲选', sub: '创始人 IP', metric: '30 万销额案例', defaults: { duration: '0-30', style: 'pain' } },
{ id: 'reviewer', name: '专业测评师', sub: '垂类达人', metric: '互动 +30%', defaults: { duration: '0-30', style: 'review' } },
{ id: 'mom', name: '实用宝妈', sub: '家庭决策者', metric: '母婴/家清稳', defaults: { duration: '0-30', style: 'pain' } },
{ id: 'genz', name: '学生党', sub: 'Z 世代 18-24', metric: '平价快消', defaults: { duration: '0-10', style: 'compare' } },
];
const USER_EMAIL = 'li@shop.com';
const ACCOUNT_BALANCE = 327.40;
/* ---------- state ---------- */
const state = {
currentStep: 1,
productId: null,
pickSearch: '',
pickCat: '全部',
sourceId: null,
themeText: '',
manualScript: '',
projectName: '',
duration: '0-15',
scriptStyle: 'pain',
persona: 'urban',
points: {},
recoDismissed: false,
notifyEmail: true,
notifyWeChat: false,
agreed: false,
};
/* ---------- helpers ---------- */
function $(sel) { return document.querySelector(sel); }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function getProduct() { return PRODUCTS.find(p => p.id === state.productId) || null; }
function getSource() { return SOURCES.find(s => s.id === state.sourceId) || null; }
function getPersona() { return PERSONAS.find(p => p.id === state.persona); }
function getDuration() { return DURATIONS.find(d => d.id === state.duration); }
function getStyle() { return STYLES.find(s => s.id === state.scriptStyle); }
function getShots() { const d = getDuration(); return (d.shots[0] + d.shots[1]) / 2; }
function getCost() {
const p = getProduct();
const script = 0.20;
const sb = 0.40;
const assets = p ? p.imgs * 0.30 : 0;
const render = getShots() * 0.30;
const subtotal = script + sb + assets + render;
const fee = +(subtotal * 0.05).toFixed(2);
return {
script: script.toFixed(2),
sb: sb.toFixed(2),
assets: assets.toFixed(2),
render: render.toFixed(2),
subtotal: subtotal.toFixed(2),
fee: fee.toFixed(2),
total: +(subtotal + fee).toFixed(2),
};
}
function balanceAfter() { return +(ACCOUNT_BALANCE - getCost().total).toFixed(2); }
function etaMinutes() {
const p = getProduct();
return Math.max(3, Math.round(2 + getShots() * 0.4 + (p ? p.imgs * 0.2 : 0)));
}
function canPass1() { return !!state.productId; }
function canPass2() {
const s = getSource(); if (!s) return false;
if (s.id === 'theme') return state.themeText.trim().length >= 4;
if (s.id === 'manual') return state.manualScript.trim().length >= 20;
return true;
}
function canPass3() { return state.projectName.trim().length >= 2; }
function canFinish() { return state.agreed && balanceAfter() >= 5; }
/* ---------- icons ---------- */
const ICONS = {
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>',
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>',
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>',
x: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5l14 14M19 5L5 19"/></svg>',
bulb: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>',
arrow: '<svg 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>',
wallet: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 14h2"/></svg>',
};
/* ---------- actions ---------- */
function selectProduct(id) {
state.productId = id;
const p = getProduct();
if (!state.projectName) {
state.projectName = p.name.split(' ')[0] + ' · 痛点种草 · v1';
}
state.points = {};
p.points.forEach((pt, i) => { state.points[pt] = i < 2; });
render();
}
function selectSource(id) {
state.sourceId = id;
render();
}
function goNext() {
if (state.currentStep === 1 && !canPass1()) return;
if (state.currentStep === 2 && !canPass2()) return;
if (state.currentStep === 3 && !canPass3()) return;
if (state.currentStep < 4) state.currentStep++;
render();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function goPrev() {
if (state.currentStep > 1) state.currentStep--;
render();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function jumpTo(n) {
if (n < state.currentStep) {
state.currentStep = n;
render();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
function applyPreset() {
const p = getPersona();
state.duration = p.defaults.duration;
state.scriptStyle = p.defaults.style;
state.recoDismissed = false;
render();
}
function startGenerate() {
if (!canFinish()) return;
if (typeof Shell !== 'undefined' && Shell.toast) {
Shell.toast('开始生成项目', '扣款 ¥' + getCost().total.toFixed(2) + ' · pipeline#stage-1');
}
setTimeout(() => { location.href = 'pipeline.html#stage-1'; }, 600);
}
function openNewProduct() {
if (!window.NewProductDrawer) {
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('Drawer 未加载', '检查 new-product-drawer.js');
return;
}
window.NewProductDrawer.open({
onSave: function (p) {
// p = { id, name, cat, target, points: string[], images: [...], imgs: N }
// 适配 wizard 数据结构
const product = {
id: p.id,
name: p.name,
cat: p.cat,
price: null, // 表单暂未收集价格,显示时跳过
imgs: p.imgs,
points: p.points,
tags: p.target ? p.target.split(/[ ,、、]+/).filter(Boolean).slice(0, 3) : [],
};
// 置顶插入,让用户立刻看到
PRODUCTS.unshift(product);
// 自动选中(同时种子项目名 / 卖点)
selectProduct(product.id);
}
});
}
// expose for inline onclick
window._wiz = {
selectProduct, selectSource, goNext, goPrev, jumpTo, applyPreset, startGenerate, openNewProduct,
setSearch: v => { state.pickSearch = v; renderStep1Only(); },
setCat: v => { state.pickCat = v; renderStep1Only(); },
setTheme: v => { state.themeText = v; updateFootOnly(); updatePreviewLive(); },
setScript: v => { state.manualScript = v; updateFootOnly(); },
setName: v => { state.projectName = v; updatePreviewLive(); updateFootOnly(); updateRailOnly(); },
setDur: v => { state.duration = v; render(); },
setStyle: v => { state.scriptStyle = v; render(); },
setPersona:v => { state.persona = v; state.recoDismissed = false; render(); },
togglePt: k => { state.points[k] = !state.points[k]; render(); },
dismissReco: () => { state.recoDismissed = true; render(); },
toggleEmail: () => { state.notifyEmail = !state.notifyEmail; render(); },
toggleWeChat: () => { state.notifyWeChat = !state.notifyWeChat; render(); },
toggleTos: () => { state.agreed = !state.agreed; render(); },
};
/* ============================================================
RENDER
============================================================ */
function railConfig() {
const p = getProduct(), s = getSource(), pe = getPersona(), du = getDuration(), st = getStyle();
return [
{ n: 1, label: '选择商品', desc: p ? p.name : '未选择' },
{ n: 2, label: '脚本来源', desc: s ? s.name : '未选择' },
{ n: 3, label: '项目配置', desc: state.currentStep >= 3 ? (du.label + ' · ' + st.name) : '时长 · 风格 · 人设' },
{ n: 4, label: '确认与计费', desc: '预估 ¥' + getCost().total.toFixed(2) },
];
}
function renderRail() {
const cfg = railConfig();
const html = cfg.map(s => {
const stt = s.n < state.currentStep ? 'done'
: s.n === state.currentStep ? 'active' : '';
const clickable = s.n < state.currentStep;
const numContent = stt === 'done' ? ICONS.check : s.n;
return `<div class="step ${stt}${clickable ? ' clickable' : ''}" ${clickable ? 'onclick="_wiz.jumpTo(' + s.n + ')"' : ''}>
<div class="num">${numContent}</div>
<div>
<div class="label">${esc(s.label)}</div>
<div class="desc">${esc(s.desc)}</div>
</div>
</div>`;
}).join('');
$('#rail').innerHTML = html;
}
function productPickHTML(p) {
const selected = state.productId === p.id;
return `<div class="product-pick${selected ? ' selected' : ''}" onclick="_wiz.selectProduct('${p.id}')">
<div class="placeholder thumb"><span class="ph-frame">9:16</span></div>
<div class="body">
<div class="name">${esc(p.name)}</div>
<div class="meta">${esc(p.cat)}${p.price != null ? ' · <b>¥' + p.price + '</b>' : ''} · ${p.imgs} 张图</div>
<div class="tags">${p.tags.map(t => `<span class="tag-s">${esc(t)}</span>`).join('')}</div>
</div>
</div>`;
}
function renderStep1() {
const q = state.pickSearch.trim();
const filtered = PRODUCTS.filter(p => {
if (state.pickCat !== '全部' && p.cat !== state.pickCat) return false;
if (q && !p.name.includes(q)) return false;
return true;
});
const recent = RECENT_IDS.map(id => PRODUCTS.find(p => p.id === id)).filter(Boolean);
const showRecent = state.pickCat === '全部' && !q;
return `<div class="wiz-pane active" data-step="1">
<div class="wiz-step-h">
<h2>第 1 步 · 选择商品</h2>
<p>从商品库选一个 SKU。它的主图与卖点会被 LLM 作为脚本/资产生成的素材。</p>
</div>
<div class="pick-toolbar">
<div class="search-input">
${ICONS.search}
<input type="text" placeholder="搜索商品名称、品牌" value="${esc(state.pickSearch)}" oninput="_wiz.setSearch(this.value)">
</div>
${CATS.map(c => `<button class="cat-chip${state.pickCat === c ? ' active' : ''}" onclick="_wiz.setCat('${esc(c)}')">${esc(c)}</button>`).join('')}
</div>
${showRecent ? `
<div class="pick-section-h"><span>最近使用</span><span class="count">${recent.length}</span></div>
<div class="product-pick-grid">
${recent.map(productPickHTML).join('')}
</div>
` : ''}
<div class="pick-section-h">
<span>${showRecent ? '全部商品' : '搜索结果'}</span>
<span class="count">${filtered.length}</span>
</div>
<div class="product-pick-grid">
${filtered.map(productPickHTML).join('')}
<div class="product-pick add" onclick="_wiz.openNewProduct()">
<div class="pc">${ICONS.plus}</div>
<div>新建商品</div>
</div>
</div>
</div>`;
}
function renderStep2() {
const s = getSource();
let detail = '';
if (s) {
if (s.id === 'ai') {
detail = `<div class="source-detail">
<div class="sd-h">// 已选 · <b>${esc(s.name)}</b></div>
<div class="field-hint" style="font-size: 12.5px; color: var(--black-alpha-72);">AI 全生模式无需额外输入。下一步选定时长 / 风格 / 人设后,LLM 会自动决定切入点和卖点权重。</div>
</div>`;
} else if (s.id === 'theme') {
detail = `<div class="source-detail">
<div class="sd-h">// 已选 · <b>${esc(s.name)}</b></div>
<div class="field" style="margin-bottom: 0;">
<label class="field-label">一句话主题<span class="req">*</span></label>
<input class="input" placeholder="例:熬夜党的急救面膜 / 加班吃啥不内疚" value="${esc(state.themeText)}" oninput="_wiz.setTheme(this.value)">
<div class="field-hint">推荐 530 字。这句话会作为 LLM 扩写的锚点,越具体越聚焦。</div>
</div>
</div>`;
} else if (s.id === 'manual') {
detail = `<div class="source-detail">
<div class="sd-h">// 已选 · <b>${esc(s.name)}</b></div>
<div class="field" style="margin-bottom: 0;">
<label class="field-label">粘贴脚本内容<span class="req">*</span></label>
<textarea class="textarea" placeholder="粘贴你的脚本内容(旁白 / 镜头描述均可,系统会自动切分镜头)" oninput="_wiz.setScript(this.value)">${esc(state.manualScript)}</textarea>
<div class="field-hint">最少 20 字。镜头数由你的脚本自然段落决定,时长 / 风格仍会影响后期渲染节奏。</div>
</div>
</div>`;
}
}
return `<div class="wiz-pane active" data-step="2">
<div class="wiz-step-h">
<h2>第 2 步 · 脚本来源</h2>
<p>决定 LLM 如何获得初稿脚本。三种方式由「最省事」到「最保真原意」。</p>
</div>
<div class="source-row">
${SOURCES.map(s => `<div class="source-card${state.sourceId === s.id ? ' selected' : ''}" onclick="_wiz.selectSource('${s.id}')">
<span class="src-ic">${s.icon}</span>
<h4>${esc(s.name)}</h4>
<span class="src-tag">[ ${esc(s.tag)} ]</span>
<p class="src-desc">${esc(s.desc)}</p>
</div>`).join('')}
</div>
${detail}
</div>`;
}
function renderStep3() {
const personaObj = getPersona(), durObj = getDuration(), styleObj = getStyle();
const recoMismatch = personaObj.defaults.duration !== state.duration || personaObj.defaults.style !== state.scriptStyle;
const showReco = recoMismatch && !state.recoDismissed;
const recoDur = DURATIONS.find(d => d.id === personaObj.defaults.duration);
const recoStyle = STYLES.find(s => s.id === personaObj.defaults.style);
return `<div class="wiz-pane active" data-step="3">
<div class="wiz-step-h">
<h2>第 3 步 · 项目配置</h2>
<p>这些设置会影响 LLM 生成脚本的方向,确认后会进入流水线第 1 步(脚本生成)。</p>
</div>
<div class="field">
<label class="field-label">项目名称<span class="req">*</span></label>
<input class="input" value="${esc(state.projectName)}" oninput="_wiz.setName(this.value)">
</div>
<div class="field">
<label class="field-label">视频时长<span class="req">*</span></label>
<div class="opt-row cols-4">
${DURATIONS.map(d => `<div class="opt-card${state.duration === d.id ? ' selected' : ''}" onclick="_wiz.setDur('${d.id}')">
<h4>${esc(d.label)}</h4>
<div class="sub">${d.shots[0]}-${d.shots[1]} 镜</div>
<div class="note">${esc(d.tag)}</div>
<div class="metric">完播 <span class="val">${d.completion}%</span></div>
</div>`).join('')}
</div>
<div class="field-hint">数据来源:抖音同品类 TOP 视频均值 · 实际镜头数由 LLM 决定</div>
</div>
<div class="field">
<label class="field-label">脚本风格</label>
<div class="opt-row">
${STYLES.map(s => `<div class="opt-card${state.scriptStyle === s.id ? ' selected' : ''}" onclick="_wiz.setStyle('${s.id}')">
<h4>${esc(s.name)}</h4>
<div class="note">${esc(s.note)}</div>
${s.tag ? `<span class="badge">[ ${esc(s.tag)} ]</span>` : ''}
</div>`).join('')}
</div>
</div>
<div class="field">
<label class="field-label">人物设定</label>
<div class="opt-row cols-6">
${PERSONAS.map(p => `<div class="opt-card${state.persona === p.id ? ' selected' : ''}" onclick="_wiz.setPersona('${p.id}')">
<h4>${esc(p.name)}</h4>
<div class="sub">${esc(p.sub)}</div>
<div class="metric"><span class="val">${esc(p.metric)}</span></div>
</div>`).join('')}
</div>
${showReco ? `<div class="reco-bubble">
<span class="ic">${ICONS.bulb}</span>
<div class="txt">
<span>抖音同人设 TOP 视频更常用 <strong>${esc(recoDur.label)}</strong> + <strong>${esc(recoStyle.name)}</strong></span>
<span class="meta">当前 ${esc(durObj.label)} · ${esc(styleObj.name)} → 推荐换为同人设最优组合</span>
</div>
<button class="btn-apply" onclick="_wiz.applyPreset()">一键套用</button>
<button class="dismiss" onclick="_wiz.dismissReco()" aria-label="忽略">${ICONS.x}</button>
</div>` : ''}
</div>
${Object.keys(state.points).length > 0 ? `<div class="field" style="margin-bottom: 0;">
<label class="field-label">关键卖点(可勾选要重点突出的)</label>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
${Object.entries(state.points).map(([k, v]) => `<span class="theme-pill${v ? ' active' : ''}" onclick="_wiz.togglePt('${esc(k).replace(/'/g, "\\'")}')">${v ? ICONS.check : ICONS.plus}<span>${esc(k)}</span></span>`).join('')}
</div>
</div>` : ''}
</div>`;
}
function renderStep4() {
const p = getProduct(), s = getSource(), personaObj = getPersona(), durObj = getDuration(), styleObj = getStyle();
const c = getCost();
const ba = balanceAfter();
const low = ba < 5;
const eta = etaMinutes();
const pointsList = Object.entries(state.points).filter(([, v]) => v).map(([k]) => k).join(' / ') || '未选';
return `<div class="wiz-pane active" data-step="4">
<div class="wiz-step-h">
<h2>第 4 步 · 确认与计费</h2>
<p>核对前 3 步的选择 + 计费明细。点击「开始生成」会立刻扣款并进入流水线。</p>
</div>
<div class="confirm-grid">
<div class="confirm-card">
<div class="cc-h"><span>// 商品</span><button class="cc-edit" onclick="_wiz.jumpTo(1)">修改</button></div>
${p ? `<div style="display:flex; gap:12px; align-items:flex-start;">
<div class="placeholder" style="width:44px; height:56px; flex-shrink:0;"><span class="ph-frame">9:16</span></div>
<div class="cc-body" style="min-width:0;">
<div style="font-weight:600; font-size:13px;">${esc(p.name)}</div>
<div class="ln">${esc(p.cat)}${p.price != null ? ' <span style="color: var(--black-alpha-32);">·</span> <b>¥' + p.price + '</b>' : ''} <span style="color: var(--black-alpha-32);">·</span> ${p.imgs} 张图</div>
</div>
</div>` : '<div class="cc-body">未选择</div>'}
</div>
<div class="confirm-card">
<div class="cc-h"><span>// 脚本来源</span><button class="cc-edit" onclick="_wiz.jumpTo(2)">修改</button></div>
${s ? `<div class="cc-body">
<div style="font-weight:600; font-size:13px;">${esc(s.name)}</div>
<div class="ln">${s.id === 'ai' ? 'LLM 全权 · 走向由 Step 3 决定'
: s.id === 'theme' ? '主题:<b style="margin-left:4px;">' + esc(state.themeText || '(未填)') + '</b>'
: '<b>' + state.manualScript.length + '</b> 字 · 自动切镜'}</div>
</div>` : '<div class="cc-body">未选择</div>'}
</div>
<div class="confirm-card">
<div class="cc-h"><span>// 项目配置</span><button class="cc-edit" onclick="_wiz.jumpTo(3)">修改</button></div>
<div class="cc-body">
<div style="font-weight:600; font-size:13px;">${esc(state.projectName)}</div>
<div class="ln"><b>${esc(styleObj.name)}</b> · ${esc(personaObj.name)} · ${esc(personaObj.sub)}</div>
<div class="ln" style="font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48);">卖点:${esc(pointsList)}</div>
</div>
</div>
<div class="confirm-card">
<div class="cc-h"><span>// 输出参数</span></div>
<div class="cc-body">
<div class="ln"><b>${esc(durObj.label)}</b> · <b>${durObj.shots[0]}-${durObj.shots[1]} 镜</b> · 9:16</div>
<div class="ln">预估完播 <b>${durObj.completion}%</b> · 预估转化 <b>${durObj.conversion}%</b></div>
<div class="ln" style="font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48);">// 数据来源:抖音同品类 TOP 均值</div>
</div>
</div>
</div>
<div class="section-sub">计费明细 · 按量计费</div>
<div class="bill-list">
<div class="bill-row"><div class="l">脚本生成 <span class="l-sub">LLM · 1 稿</span></div><div class="qty">× 1</div><div class="amt">¥${c.script}</div></div>
<div class="bill-row"><div class="l">故事板生成 <span class="l-sub">含分镜画面描述</span></div><div class="qty">× 1</div><div class="amt">¥${c.sb}</div></div>
<div class="bill-row"><div class="l">资产生成 <span class="l-sub">主图 → 镜头素材</span></div><div class="qty">× ${p ? p.imgs : 0} 张</div><div class="amt">¥${c.assets}</div></div>
<div class="bill-row"><div class="l">视频渲染 <span class="l-sub">合成 · 配乐 · 字幕</span></div><div class="qty">× ${getShots()} 镜</div><div class="amt">¥${c.render}</div></div>
<div class="bill-row subtotal"><div class="l">小计</div><div class="qty"></div><div class="amt">¥${c.subtotal}</div></div>
<div class="bill-row subtotal"><div class="l">平台服务费 <span class="l-sub">5%</span></div><div class="qty"></div><div class="amt">¥${c.fee}</div></div>
<div class="bill-row total"><div class="l">合计</div><div class="qty"></div><div class="amt">¥${Math.floor(c.total)}<small>.${c.total.toFixed(2).split('.')[1]}</small></div></div>
</div>
<div class="balance-row${low ? ' low' : ''}">
<div class="bl">
${ICONS.wallet}
<span class="lbl">账户余额</span>
<span class="val">¥${ACCOUNT_BALANCE.toFixed(2)}</span>
<span class="arrow"></span>
<span class="lbl">扣款后</span>
<span class="val after">¥${ba.toFixed(2)}</span>
</div>
${low
? `<span class="pill err"><span class="dot"></span>余额不足 · <a>去充值</a></span>`
: `<span class="pill ok"><span class="dot"></span>余额充足</span>`}
</div>
<div class="section-sub">预估耗时 · 通知</div>
<div class="eta-block">
<div class="eta-tile">
<div class="lbl">预估出片</div>
<div class="v">~ ${eta}<small>分钟</small></div>
<div class="desc">// pipeline 5 阶段累计 · 不含人工审核</div>
</div>
<div class="eta-tile">
<div class="lbl">完成后通知</div>
<div class="check-row${state.notifyEmail ? ' on' : ''}" onclick="_wiz.toggleEmail()" style="padding:4px 0;">
<span class="check-box"></span>
<span class="lab">邮件 <span class="mono">${esc(USER_EMAIL)}</span></span>
</div>
<div class="check-row${state.notifyWeChat ? ' on' : ''}" onclick="_wiz.toggleWeChat()" style="padding:4px 0;">
<span class="check-box"></span>
<span class="lab">微信 <span class="mono">未绑定 · 去绑定</span></span>
</div>
</div>
</div>
<div class="tos-row${state.agreed ? ' on' : ''}" onclick="_wiz.toggleTos()">
<span class="check-box"></span>
<span class="lab">我已阅读并同意 <a>《按量计费协议》</a><a>《商品素材使用授权》</a></span>
</div>
</div>`;
}
function renderCollapsedStep(n) {
const p = getProduct(), s = getSource();
let title = '', body = '';
if (n === 1) {
title = '第 1 步 · 选择商品';
body = p
? `<div style="display:flex; gap:12px; align-items:flex-start;">
<div class="placeholder" style="width:44px; height:56px; flex-shrink:0;"><span class="ph-frame">9:16</span></div>
<div style="min-width:0;">
<div style="font-weight:600; font-size:13.5px;">${esc(p.name)}</div>
<div class="mono" style="font-size:11.5px; color: var(--black-alpha-48); margin-top:3px; letter-spacing:.02em;">${esc(p.cat)}${p.price != null ? ' · ¥' + p.price : ''} · ${p.imgs} 张图 · ${p.points.length} 个卖点</div>
</div>
</div>`
: '<span class="mono" style="color: var(--black-alpha-48); font-size: 11.5px;">未选择</span>';
} else if (n === 2) {
title = '第 2 步 · 脚本来源';
if (s) {
let extra = '';
if (s.id === 'theme' && state.themeText) {
extra = `<span class="muted" style="color: var(--black-alpha-56);">主题:</span><span style="font-size: 13px;">${esc(state.themeText)}</span>`;
} else if (s.id === 'manual') {
extra = `<span class="muted" style="color: var(--black-alpha-56);">脚本:</span><span style="font-size: 13px;">${state.manualScript.length} 字</span>`;
} else {
extra = `<span class="mono" style="font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em;">// 走向由 Step 3 决定</span>`;
}
body = `<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<span class="pill info" style="display:inline-flex; align-items:center; gap:6px; padding: 4px 10px; border-radius: 999px; font-size: 11.5px; background: var(--heat-12); color: var(--heat); border: 1px solid var(--heat-20); font-weight: 500;"><span class="dot" style="width:6px;height:6px;border-radius:50%;background:currentColor;"></span>${esc(s.name)}</span>
${extra}
</div>`;
} else {
body = '<span class="mono" style="color: var(--black-alpha-48); font-size: 11.5px;">未选择</span>';
}
}
return `<div class="wiz-pane collapsed">
<div class="wiz-pane-h">
<h3>${esc(title)}</h3>
<span style="flex:1"></span>
<button class="btn btn-ghost btn-sm" onclick="_wiz.jumpTo(${n})">修改</button>
</div>
${body}
</div>`;
}
function renderFoot() {
const cfg = railConfig();
const last = state.currentStep === 4;
const passOk = state.currentStep === 1 ? canPass1()
: state.currentStep === 2 ? canPass2()
: state.currentStep === 3 ? canPass3()
: canFinish();
const nextLabel = last ? '开始生成 →' : '下一步 →';
const hint = last
? `// 扣款 ¥${getCost().total.toFixed(2)} · 进入 pipeline`
: `// 下一步:${cfg[state.currentStep].label}`;
const action = last ? '_wiz.startGenerate()' : '_wiz.goNext()';
return `<div class="wiz-foot">
<button class="btn btn-ghost"${state.currentStep === 1 ? ' disabled' : ''} onclick="_wiz.goPrev()">← 上一步</button>
<div style="display:flex; align-items:center; gap:12px;">
<span class="mono" style="font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em;">${esc(hint)}</span>
<button class="btn btn-primary btn-lg${!passOk ? ' disabled' : ''}" onclick="${action}">${nextLabel}</button>
</div>
</div>`;
}
function renderPreview() {
const p = getProduct(), personaObj = getPersona(), durObj = getDuration(), styleObj = getStyle(), c = getCost();
const shots = getShots();
const pointsOn = Object.entries(state.points).filter(([, v]) => v).map(([k]) => k);
const title = state.projectName || (p ? p.name + ' · 待命名' : '未命名项目');
const arrows = '<span class="arrow">' + ICONS.arrow + '</span>';
const productSection = p ? `
<div class="pv-section">
<div class="lbl">// 商品</div>
<ul class="pv-list">
<li>${esc(p.name)}</li>
<li>${esc(p.cat)}${p.price != null ? ' · ¥' + p.price : ''}</li>
</ul>
</div>
<div class="pv-section">
<div class="lbl">// 人设 · 风格</div>
<ul class="pv-list">
<li>${esc(personaObj.name)} · ${esc(personaObj.sub)}</li>
<li>${esc(styleObj.name)} · ${esc(durObj.tag)}</li>
</ul>
</div>
<div class="pv-section">
<div class="lbl">// 脚本走向</div>
<div class="pv-flow">
${styleObj.flow.map((n, i) => `<span style="display:inline-flex; align-items:center;"><span class="node">${esc(n)}</span>${i < styleObj.flow.length - 1 ? arrows : ''}</span>`).join('')}
</div>
</div>
<div class="pv-section">
<div class="lbl">// 突出卖点</div>
<ul class="pv-list">
${pointsOn.length ? pointsOn.map(k => `<li>${esc(k)}</li>`).join('') : '<li style="color: var(--black-alpha-48);">未选 · 由 LLM 自动权衡</li>'}
</ul>
</div>
` : `
<div class="pv-section">
<div class="lbl">// 待选择</div>
<ul class="pv-list" style="opacity: .6;">
<li style="color: var(--black-alpha-48);">先选一个商品</li>
<li style="color: var(--black-alpha-48);">预估指标会自动填充</li>
</ul>
</div>
`;
const footState = state.currentStep < 4 ? '进行中'
: canFinish() ? '就绪'
: (balanceAfter() < 5 ? '余额不足' : '待确认');
$('#preview').innerHTML = `
<div class="pv-h"><span>实时预估</span><span class="live">LIVE</span></div>
<div class="pv-title">${esc(title)}</div>
<div class="pv-metrics">
<div class="pv-metric"><div class="l">镜头</div><div class="v">${shots}<small></small></div></div>
<div class="pv-metric accent"><div class="l">预估完播</div><div class="v">${durObj.completion}<small>%</small></div></div>
<div class="pv-metric"><div class="l">预估转化</div><div class="v">${durObj.conversion}<small>%</small></div></div>
<div class="pv-metric"><div class="l">预估成本</div><div class="v">¥${c.total.toFixed(2)}</div></div>
</div>
${productSection}
<div class="pv-foot">
<span>Step ${state.currentStep} / 4 · Restraint</span>
<strong>${footState}</strong>
</div>
`;
}
/* ---------- partial updates (to keep inputs from losing focus) ---------- */
function renderStep1Only() {
// when user types in search or clicks cat chip — only re-render Step 1 main area
if (state.currentStep !== 1) return;
const body = $('#wiz-body');
const active = body.querySelector('.wiz-pane.active');
if (active) {
const tmp = document.createElement('div');
tmp.innerHTML = renderStep1();
active.replaceWith(tmp.firstElementChild);
}
// refocus search input
const inp = body.querySelector('.search-input input');
if (inp && document.activeElement !== inp) {
inp.focus();
const v = inp.value;
inp.setSelectionRange(v.length, v.length);
}
}
function updatePreviewLive() { renderPreview(); }
function updateFootOnly() {
const body = $('#wiz-body');
const foot = body.querySelector('.wiz-foot');
if (foot) {
const tmp = document.createElement('div');
tmp.innerHTML = renderFoot();
foot.replaceWith(tmp.firstElementChild);
}
}
function updateRailOnly() { renderRail(); }
/* ---------- main render ---------- */
function render() {
renderRail();
const body = $('#wiz-body');
let html = '';
if (state.currentStep >= 2) html += renderCollapsedStep(1);
if (state.currentStep >= 3) html += renderCollapsedStep(2);
if (state.currentStep === 1) html += renderStep1();
else if (state.currentStep === 2) html += renderStep2();
else if (state.currentStep === 3) html += renderStep3();
else html += renderStep4();
html += renderFoot();
body.innerHTML = html;
renderPreview();
}
// initial render
render();
})();
</script>
</body>
</html>

1196
app/globals.css Normal file

File diff suppressed because it is too large Load Diff

43
app/layout.tsx Normal file
View File

@ -0,0 +1,43 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import Sidebar from "@/components/Sidebar";
import GridBg from "@/components/GridBg";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
const mono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
display: "swap",
});
export const metadata: Metadata = {
title: "流·Studio — AI 短视频生产平台",
description:
"为抖音 / TikTok 商户打造的 AI 短视频生产流水线 · 脚本 → 基础资产 → 故事板 → 视频片段 → 拼接导出",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN" className={`${inter.variable} ${mono.variable}`}>
<body>
<div className="app">
<Sidebar />
<main className="main">
<GridBg />
{children}
</main>
</div>
</body>
</html>
);
}

196
app/page.tsx Normal file
View File

@ -0,0 +1,196 @@
import Link from "next/link";
import Topbar from "@/components/Topbar";
import Icon from "@/components/Icon";
interface Recent {
name: string;
meta: string;
prog: ("done" | "cur" | "fail" | "")[];
pill: { kind: "info" | "ok" | "err"; label: string };
action: { label: string; href: string };
}
const RECENT: Recent[] = [
{
name: "补水面膜 · 痛点种草",
meta: "补水面膜 / AI 全生 / 6 镜",
prog: ["done", "done", "cur", "", ""],
pill: { kind: "info", label: "故事板 待确认" },
action: { label: "继续", href: "/pipeline?stage=3" },
},
{
name: "蓝牙耳机 · 开箱测评",
meta: "南卡 Lite Pro / 自带脚本 / 5 镜",
prog: ["done", "done", "done", "done", "done"],
pill: { kind: "ok", label: "已完成" },
action: { label: "打开", href: "/pipeline?stage=5" },
},
{
name: "速食牛肉面 · 一句话主题",
meta: "滋啦速食 / 一句话 / 4 镜",
prog: ["done", "cur", "", "", ""],
pill: { kind: "info", label: "资产生成中" },
action: { label: "继续", href: "/pipeline?stage=2" },
},
{
name: "防晒霜 · 对比展示",
meta: "透真防晒 / AI 全生 / 6 镜",
prog: ["done", "done", "done", "cur", ""],
pill: { kind: "info", label: "视频生成 4/6" },
action: { label: "继续", href: "/pipeline?stage=4" },
},
{
name: "咖啡冻干粉 · 剧情带货",
meta: "三顿半同款 / 一句话 / 5 镜",
prog: ["done", "done", "fail", "", ""],
pill: { kind: "err", label: "故事板失败" },
action: { label: "查看", href: "/pipeline?stage=3" },
},
];
export default function WorkspacePage() {
return (
<>
<Topbar />
<section className="content">
<div className="welcome page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono-sub">// 05.13 · 周三</span>
<span>·</span>
<b style={{ color: "var(--ink)" }}>3 </b>
</div>
</div>
<div className="actions">
<Link className="btn" href="/products">
<Icon name="plus" size={14} />
</Link>
<Link className="btn btn-primary" href="/projects/new">
<Icon name="plus" size={14} />
</Link>
</div>
</div>
<div className="stats has-corners" style={{ marginBottom: 36 }}>
<span className="corner-tr" aria-hidden />
<span className="corner-bl" aria-hidden />
<div className="stat">
<div className="lbl"> <span className="badge">ALL</span></div>
<div className="v">12</div>
<div className="delta up"> +3</div>
</div>
<div className="stat">
<div className="lbl"> <span className="badge">WIP</span></div>
<div className="v">3</div>
<div className="delta">2 </div>
</div>
<div className="stat">
<div className="lbl"> <span className="badge">DONE</span></div>
<div className="v">8</div>
<div className="delta up"> +33%</div>
</div>
<div className="stat">
<div className="lbl"> <span className="badge">¥</span></div>
<div className="v">¥327<small>.40</small></div>
<div className="usage-bar"><span /></div>
<div className="sub-mono"> ¥162.60 / ¥500</div>
</div>
</div>
<div className="grid2">
<div>
<div className="section-h">
<h2></h2>
<Link className="more" href="/projects">[ ALL · 12 ] </Link>
</div>
<div className="list-card">
{RECENT.map((r) => (
<div className="recent-row" key={r.name}>
<div className="thumb">9:16</div>
<div className="r-meta">
<div className="name">{r.name}</div>
<div className="sub">{r.meta}</div>
</div>
<div className="prog">
{r.prog.map((p, i) => <span key={i} className={p || undefined} />)}
</div>
<span className={`pill pill-${r.pill.kind}`}><span className="dot" />{r.pill.label}</span>
<Link className="btn btn-sm" href={r.action.href}>{r.action.label}</Link>
</div>
))}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<div>
<div className="section-h">
<h2></h2>
<span className="more">[ /shortcuts ]</span>
</div>
<div className="shortcuts">
<Link className="shortcut" href="/products">
<div className="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8}>
<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>
</div>
<div>
<div className="t"></div>
<div className="d">12 SKU</div>
</div>
</Link>
<Link className="shortcut" href="/library">
<div className="ic"><Icon name="bars" /></div>
<div>
<div className="t"></div>
<div className="d"> 8 · 14 · 8</div>
</div>
</Link>
<Link className="shortcut" href="/account">
<div className="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8}>
<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>
<div className="t"></div>
<div className="d">¥327.40</div>
</div>
</Link>
<Link className="shortcut" href="/projects">
<div className="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8}>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M7 4v16M16 4v16M3 9h18M3 15h18" />
</svg>
</div>
<div>
<div className="t"></div>
<div className="d">12 </div>
</div>
</Link>
</div>
</div>
<div>
<div className="section-h">
<h2></h2>
<span className="more">[ FAQ ]</span>
</div>
<div className="tip">
<strong></strong>
{" "}
<span className="mono-pill">[ ]</span> token
</div>
</div>
</div>
</div>
</section>
</>
);
}

93
app/products/page.tsx Normal file
View File

@ -0,0 +1,93 @@
import Topbar from "@/components/Topbar";
import Icon from "@/components/Icon";
interface Product {
name: string;
cat: string;
imgs: number;
tags: string[];
thumb: string;
}
const PRODUCTS: Product[] = [
{ name: "透真玻尿酸补水面膜", cat: "美妆个护", imgs: 3, tags: ["熬夜党", "敏感肌", "¥39.9/盒"], thumb: "补水面膜 · 1200×800" },
{ name: "南卡 Lite Pro 蓝牙耳机", cat: "数码 3C", imgs: 5, tags: ["通勤", "运动", "¥199"], thumb: "蓝牙耳机 · 1200×800" },
{ name: "滋啦速食牛肉面 · 6 桶装", cat: "食品饮料", imgs: 4, tags: ["加班", "独居", "¥49.9"], thumb: "速食牛肉面 · 1200×800" },
{ name: "透真清透物理防晒霜", cat: "美妆个护", imgs: 4, tags: ["SPF50", "通勤", "¥69"], thumb: "防晒霜 · 1200×800" },
{ name: "三顿半同款冻干咖啡粉", cat: "食品饮料", imgs: 6, tags: ["提神", "早八", "¥89/24 颗"], thumb: "咖啡冻干粉 · 1200×800" },
{ name: "小熊 4L 可视空气炸锅", cat: "家电", imgs: 5, tags: ["小户型", "健康", "¥159"], thumb: "空气炸锅 · 1200×800" },
{ name: "露露同款裸感瑜伽裤", cat: "服饰", imgs: 8, tags: ["健身房", "通勤", "¥119"], thumb: "瑜伽裤 · 1200×800" },
];
const FILTERS = ["全部", "美妆个护", "数码 3C", "食品饮料", "服饰", "家电"];
export default function ProductsPage() {
return (
<>
<Topbar crumbs={[{ label: "工作台", href: "/" }, { label: "商品库" }]} />
<section className="content">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span>12 </span>
<span className="mono-sub">· SKU</span>
<span>· </span>
</div>
</div>
<div className="actions">
<button className="btn btn-primary">
<Icon name="plus" size={14} />
</button>
</div>
</div>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input className="input" placeholder="搜索商品名称、品牌" />
</div>
{FILTERS.map((f, i) => (
<button key={f} className={`filter-chip${i === 0 ? " active" : ""}`}>
{f}
{i === 0 && <span className="count-mini">12</span>}
</button>
))}
<span className="spacer" />
<button className="filter-chip"></button>
</div>
<div className="product-grid">
{PRODUCTS.map((p) => (
<div className="product-card" key={p.name}>
<div className="placeholder product-thumb">
<span className="ph-frame">{p.thumb}</span>
</div>
<div className="product-body">
<div className="product-name">{p.name}</div>
<div className="product-meta">
<span>{p.cat}</span>
<span className="dot-sep">·</span>
<span>{p.imgs} </span>
</div>
<div className="product-tags">
{p.tags.map((t) => (
<span key={t} className="tag-sm">{t}</span>
))}
</div>
</div>
</div>
))}
<div className="product-card add">
<div className="plus-circle">
<Icon name="plus" size={20} />
</div>
<div></div>
</div>
</div>
</section>
</>
);
}

877
app/projects/new/page.tsx Normal file
View File

@ -0,0 +1,877 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Topbar from "@/components/Topbar";
import Icon from "@/components/Icon";
/* ============================================================
Data
============================================================ */
interface Product {
id: string;
name: string;
cat: string;
price: number;
imgs: number;
points: string[];
tags: string[];
thumb: string;
}
const PRODUCTS: Product[] = [
{ id: "mask", name: "透真玻尿酸补水面膜", cat: "美妆个护", price: 39.9, imgs: 3, points: ["透明质酸 + B5", "30g 大容量精华", "0 香精 0 酒精"], tags: ["熬夜党", "敏感肌"], thumb: "补水面膜" },
{ id: "earphone",name: "南卡 Lite Pro 蓝牙耳机", cat: "数码 3C", price: 199, imgs: 5, points: ["主动降噪", "32 小时续航", "IP55 防水"], tags: ["通勤", "运动"], thumb: "蓝牙耳机" },
{ id: "noodle", name: "滋啦速食牛肉面 · 6 桶装", cat: "食品饮料", price: 49.9, imgs: 4, points: ["3 分钟出餐", "真材实料牛肉", "0 防腐剂"], tags: ["加班", "独居"], thumb: "速食牛肉面" },
{ id: "sun", name: "透真清透物理防晒霜", cat: "美妆个护", price: 69, imgs: 4, points: ["SPF50 PA+++", "纯物理防晒", "不泛白不假面"], tags: ["SPF50", "通勤"], thumb: "防晒霜" },
{ id: "coffee", name: "三顿半同款冻干咖啡粉", cat: "食品饮料", price: 89, imgs: 6, points: ["冷热水秒溶", "意式深烘", "24 颗轻便装"], tags: ["提神", "早八"], thumb: "咖啡冻干粉" },
{ id: "fryer", name: "小熊 4L 可视空气炸锅", cat: "家电", price: 159, imgs: 5, points: ["可视化窗口", "4L 大容量", "低脂少油"], tags: ["小户型", "健康"], thumb: "空气炸锅" },
{ id: "yoga", name: "露露同款裸感瑜伽裤", cat: "服饰", price: 119, imgs: 8, points: ["裸感面料", "高弹回弹", "随心动随心穿"], tags: ["健身房", "通勤"], thumb: "瑜伽裤" },
];
const RECENT_IDS = ["mask", "sun", "coffee", "earphone"];
const CATS = ["全部", "美妆个护", "数码 3C", "食品饮料", "服饰", "家电"];
type SourceId = "ai" | "theme" | "manual";
const SOURCES: Array<{ id: SourceId; name: string; icon: "sparkles" | "lightbulb" | "doc"; tag: string; desc: string }> = [
{ id: "ai", name: "AI 全生", icon: "sparkles", tag: "最常用", desc: "LLM 全权决定脚本走向,最省事。后续仍可在故事板阶段微调。" },
{ id: "theme", name: "一句话主题", icon: "lightbulb", tag: "轻引导", desc: "你给一句切入主题AI 按此扩写。推荐 530 字。" },
{ id: "manual", name: "自带脚本", icon: "doc", tag: "我已有稿", desc: "粘贴或上传完整脚本,系统按镜头自动切分并适配商品。" },
];
type DurationId = "0-10" | "0-15" | "0-30" | "0-60";
const DURATIONS: Array<{ id: DurationId; label: string; shotsRange: [number, number]; tag: string; completion: number; conversion: number; }> = [
{ id: "0-10", label: "0-10 秒", shotsRange: [3, 4], tag: "黄金完播", completion: 52, conversion: 1.6 },
{ id: "0-15", label: "0-15 秒", shotsRange: [4, 5], tag: "完播率最佳", completion: 42, conversion: 1.8 },
{ id: "0-30", label: "0-30 秒", shotsRange: [6, 8], tag: "卖点详解", completion: 32, conversion: 2.1 },
{ id: "0-60", label: "0-60 秒", shotsRange: [10, 12], tag: "故事化", completion: 26, conversion: 2.4 },
];
type StyleId = "pain" | "review" | "compare";
const STYLES: Array<{ id: StyleId; name: string; note: string; tag?: string; flow: string[] }> = [
{ id: "pain", name: "痛点种草", note: "用户痛点切入,以「我懂你」的口吻引出产品。", tag: "最常用", flow: ["痛点", "共鸣", "产品", "效果", "引导"] },
{ id: "review", name: "开箱测评", note: "朋友式分享,从开箱到使用感受娓娓道来。", flow: ["开箱", "首印象", "试用", "对比", "结论"] },
{ id: "compare", name: "对比展示", note: "「用前 vs 用后 / 同类 vs 本品」直观呈现。", flow: ["对照", "差距", "本品", "数据", "购买"] },
];
type PersonaId = "urban" | "bestie" | "ceo" | "reviewer" | "mom" | "genz";
const PERSONAS: Array<{ id: PersonaId; name: string; sub: string; metric: string; defaults: { duration: DurationId; style: StyleId } }> = [
{ id: "urban", name: "都市白领女性", sub: "25-30 岁", metric: "大盘消费力", defaults: { duration: "0-15", style: "pain" } },
{ id: "bestie", name: "闺蜜种草", sub: "邻家女孩", metric: "复购最高", defaults: { duration: "0-15", style: "pain" } },
{ id: "ceo", name: "总裁亲选", sub: "创始人 IP", metric: "30 万销额案例", defaults: { duration: "0-30", style: "pain" } },
{ id: "reviewer", name: "专业测评师", sub: "垂类达人", metric: "互动 +30%", defaults: { duration: "0-30", style: "review" } },
{ id: "mom", name: "实用宝妈", sub: "家庭决策者", metric: "母婴/家清稳", defaults: { duration: "0-30", style: "pain" } },
{ id: "genz", name: "学生党", sub: "Z 世代 18-24", metric: "平价快消", defaults: { duration: "0-10", style: "compare" } },
];
/* ============================================================
Helpers
============================================================ */
const USER_EMAIL = "airlabsv001@gmail.com";
const ACCOUNT_BALANCE = 327.4;
function avg([a, b]: [number, number]) { return (a + b) / 2; }
/* ============================================================
Component
============================================================ */
type StepNum = 1 | 2 | 3 | 4;
export default function NewProjectPage() {
const router = useRouter();
const [step, setStep] = useState<StepNum>(1);
// Step 1
const [productId, setProductId] = useState<string | null>(null);
const [pickSearch, setPickSearch] = useState("");
const [pickCat, setPickCat] = useState("全部");
// Step 2
const [sourceId, setSourceId] = useState<SourceId | null>(null);
const [themeText, setThemeText] = useState("");
const [manualScript, setManualScript] = useState("");
// Step 3
const [projectName, setProjectName] = useState("");
const [duration, setDuration] = useState<DurationId>("0-15");
const [scriptStyle, setScriptStyle] = useState<StyleId>("pain");
const [persona, setPersona] = useState<PersonaId>("urban");
const [recoDismissed, setRecoDismissed] = useState(false);
const [points, setPoints] = useState<Record<string, boolean>>({});
// Step 4
const [notifyEmail, setNotifyEmail] = useState(true);
const [notifyWeChat, setNotifyWeChat] = useState(false);
const [agreed, setAgreed] = useState(false);
/* ---- derived ---- */
const product = useMemo(() => PRODUCTS.find((p) => p.id === productId) ?? null, [productId]);
const source = useMemo(() => SOURCES.find((s) => s.id === sourceId) ?? null, [sourceId]);
const personaObj = useMemo(() => PERSONAS.find((p) => p.id === persona)!, [persona]);
const durationObj = useMemo(() => DURATIONS.find((d) => d.id === duration)!, [duration]);
const styleObj = useMemo(() => STYLES.find((s) => s.id === scriptStyle)!, [scriptStyle]);
const shots = avg(durationObj.shotsRange);
const completion = durationObj.completion;
const conversion = durationObj.conversion;
// Live cost: roughly 4 line items
const cost = useMemo(() => {
const script = 0.20;
const storyboard = 0.40;
const assets = product ? product.imgs * 0.30 : 0;
const render = shots * 0.30;
const subtotal = script + storyboard + assets + render;
const fee = +(subtotal * 0.05).toFixed(2);
return { script, storyboard, assets, render, subtotal: +subtotal.toFixed(2), fee, total: +(subtotal + fee).toFixed(2) };
}, [product, shots]);
const balanceAfter = +(ACCOUNT_BALANCE - cost.total).toFixed(2);
const lowBalance = balanceAfter < 5;
const etaMinutes = Math.max(3, Math.round(2 + shots * 0.4 + (product?.imgs ?? 0) * 0.2));
// Reco bubble (Step 3)
const recoMismatch =
personaObj.defaults.duration !== duration || personaObj.defaults.style !== scriptStyle;
const showReco = step === 3 && recoMismatch && !recoDismissed;
const recoDuration = DURATIONS.find((d) => d.id === personaObj.defaults.duration)!;
const recoStyle = STYLES.find((s) => s.id === personaObj.defaults.style)!;
/* ---- validation gates ---- */
const canPass1 = !!product;
const canPass2 =
!!source &&
(source.id !== "theme" || themeText.trim().length >= 4) &&
(source.id !== "manual" || manualScript.trim().length >= 20);
const canPass3 = projectName.trim().length >= 2;
const canFinish = agreed && !lowBalance;
/* ---- actions ---- */
function selectProduct(p: Product) {
setProductId(p.id);
// seed defaults derived from product
if (!projectName) setProjectName(`${p.name.split(" ")[0]} · 痛点种草 · v1`);
const seeded: Record<string, boolean> = {};
p.points.forEach((pt, i) => { seeded[pt] = i < 2; });
setPoints(seeded);
}
function applyPreset() {
setDuration(personaObj.defaults.duration);
setScriptStyle(personaObj.defaults.style);
setRecoDismissed(false);
}
function pickPersona(id: PersonaId) {
setPersona(id);
setRecoDismissed(false);
}
function togglePoint(k: string) { setPoints((p) => ({ ...p, [k]: !p[k] })); }
function goPrev() { setStep((s) => (s > 1 ? ((s - 1) as StepNum) : s)); }
function goNext() {
if (step === 1 && !canPass1) return;
if (step === 2 && !canPass2) return;
if (step === 3 && !canPass3) return;
setStep((s) => (s < 4 ? ((s + 1) as StepNum) : s));
}
function startGenerate() {
if (!canFinish) return;
router.push("/pipeline?stage=1");
}
function jumpTo(target: StepNum) {
// only allow going to a completed step or current
if (target < step) setStep(target);
}
/* ---- step rail config ---- */
const stepConfig: Array<{ n: StepNum; label: string; desc: string }> = [
{ n: 1, label: "选择商品", desc: product ? product.name : "未选择" },
{ n: 2, label: "脚本来源", desc: source ? source.name + (source.id === "theme" && themeText ? " · 有主题" : "") : "未选择" },
{ n: 3, label: "项目配置", desc: step >= 3 ? `${durationObj.label} · ${styleObj.name}` : "时长 · 风格 · 人设" },
{ n: 4, label: "确认与计费", desc: `预估 ¥${cost.total.toFixed(2)}` },
];
/* ---- filtered products for Step 1 ---- */
const filteredProducts = useMemo(() => {
return PRODUCTS.filter((p) => {
if (pickCat !== "全部" && p.cat !== pickCat) return false;
if (pickSearch && !p.name.includes(pickSearch)) return false;
return true;
});
}, [pickCat, pickSearch]);
const recentProducts = useMemo(
() => RECENT_IDS.map((id) => PRODUCTS.find((p) => p.id === id)!).filter(Boolean),
[]
);
return (
<>
<Topbar
crumbs={[
{ label: "工作台", href: "/" },
{ label: "视频项目", href: "/projects" },
{ label: "新建项目" },
]}
/>
<section className="content wizard-content">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono-sub">// 商品 → 脚本来源 → 配置 → 确认 · 4 步开始生成</span>
</div>
</div>
<div className="actions">
<Link className="btn btn-ghost" href="/projects">退</Link>
</div>
</div>
<div className="wizard-shell">
{/* ── Steps rail ─────────────────────────── */}
<nav className="steps">
{stepConfig.map((s, i) => {
const state: "done" | "active" | "pending" =
s.n < step ? "done" : s.n === step ? "active" : "pending";
const clickable = s.n < step;
return (
<div
key={s.n}
className={`step ${state}${clickable ? " clickable" : ""}${i === stepConfig.length - 1 ? " last" : ""}`}
onClick={() => clickable && jumpTo(s.n)}
>
<div className="num">
{state === "done"
? <Icon name="check" size={12} strokeWidth={3} />
: s.n}
</div>
<div>
<div className="step-label">{s.label}</div>
<div className="step-desc">{s.desc}</div>
</div>
</div>
);
})}
</nav>
{/* ── Wiz main ───────────────────────────── */}
<div className="wiz-main">
{/* Step 1 · 选择商品 ───────────────── */}
{step === 1 && (
<section className="card active-step">
<div className="step-h">
<h2> 1 · </h2>
<p> SKU LLM /</p>
</div>
<div className="pick-toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input
className="input"
placeholder="搜索商品名称、品牌"
value={pickSearch}
onChange={(e) => setPickSearch(e.target.value)}
/>
</div>
{CATS.map((c) => (
<button
key={c}
className={`filter-chip${pickCat === c ? " active" : ""}`}
onClick={() => setPickCat(c)}
>
{c}
</button>
))}
</div>
{pickCat === "全部" && !pickSearch && (
<>
<div className="pick-section-h">
<span>使</span>
<span className="count">{recentProducts.length}</span>
</div>
<div className="product-pick-grid">
{recentProducts.map((p) => (
<ProductPickCard key={p.id} p={p} selected={productId === p.id} onSelect={() => selectProduct(p)} />
))}
</div>
</>
)}
<div className="pick-section-h">
<span>{pickCat === "全部" && !pickSearch ? "全部商品" : "搜索结果"}</span>
<span className="count">{filteredProducts.length}</span>
</div>
<div className="product-pick-grid">
{filteredProducts.map((p) => (
<ProductPickCard key={p.id} p={p} selected={productId === p.id} onSelect={() => selectProduct(p)} />
))}
<div className="product-pick add">
<div className="pc"><Icon name="plus" size={16} /></div>
<div></div>
</div>
</div>
</section>
)}
{/* Step 2 · 脚本来源 ───────────────── */}
{step === 2 && (
<>
<CollapsedStep
title="第 1 步 · 选择商品"
onEdit={() => setStep(1)}
body={product && <ProductSummary p={product} />}
/>
<section className="card active-step">
<div className="step-h">
<h2> 2 · </h2>
<p> LLM 稿</p>
</div>
<div className="source-row">
{SOURCES.map((s) => (
<div
key={s.id}
className={`source-card${sourceId === s.id ? " selected" : ""}`}
onClick={() => setSourceId(s.id)}
>
<span className="src-ic"><Icon name={s.icon} size={16} /></span>
<h4>{s.name}</h4>
<span className="src-tag">[ {s.tag} ]</span>
<p className="src-desc">{s.desc}</p>
</div>
))}
</div>
{source && (
<div className="source-detail">
<div className="sd-h">
// 已选 · <b>{source.name}</b>
</div>
{source.id === "ai" && (
<div className="field-hint" style={{ fontSize: 12.5, color: "var(--ink-2)" }}>
AI / / LLM
</div>
)}
{source.id === "theme" && (
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"><span className="req">*</span></label>
<input
className="input"
placeholder="例:熬夜党的急救面膜 / 加班吃啥不内疚"
value={themeText}
onChange={(e) => setThemeText(e.target.value)}
/>
<div className="field-hint"> 530 LLM </div>
</div>
)}
{source.id === "manual" && (
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"><span className="req">*</span></label>
<textarea
className="input textarea"
style={{ minHeight: 140 }}
placeholder="粘贴你的脚本内容(旁白 / 镜头描述均可,系统会自动切分镜头)"
value={manualScript}
onChange={(e) => setManualScript(e.target.value)}
/>
<div className="field-hint">
20 /
</div>
</div>
)}
</div>
)}
</section>
</>
)}
{/* Step 3 · 项目配置 ───────────────── */}
{step === 3 && (
<>
<CollapsedStep
title="第 1 步 · 选择商品"
onEdit={() => setStep(1)}
body={product && <ProductSummary p={product} />}
/>
<CollapsedStep
title="第 2 步 · 脚本来源"
onEdit={() => setStep(2)}
body={source && <SourceSummary source={source} themeText={themeText} manualScript={manualScript} />}
/>
<section className="card active-step">
<div className="step-h">
<h2> 3 · </h2>
<p> LLM 线 1 </p>
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<input className="input" value={projectName} onChange={(e) => setProjectName(e.target.value)} />
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<div className="option-row cols-4">
{DURATIONS.map((d) => (
<div
key={d.id}
className={`option-card${duration === d.id ? " selected" : ""}`}
onClick={() => setDuration(d.id)}
>
<h4>{d.label}</h4>
<div className="sub">{d.shotsRange[0]}-{d.shotsRange[1]} </div>
<div className="note">{d.tag}</div>
<div className="metric"> <span className="val">{d.completion}%</span></div>
</div>
))}
</div>
<div className="field-hint"> TOP · LLM </div>
</div>
<div className="field">
<label className="field-label"></label>
<div className="option-row">
{STYLES.map((s) => (
<div
key={s.id}
className={`option-card${scriptStyle === s.id ? " selected" : ""}`}
onClick={() => setScriptStyle(s.id)}
>
<h4>{s.name}</h4>
<div className="note">{s.note}</div>
{s.tag && <span className="tag-mono">[ {s.tag} ]</span>}
</div>
))}
</div>
</div>
<div className="field">
<label className="field-label"></label>
<div className="option-row cols-6">
{PERSONAS.map((p) => (
<div
key={p.id}
className={`option-card${persona === p.id ? " selected" : ""}`}
onClick={() => pickPersona(p.id)}
>
<h4>{p.name}</h4>
<div className="sub">{p.sub}</div>
<div className="metric"><span className="val">{p.metric}</span></div>
</div>
))}
</div>
{showReco && (
<div className="reco-bubble">
<span className="ic"><Icon name="lightbulb" size={14} /></span>
<div className="txt">
<span>
TOP <strong>{recoDuration.label}</strong> + <strong>{recoStyle.name}</strong>
</span>
<span className="meta">
{durationObj.label} · {styleObj.name}
</span>
</div>
<button onClick={applyPreset}></button>
<button className="dismiss" onClick={() => setRecoDismissed(true)} aria-label="忽略">
<Icon name="x" size={14} />
</button>
</div>
)}
</div>
{Object.keys(points).length > 0 && (
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"></label>
<div className="hstack" style={{ gap: 6, flexWrap: "wrap" }}>
{Object.entries(points).map(([k, v]) => (
<span key={k} className={`theme-pill${v ? " on" : ""}`} onClick={() => togglePoint(k)}>
{v ? "✓" : "+"} {k}
</span>
))}
</div>
</div>
)}
</section>
</>
)}
{/* Step 4 · 确认与计费 ───────────── */}
{step === 4 && (
<section className="card active-step">
<div className="step-h">
<h2> 4 · </h2>
<p> 3 + 线</p>
</div>
<div className="confirm-grid">
<div className="confirm-card">
<div className="cc-h">
<span>// 商品</span>
<button className="cc-edit" onClick={() => setStep(1)}></button>
</div>
{product && (
<div className="hstack" style={{ gap: 12, alignItems: "flex-start" }}>
<div className="placeholder" style={{ width: 44, height: 56, flexShrink: 0 }}>
<span className="ph-frame">9:16</span>
</div>
<div className="cc-body" style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{product.name}</div>
<div className="ln">
<span>{product.cat}</span>
<span style={{ color: "var(--ink-4)" }}>·</span>
<b>¥{product.price}</b>
<span style={{ color: "var(--ink-4)" }}>·</span>
<span>{product.imgs} </span>
</div>
</div>
</div>
)}
</div>
<div className="confirm-card">
<div className="cc-h">
<span>// 脚本来源</span>
<button className="cc-edit" onClick={() => setStep(2)}></button>
</div>
{source && (
<div className="cc-body">
<div style={{ fontWeight: 600, fontSize: 13 }}>{source.name}</div>
<div className="ln">
{source.id === "ai" && <span>LLM · Step 3 </span>}
{source.id === "theme" && <span><b>{themeText || "(未填)"}</b></span>}
{source.id === "manual" && <span><b>{manualScript.length}</b> · </span>}
</div>
</div>
)}
</div>
<div className="confirm-card">
<div className="cc-h">
<span>// 项目配置</span>
<button className="cc-edit" onClick={() => setStep(3)}></button>
</div>
<div className="cc-body">
<div style={{ fontWeight: 600, fontSize: 13 }}>{projectName}</div>
<div className="ln"><b>{styleObj.name}</b> · {personaObj.name} · {personaObj.sub}</div>
<div className="ln" style={{ fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--ink-3)" }}>
{Object.entries(points).filter(([, v]) => v).map(([k]) => k).join(" / ") || "未选"}
</div>
</div>
</div>
<div className="confirm-card">
<div className="cc-h">
<span>// 输出参数</span>
</div>
<div className="cc-body">
<div className="ln"><b>{durationObj.label}</b> · <b>{durationObj.shotsRange[0]}-{durationObj.shotsRange[1]} </b> · 9:16</div>
<div className="ln"> <b>{completion}%</b> · <b>{conversion}%</b></div>
<div className="ln" style={{ fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--ink-3)" }}>
// 数据来源:抖音同品类 TOP 均值
</div>
</div>
</div>
</div>
<div className="section-sub"> · </div>
<div className="bill-list">
<div className="bill-row">
<div className="l"> <span className="l-sub">LLM · 1 稿</span></div>
<div className="qty">× 1</div>
<div className="amt">¥{cost.script.toFixed(2)}</div>
</div>
<div className="bill-row">
<div className="l"> <span className="l-sub"></span></div>
<div className="qty">× 1</div>
<div className="amt">¥{cost.storyboard.toFixed(2)}</div>
</div>
<div className="bill-row">
<div className="l"> <span className="l-sub"> </span></div>
<div className="qty">× {product?.imgs ?? 0} </div>
<div className="amt">¥{cost.assets.toFixed(2)}</div>
</div>
<div className="bill-row">
<div className="l"> <span className="l-sub"> · · </span></div>
<div className="qty">× {shots} </div>
<div className="amt">¥{cost.render.toFixed(2)}</div>
</div>
<div className="bill-row subtotal">
<div className="l"></div>
<div className="qty" />
<div className="amt">¥{cost.subtotal.toFixed(2)}</div>
</div>
<div className="bill-row subtotal">
<div className="l"> <span className="l-sub">5%</span></div>
<div className="qty" />
<div className="amt">¥{cost.fee.toFixed(2)}</div>
</div>
<div className="bill-row total">
<div className="l"></div>
<div className="qty" />
<div className="amt">¥{Math.floor(cost.total)}<small>.{cost.total.toFixed(2).split(".")[1]}</small></div>
</div>
</div>
<div className={`balance-row${lowBalance ? " low" : ""}`}>
<div className="bl">
<Icon name="wallet" size={14} />
<span className="lbl"></span>
<span className="val">¥{ACCOUNT_BALANCE.toFixed(2)}</span>
<span className="arrow"></span>
<span className="lbl"></span>
<span className="val after">¥{balanceAfter.toFixed(2)}</span>
</div>
{lowBalance ? (
<span className="pill pill-err"><span className="dot" /> · <a style={{ marginLeft: 4, textDecoration: "underline" }}></a></span>
) : (
<span className="pill pill-ok"><span className="dot" /></span>
)}
</div>
<div className="section-sub"> · </div>
<div className="eta-block">
<div className="eta-tile">
<div className="lbl"></div>
<div className="v">~ {etaMinutes}<small></small></div>
<div className="desc">// pipeline 5 阶段累计 · 不含人工审核</div>
</div>
<div className="eta-tile">
<div className="lbl"></div>
<div
className={`check-row${notifyEmail ? " on" : ""}`}
style={{ padding: "4px 0" }}
onClick={() => setNotifyEmail((v) => !v)}
>
<span className="check-box" />
<span className="lab"> <span className="mono">{USER_EMAIL}</span></span>
</div>
<div
className={`check-row${notifyWeChat ? " on" : ""}`}
style={{ padding: "4px 0" }}
onClick={() => setNotifyWeChat((v) => !v)}
>
<span className="check-box" />
<span className="lab"> <span className="mono"> · </span></span>
</div>
</div>
</div>
<div className={`tos-row${agreed ? " on" : ""}`} onClick={() => setAgreed((v) => !v)}>
<span className="check-box" />
<span className="lab">
<a></a> <a>使</a>
</span>
</div>
</section>
)}
{/* ── Wiz foot ──────────────────────── */}
<div className="wiz-foot">
<button className="btn btn-ghost" onClick={goPrev} disabled={step === 1}>
</button>
<div className="hstack" style={{ gap: 12 }}>
{step < 4 ? (
<>
<span className="muted-2" style={{ fontSize: 12.5, fontFamily: "var(--mono)", letterSpacing: ".02em" }}>
// 下一步:{stepConfig[step].label}
</span>
<button
className="btn btn-primary btn-lg"
disabled={
(step === 1 && !canPass1) ||
(step === 2 && !canPass2) ||
(step === 3 && !canPass3)
}
onClick={goNext}
>
</button>
</>
) : (
<>
<span className="muted-2" style={{ fontSize: 12.5, fontFamily: "var(--mono)", letterSpacing: ".02em" }}>
// 扣款 ¥{cost.total.toFixed(2)} · 进入 pipeline
</span>
<button className="btn btn-primary btn-lg" disabled={!canFinish} onClick={startGenerate}>
</button>
</>
)}
</div>
</div>
</div>
{/* ── Live preview panel ─────────────────── */}
<aside className="wiz-preview">
<div className="pv-h">
<span></span>
<span className="live-dot">LIVE</span>
</div>
<div className="pv-title">
{projectName || (product ? `${product.name} · 待命名` : "未命名项目")}
</div>
<div className="pv-metrics">
<div className="pv-metric">
<div className="l"></div>
<div className="v">{shots}<small></small></div>
</div>
<div className="pv-metric accent">
<div className="l"></div>
<div className="v">{completion}<small>%</small></div>
</div>
<div className="pv-metric">
<div className="l"></div>
<div className="v">{conversion}<small>%</small></div>
</div>
<div className="pv-metric">
<div className="l"></div>
<div className="v">¥{cost.total.toFixed(2)}</div>
</div>
</div>
{product ? (
<>
<div className="pv-section">
<div className="lbl">// 商品</div>
<ul className="pv-list">
<li>{product.name}</li>
<li>{product.cat} · ¥{product.price}</li>
</ul>
</div>
<div className="pv-section">
<div className="lbl">// 人设 · 风格</div>
<ul className="pv-list">
<li>{personaObj.name} · {personaObj.sub}</li>
<li>{styleObj.name} · {durationObj.tag}</li>
</ul>
</div>
<div className="pv-section">
<div className="lbl">// 脚本走向</div>
<div className="pv-flow">
{styleObj.flow.map((n, i) => (
<span key={i} style={{ display: "inline-flex", alignItems: "center" }}>
<span className="node">{n}</span>
{i < styleObj.flow.length - 1 && <span className="arrow"></span>}
</span>
))}
</div>
</div>
<div className="pv-section">
<div className="lbl">// 突出卖点</div>
<ul className="pv-list">
{Object.entries(points).filter(([, v]) => v).map(([k]) => <li key={k}>{k}</li>)}
{Object.values(points).every((v) => !v) && (
<li style={{ color: "var(--ink-3)" }}> · LLM </li>
)}
</ul>
</div>
</>
) : (
<div className="pv-section">
<div className="lbl">// 待选择</div>
<ul className="pv-list" style={{ opacity: 0.6 }}>
<li style={{ color: "var(--ink-3)" }}></li>
<li style={{ color: "var(--ink-3)" }}></li>
</ul>
</div>
)}
<div className="pv-foot">
<span>Step {step} / 4 · Restraint</span>
<strong>
{step < 4 ? "进行中" : canFinish ? "就绪" : (lowBalance ? "余额不足" : "待确认")}
</strong>
</div>
</aside>
</div>
</section>
</>
);
}
/* ============================================================
Sub-components
============================================================ */
function ProductPickCard({ p, selected, onSelect }: { p: Product; selected: boolean; onSelect: () => void }) {
return (
<div className={`product-pick${selected ? " selected" : ""}`} onClick={onSelect}>
<div className="placeholder thumb"><span className="ph-frame">9:16</span></div>
<div className="body">
<div className="name">{p.name}</div>
<div className="meta">
{p.cat} · <b>¥{p.price}</b> · {p.imgs}
</div>
<div className="tags">
{p.tags.map((t) => <span key={t} className="tag-s">{t}</span>)}
</div>
</div>
</div>
);
}
function CollapsedStep({ title, onEdit, body }: { title: string; onEdit: () => void; body: React.ReactNode }) {
return (
<section className="card collapsed-step">
<div className="hstack">
<h3>{title}</h3>
<span className="spacer" />
<button className="btn btn-ghost btn-sm" onClick={onEdit}></button>
</div>
<div style={{ marginTop: 10 }}>{body}</div>
</section>
);
}
function ProductSummary({ p }: { p: Product }) {
return (
<div className="hstack" style={{ gap: 12, alignItems: "flex-start" }}>
<div className="placeholder" style={{ width: 44, height: 56, flexShrink: 0 }}>
<span className="ph-frame">9:16</span>
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>{p.name}</div>
<div className="muted-2 mono" style={{ fontSize: 11.5, marginTop: 3, letterSpacing: ".02em" }}>
{p.cat} · ¥{p.price} · {p.imgs} · {p.points.length}
</div>
</div>
</div>
);
}
function SourceSummary({ source, themeText, manualScript }: { source: { id: SourceId; name: string }; themeText: string; manualScript: string }) {
return (
<div className="hstack" style={{ gap: 8, flexWrap: "wrap" }}>
<span className="pill pill-info"><span className="dot" />{source.name}</span>
{source.id === "theme" && themeText && (
<>
<span className="muted"></span>
<span style={{ fontSize: 13 }}>{themeText}</span>
</>
)}
{source.id === "manual" && (
<>
<span className="muted"></span>
<span style={{ fontSize: 13 }}>{manualScript.length} </span>
</>
)}
{source.id === "ai" && (
<span className="muted-2 mono" style={{ fontSize: 11.5, letterSpacing: ".02em" }}>
// 走向由 Step 3 决定
</span>
)}
</div>
);
}

224
app/projects/page.tsx Normal file
View File

@ -0,0 +1,224 @@
import Link from "next/link";
import Topbar from "@/components/Topbar";
import Icon from "@/components/Icon";
interface Project {
name: string;
sub: string;
product: string;
source: string;
prog: ("done" | "cur" | "fail" | "")[];
step: string;
pill: { kind: "info" | "ok" | "err" | "neutral"; label: string };
updated: string;
}
const PROJECTS: Project[] = [
{
name: "补水面膜 · 痛点种草 · v3",
sub: "6 镜 · 0-15s",
product: "透真补水面膜",
source: "AI 全生",
prog: ["done", "done", "cur", "", ""],
step: "3/5",
pill: { kind: "info", label: "故事板 待确认" },
updated: "12 分钟前",
},
{
name: "速食牛肉面 · 加班治愈",
sub: "4 镜 · 0-12s",
product: "滋啦速食 · 6 桶装",
source: "一句话主题",
prog: ["done", "cur", "", "", ""],
step: "2/5",
pill: { kind: "info", label: "资产生成中" },
updated: "37 分钟前",
},
{
name: "透真防晒 · 通勤对比",
sub: "6 镜 · 0-18s",
product: "透真清透防晒霜",
source: "AI 全生",
prog: ["done", "done", "done", "cur", ""],
step: "4/5",
pill: { kind: "info", label: "视频生成 4/6" },
updated: "2 小时前",
},
{
name: "咖啡冻干 · 早八剧情",
sub: "5 镜 · 0-15s",
product: "三顿半同款冻干",
source: "一句话主题",
prog: ["done", "done", "fail", "", ""],
step: "3/5",
pill: { kind: "err", label: "故事板生成失败" },
updated: "昨天 18:42",
},
{
name: "蓝牙耳机 · 开箱测评",
sub: "5 镜 · 0-15s",
product: "南卡 Lite Pro",
source: "自带脚本",
prog: ["done", "done", "done", "done", "done"],
step: "5/5",
pill: { kind: "ok", label: "已完成" },
updated: "5 月 7 日",
},
{
name: "瑜伽裤 · 通勤穿搭",
sub: "5 镜 · 0-15s",
product: "露露同款瑜伽裤",
source: "AI 全生",
prog: ["done", "done", "done", "done", "done"],
step: "5/5",
pill: { kind: "ok", label: "已完成" },
updated: "5 月 6 日",
},
{
name: "空气炸锅 · 小户型",
sub: "4 镜 · 0-12s",
product: "小熊 4L 空气炸锅",
source: "一句话主题",
prog: ["done", "done", "done", "done", "done"],
step: "5/5",
pill: { kind: "ok", label: "已完成" },
updated: "5 月 4 日",
},
{
name: "补水面膜 · 痛点种草 · v1",
sub: "6 镜 · 0-15s",
product: "透真补水面膜",
source: "AI 全生",
prog: ["done", "done", "done", "done", "done"],
step: "5/5",
pill: { kind: "neutral", label: "已归档" },
updated: "4 月 28 日",
},
];
const TABS = [
{ label: "全部", count: 12, active: true },
{ label: "进行中", count: 3 },
{ label: "待审核", count: 2 },
{ label: "已完成", count: 8 },
{ label: "失败", count: 1 },
];
export default function ProjectsPage() {
return (
<>
<Topbar crumbs={[{ label: "工作台", href: "/" }, { label: "视频项目" }]} />
<section className="content">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span>12 </span>
<span>·</span>
<span>3 </span>
<span>·</span>
<span>8 </span>
</div>
</div>
<div className="actions">
<Link className="btn btn-primary btn-lg" href="/projects/new">
<Icon name="plus" size={14} />
</Link>
</div>
</div>
<div className="tabs">
{TABS.map((t) => (
<div key={t.label} className={`tab${t.active ? " active" : ""}`}>
{t.label}
<span className="count">{t.count}</span>
</div>
))}
</div>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input className="input" placeholder="搜索项目名称、商品" />
</div>
<button className="filter-chip">
<Icon name="chev-down" size={12} />
</button>
<button className="filter-chip">
<Icon name="chev-down" size={12} />
</button>
<button className="filter-chip">
<Icon name="chev-down" size={12} />
</button>
<span className="spacer" />
<div className="view-toggle">
<button></button>
<button className="active"></button>
</div>
</div>
<table className="proj-table">
<thead>
<tr>
<th style={{ width: "32%" }}></th>
<th></th>
<th></th>
<th style={{ width: 200 }}></th>
<th></th>
<th style={{ width: 120 }}></th>
<th style={{ width: 60 }} />
</tr>
</thead>
<tbody>
{PROJECTS.map((p) => (
<tr key={p.name}>
<td>
<div className="proj-row-cell">
<div className="placeholder proj-thumb">
<span className="ph-frame">9:16</span>
</div>
<div>
<div className="proj-name">{p.name}</div>
<div className="proj-sub">{p.sub}</div>
</div>
</div>
</td>
<td>{p.product}</td>
<td><span className="muted">{p.source}</span></td>
<td>
<div className="hstack" style={{ gap: 8 }}>
<div className="prog">
{p.prog.map((s, i) => <span key={i} className={s || undefined} />)}
</div>
<span className="muted-2 mono" style={{ fontSize: 11 }}>{p.step}</span>
</div>
</td>
<td>
<span className={`pill pill-${p.pill.kind}`}>
<span className="dot" />
{p.pill.label}
</span>
</td>
<td className="muted-2">{p.updated}</td>
<td>
<div className="row-actions">
<Link className="icon-btn-sm" href="/pipeline?stage=3" title="继续">
<Icon name="play-tri" size={12} />
</Link>
<button className="icon-btn-sm" title="更多">
<Icon name="more" size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
</>
);
}

38
components/GridBg.tsx Normal file
View File

@ -0,0 +1,38 @@
export default function GridBg() {
return (
<>
<div className="grid-bg" aria-hidden />
<pre className="scatter" style={{ top: 96, left: 280 }} aria-hidden>
{` · · +
· +XX+
+XXXX·
+X· `}
</pre>
<pre className="scatter" style={{ top: 340, right: 96 }} aria-hidden>
{`+ · ·
XX· ·
·XXXX·+
·++· `}
</pre>
<pre className="scatter" style={{ bottom: 160, left: "42%" }} aria-hidden>
{` · +
+·XX·
·X+ ·
· `}
</pre>
<pre className="scatter" style={{ top: 580, left: 60 }} aria-hidden>
{` +X·
·XX·
+·X·+`}
</pre>
<span className="sq" style={{ top: 238, left: 478 }} aria-hidden />
<span className="sq" style={{ top: 478, left: 1198 }} aria-hidden />
<span className="sq" style={{ bottom: 300, left: 238 }} aria-hidden />
<span className="sq" style={{ top: 718, right: 240 }} aria-hidden />
<span className="corner-tag" style={{ top: 158, left: 34 }} aria-hidden>[ 200 OK ]</span>
<span className="corner-tag" style={{ top: 158, right: 34 }} aria-hidden>[ /v2 ]</span>
<span className="corner-tag" style={{ bottom: 36, left: 34 }} aria-hidden>[ .MP4 · 9:16 ]</span>
<span className="corner-tag" style={{ bottom: 36, right: 34 }} aria-hidden>[ STUDIO ]</span>
</>
);
}

177
components/Icon.tsx Normal file
View File

@ -0,0 +1,177 @@
import type { SVGProps } from "react";
type IconName =
| "home"
| "play"
| "folder"
| "tile"
| "bars"
| "bars2"
| "key"
| "cog"
| "chev-down"
| "chev-right"
| "search"
| "bell"
| "help"
| "doc"
| "up"
| "plus"
| "flame"
| "check"
| "x"
| "play-tri"
| "rotate"
| "more"
| "wallet"
| "coin"
| "download"
| "team"
| "lightbulb"
| "sparkles"
| "info"
| "arrow-right";
const PATHS: Record<IconName, React.ReactNode> = {
home: (
<>
<path d="M3 12 12 3l9 9" />
<path d="M5 10v10h14V10" />
</>
),
play: <polygon points="6 4 20 12 6 20" fill="currentColor" stroke="none" />,
folder: (
<>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M3 10h18" />
</>
),
tile: <path d="M3 6h18M3 12h18M3 18h18" />,
bars: (
<>
<rect x="3" y="4" width="6" height="16" />
<rect x="11" y="4" width="4" height="16" />
<rect x="17" y="6" width="4" height="14" />
</>
),
bars2: <path d="M3 21V9M9 21V5M15 21v-8M21 21V11" />,
key: (
<>
<circle cx="9" cy="12" r="6" />
<path d="m15 12 6 0M19 9v6" />
</>
),
cog: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8 2 2 0 0 1-2.8 2.8 1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5 2 2 0 0 1-4 0 1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3 2 2 0 0 1-2.8-2.8 1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1 2 2 0 0 1 0-4 1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8 2 2 0 0 1 2.8-2.8 1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5 2 2 0 0 1 4 0 1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3 2 2 0 0 1 2.8 2.8 1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1 2 2 0 0 1 0 4 1.7 1.7 0 0 0-1.5 1.1Z" />
</>
),
"chev-down": <path d="m6 9 6 6 6-6" />,
"chev-right": <path d="m9 6 6 6-6 6" />,
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-9M10 21a2 2 0 0 0 4 0" />,
help: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9a2.5 2.5 0 1 1 4 2.2c-.7.5-1.5 1-1.5 2v.3M12 17h.01" />
</>
),
doc: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6M9 13h6M9 17h6" />
</>
),
up: <path d="M12 19V5M5 12l7-7 7 7" />,
plus: <path d="M12 5v14M5 12h14" />,
flame: <path fill="currentColor" stroke="none" 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" />,
check: <path d="M4 12l5 5L20 6" />,
x: <path d="M5 5l14 14M19 5L5 19" />,
"play-tri": <path fill="currentColor" stroke="none" d="M5 4l14 8-14 8z" />,
rotate: (
<>
<path d="M4 12a8 8 0 0 1 14-5.5L21 9" />
<path d="M21 4v5h-5" />
<path d="M20 12a8 8 0 0 1-14 5.5L3 15" />
<path d="M3 20v-5h5" />
</>
),
more: (
<>
<circle cx="5" cy="12" r="1.5" fill="currentColor" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
<circle cx="19" cy="12" r="1.5" fill="currentColor" />
</>
),
wallet: (
<>
<rect x="3" y="6" width="18" height="13" rx="2" />
<path d="M3 10h18M16 14h2" />
</>
),
coin: (
<>
<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" />
</>
),
download: <path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" />,
team: (
<>
<circle cx="9" cy="9" r="3" />
<path d="M3 20c0-3 3-5 6-5s6 2 6 5" />
<circle cx="17" cy="10" r="2.4" />
<path d="M21 19c0-2-1.6-4-4-4-.6 0-1.2.2-1.7.4" />
</>
),
lightbulb: (
<>
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14" />
</>
),
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 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z" />
</>
),
info: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 16v-4M12 8h.01" />
</>
),
"arrow-right": <path d="M5 12h14M13 6l6 6-6 6" />,
};
interface Props extends Omit<SVGProps<SVGSVGElement>, "name"> {
name: IconName;
size?: number;
strokeWidth?: number;
}
export default function Icon({ name, size = 16, strokeWidth = 1.5, ...rest }: Props) {
return (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
{...rest}
>
{PATHS[name]}
</svg>
);
}

79
components/Sidebar.tsx Normal file
View File

@ -0,0 +1,79 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import Icon from "./Icon";
const NAV = [
{ id: "workspace", label: "工作台", icon: "home" as const, href: "/" },
{ id: "play", label: "试拍台", icon: "play" as const, href: "/play" },
{
id: "projects",
label: "项目",
icon: "folder" as const,
href: "/projects",
chev: true,
},
{ id: "products", label: "商品库", icon: "tile" as const, href: "/products" },
{ id: "library", label: "资产库", icon: "bars" as const, href: "/library" },
{ id: "usage", label: "用量", icon: "bars2" as const, href: "/usage" },
{ id: "api", label: "API Keys", icon: "key" as const, href: "/api-keys" },
{ id: "settings", label: "设置", icon: "cog" as const, href: "/settings" },
];
function isActive(pathname: string, href: string): boolean {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(href + "/");
}
export default function Sidebar() {
const pathname = usePathname();
// /account is reached via the user pill in the topbar — not in nav.
return (
<aside className="sidebar">
<div className="brand">
<div className="flame">
<Icon name="flame" size={22} />
</div>
<div className="brand-name">·Studio</div>
<div className="brand-ver">v1</div>
</div>
<div className="search-box">
<Icon name="search" />
<span></span>
<span className="kbd">K</span>
</div>
<nav className="sidebar-nav">
{NAV.map((n) => {
const active = isActive(pathname, n.href);
return (
<Link key={n.id} href={n.href} className={active ? "active" : ""}>
<Icon name={n.icon} />
<span className="label">{n.label}</span>
{n.chev && <Icon name="chev-down" className="chev" />}
</Link>
);
})}
<div className="nav-section"></div>
<div className="nav-item disabled" title="V1.5 上线 · 敬请期待">
<Icon name="team" />
<span className="label"></span>
<span className="badge-mini">V1.5</span>
</div>
</nav>
<div className="aside-foot">
<Link href="/account" className="aside-user">
<div className="av"></div>
<div className="em">li.dao@studio.cn</div>
</Link>
<div className="aside-collapse">
<Icon name="chev-right" size={12} style={{ transform: "rotate(180deg)" }} />
</div>
</div>
</aside>
);
}

65
components/Topbar.tsx Normal file
View File

@ -0,0 +1,65 @@
import Link from "next/link";
import Icon from "./Icon";
export interface Crumb {
label: string;
href?: string;
}
interface Props {
/** Pass crumbs to show breadcrumbs (e.g. inner pages). Omit to show the team switcher (workspace). */
crumbs?: Crumb[];
balance?: string;
}
export default function Topbar({ crumbs, balance = "¥327.40" }: Props) {
return (
<header className="topbar">
{crumbs && crumbs.length > 0 ? (
<nav className="crumbs">
{crumbs.map((c, i) => {
const last = i === crumbs.length - 1;
const sep = i > 0 ? <span key={`s-${i}`} className="sep">/</span> : null;
return last ? (
<span key={c.label}>{sep}<span className="here">{c.label}</span></span>
) : (
<span key={c.label}>
{sep}
{c.href ? <Link href={c.href}>{c.label}</Link> : <span>{c.label}</span>}
</span>
);
})}
</nav>
) : (
<div className="team-switcher" role="button">
<div className="p"></div>
<Icon name="chev-down" size={12} className="chev" />
</div>
)}
<div className="top-r">
<span className="balance-chip">
<Icon name="coin" size={13} />
<strong>{balance}</strong>
</span>
<button className="icon-btn" aria-label="通知">
<Icon name="bell" />
<span className="dot" />
</button>
<button className="pill-btn">
<Icon name="help" />
</button>
<button className="pill-btn">
<Icon name="doc" />
</button>
<button className="pill-btn upgrade">
<Icon name="up" />
</button>
</div>
</header>
);
}

118
deployment-guide.md Normal file
View File

@ -0,0 +1,118 @@
# 部署操作手册
> 本文档说明如何将代码推送到测试环境和生产环境。
> 日常开发在 `dev` 分支,生产发布通过合并到 `master` 分支触发。
---
## 环境说明
| 环境 | 触发分支 | 镜像仓库 | K3s 集群 | 域名 |
|------|---------|---------|---------|------|
| 测试development | `dev` | `cr.volces.com/zyc/...` | `192.168.0.129:6443` | `airflow-studio.test.airlabs.art` |
| 生产production | `master` | `gitea-prod-cn-shanghai.cr.volces.com/prod/...` | `192.168.0.130:6443` | `airflow-studio.airlabs.art` |
---
## 推送到测试环境
只需要把代码推到 `dev` 分支CI/CD 自动触发。
```bash
# 确认当前在 dev 分支
git checkout dev
# 提交代码
git add .
git commit -m "feat: 你的改动描述"
# 推送触发构建
git push origin dev
```
构建完成后在 Gitea Actions 查看进度:
- Build and Push Backend ✅
- Build and Push Web ✅
- Setup Kubectl ✅
- Deploy to K3s ✅
---
## 推送到生产环境
> ⚠️ **注意**:操作完成后必须切回 `dev` 分支,不要在 `master` 上继续开发。
### 完整流程
```bash
# 1. 确保 dev 分支代码是最新的
git checkout dev
git pull origin dev
# 2. 切换到 master 分支
git checkout master
# 3. 合并 dev 的代码
git merge dev
# 4. 推送到远程,触发生产构建
git push origin master
# 5. ⚠️ 立刻切回 dev不要停留在 master
git checkout dev
```
### 如果有合并冲突
```bash
# 解决冲突后
git add .
git commit -m "merge: dev into master"
git push origin master
git checkout dev
```
---
## 构建失败排查
### Build and Push 失败docker pull 超时)
Docker 镜像拉取超时CI 会自动重试 3 次。如仍失败,检查构建机网络。
### Setup Kubectl 失败command not found
kubectl 未安装或下载失败CI 会自动从 daocloud 镜像安装。
### Deploy to K3s 失败i/o timeout
K3s API Server 连接超时CI 会自动重试 3 次(每次间隔 10 秒)。
- 若持续失败,检查 K3s 节点状态:`kubectl get nodes`
- 确认 kubeconfig secret`VOLCANO_TEST_KUBE_CONFIG` / `VOLCANO_PROD_KUBE_CONFIG`)有值
---
## 快速检查部署状态
```bash
# 测试环境
ssh root@14.103.63.199
kubectl get pods -n default
# 生产环境
ssh root@118.196.0.100
kubectl get pods -n default
```
---
## Celery Worker 监控
Celery worker 负责轮询火山 API 的视频生成状态。
```bash
# 查看 worker 日志(测试环境)
kubectl logs -f deployment/celery-worker -n default
# 查看队列积压(测试环境 Redis
redis-cli -h redis-shzlsczo52dft8mia.redis.ivolces.com -p 6379 -a Zyc188208 llen celery
```
`recover_stuck_tasks` 定时任务每 3 分钟自动扫描卡住的任务并重新入队,无需手动干预。

6
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

6
next.config.mjs Normal file
View File

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default nextConfig;

1744
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "liu-studio",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.5.18",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "4.0.0",
"@types/node": "22.10.5",
"@types/react": "19.0.7",
"@types/react-dom": "19.0.3",
"postcss": "8.5.1",
"tailwindcss": "4.0.0",
"typescript": "5.7.3"
}
}

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@ -0,0 +1,36 @@
<!doctype html>
<html><head>
<style>
body { margin: 36px; background: #FAF9F5; font-family: Inter, sans-serif; font-size: 13px; }
.reco-bubble { position: relative; margin-top: 10px; padding: 10px 14px; background: #FFF4ED; border: 1px solid #FAE8DC; display: flex; align-items: center; gap: 12px; font-size: 12.5px; max-width: 700px; }
.reco-bubble::before { content: ''; position: absolute; top: -5px; left: 28px; width: 9px; height: 9px; background: #FFF4ED; border-left: 1px solid #FAE8DC; border-top: 1px solid #FAE8DC; transform: rotate(45deg); }
.reco-bubble .ic { color: #E55B26; display: inline-flex; align-items: center; width: 18px; height: 18px; flex-shrink: 0; }
.reco-bubble .ic svg, .reco-bubble .dismiss svg { display: block; }
.reco-bubble .txt { flex: 1; line-height: 1.5; }
.reco-bubble .txt strong { color: #E55B26; font-weight: 600; }
.reco-bubble .txt .meta { display: block; font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: #9C988C; margin-top: 2px; }
.reco-bubble .btn-apply { height: 26px; padding: 0 12px; background: #E55B26; color: #FFF; border: 1px solid #E55B26; font-size: 11.5px; font-weight: 600; cursor: pointer; flex-shrink: 0; }
.reco-bubble .dismiss { background: transparent; color: #9C988C; border: 0; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
.note { font-family: monospace; font-size: 11px; color: #888; margin: 12px 0 4px; }
</style></head>
<body>
<div class="note">// 旧 emoji 版本 (V1)</div>
<div class="reco-bubble">
<span class="ic" style="font-size:14px">💡</span>
<div class="txt"><span>抖音同人设 TOP 视频更常用 <strong>0-10 秒</strong> + <strong>对比展示</strong></span><span class="meta">当前 0-15 秒 · 痛点种草 → 推荐换为学生党最优组合</span></div>
<button class="btn-apply">一键套用</button>
<button class="dismiss" style="font-size:14px">×</button>
</div>
<div class="note">// 新 SVG icon 版本 (V2.1 spec · stroke 1.5)</div>
<div class="reco-bubble">
<span class="ic">
<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 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>
</span>
<div class="txt"><span>抖音同人设 TOP 视频更常用 <strong>0-10 秒</strong> + <strong>对比展示</strong></span><span class="meta">当前 0-15 秒 · 痛点种草 → 推荐换为学生党最优组合</span></div>
<button class="btn-apply">一键套用</button>
<button class="dismiss" aria-label="忽略">
<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 5l14 14M19 5L5 19"/></svg>
</button>
</div>
</body></html>

BIN
screenshots/fix-sidebar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

BIN
screenshots/v2.1-index.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

View File

@ -0,0 +1,70 @@
<!doctype html>
<html><head>
<style>
:root {
--ink: #15140F; --ink-2: #5A584F; --ink-3: #9C988C;
--orange: #E55B26; --orange-tint: #FFF4ED; --orange-soft: #FAE8DC;
--card: #FFFFFF; --border: #E9E5DB; --bg-soft: #F4F2EC; --bg: #FAF9F5;
}
body { margin: 36px; background: var(--bg); font-family: Inter, sans-serif; font-size: 13px; }
.demo { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin-bottom: 24px; }
.demo > div { background: var(--card); border: 1px solid var(--border); padding: 18px; position: relative; }
.lbl { font-family: monospace; font-size: 10px; color: #888; margin-bottom: 12px; letter-spacing: 0.04em; }
/* V1 char version */
.v1-corner::before, .v1-corner::after { content: '+'; position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 13px; line-height: 1; }
.v1-corner::before { top: -8px; left: -8px; }
.v1-corner::after { bottom: -8px; right: -8px; }
/* V2.1 SVG version */
.v2-corner::before, .v2-corner::after { 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='%239C988C'%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; }
.v2-corner::before { top: -7px; left: -7px; }
.v2-corner::after { bottom: -7px; right: -7px; }
/* Card selected check */
.card-radio { width: 160px; height: 90px; padding: 14px; background: var(--orange-tint); border: 1px solid var(--orange); position: relative; }
.card-radio h4 { font-size: 13px; font-weight: 600; }
.card-radio .sub { font-family: monospace; font-size: 10.5px; color: var(--ink-3); margin-top: 3px; }
.v1-check::after { content: '✓'; position: absolute; top: 8px; right: 10px; width: 16px; height: 16px; background: var(--orange); color: #FFF; font-size: 10.5px; font-weight: 700; display: grid; place-items: center; }
.v2-check::after { content: ''; position: absolute; top: 8px; right: 10px; width: 16px; height: 16px; background-color: var(--orange); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; }
/* List item check */
.pv-list { list-style: none; padding: 0; margin: 0; }
.pv-list li { font-size: 11.5px; color: var(--ink-2); padding: 4px 0; display: flex; align-items: center; gap: 6px; }
.v1-list li::before { content: '✓'; color: var(--orange); font-size: 11px; font-weight: 700; }
.v2-list li::before { content: ''; width: 11px; height: 11px; flex-shrink: 0; background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23E55B26' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12l5 5L20 6'/%3E%3C/svg%3E") no-repeat center; background-size: contain; }
</style></head>
<body>
<div class="lbl">// 容器四角准星 · V1 字符 (左) vs V2.1 SVG 圆弧内凹 (右)</div>
<div class="demo">
<div class="v1-corner"><strong>V1 · 字符 +</strong><br><span style="color:#999;font-size:11px">font-family: 'JetBrains Mono' · 13px</span></div>
<div class="v2-corner"><strong>V2.1 · SVG 准星</strong><br><span style="color:#999;font-size:11px">viewBox 22×21 · 圆弧内凹 · #9C988C</span></div>
</div>
<div class="lbl">// 选中卡片 ✓ 角标 · V1 字符 (左) vs V2.1 SVG (右)</div>
<div class="demo" style="grid-template-columns: 200px 200px;">
<div class="card-radio v1-check"><h4>V1 · 字符 ✓</h4><div class="sub">font-weight 700</div></div>
<div class="card-radio v2-check"><h4>V2.1 · SVG check</h4><div class="sub">stroke 3 · linecap round</div></div>
</div>
<div class="lbl">// 列表项 bullet · V1 字符 (左) vs V2.1 SVG (右)</div>
<div class="demo">
<div>
<strong style="display:block;margin-bottom:10px">V1 · 字符 ✓</strong>
<ul class="pv-list v1-list">
<li>透明质酸 + B5</li>
<li>30g 大容量精华</li>
<li>都市白领女性 · 25-30 岁</li>
</ul>
</div>
<div>
<strong style="display:block;margin-bottom:10px">V2.1 · SVG check (stroke 2.5)</strong>
<ul class="pv-list v2-list">
<li>透明质酸 + B5</li>
<li>30g 大容量精华</li>
<li>都市白领女性 · 25-30 岁</li>
</ul>
</div>
</div>
</body></html>

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "_design_src"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

393
v1/DESIGN_SPEC.md Normal file
View File

@ -0,0 +1,393 @@
# 流·Studio 设计规范
> **风格代号:** Restraint(克制)· Firecrawl-inspired
> **适用范围:** 流·Studio 全产品(工作台 / 项目 / 商品库 / 流水线 / 资产库 / 编辑器 / 账户)
> **最后更新:** 2026-05-14
---
## 1. 设计哲学
**一句话:** 不是 SaaS,不是工具站,而是「一台精密设备的工作面板」。
**三条铁律:**
1. **克制大于装饰** — 留白 > 容器 > 内容,大量空气感,绝不堆砌
2. **单色锚点** — 全场只有一种 accent(橙色),且只用于 CTA / 关键状态 / 强调单词
3. **结构清晰可见** — 用 1px 边框、网格背景、"+" 准星、mono 标签暴露"图纸感",而不是用阴影/渐变去隐藏结构
**避免的"AI 味":**
- 渐变铺面、玻璃拟态、彩色阴影
- 多色 emoji 图标
- 圆角无差别(全部 8px / 16px)
- 卡片浮在背景上的"贴纸感"
- 装饰盖过内容(场记板、霓虹灯、丝绒幕布这种主题公园式视觉)
---
## 2. 色彩系统
### 2.1 中性色(主体 95%)
| Token | Hex | 用途 |
| ------------- | --------- | ------------------------------- |
| `--bg` | `#FAF9F5` | 页面底色 · 米白(暖调,不是纯白) |
| `--bg-soft` | `#F4F2EC` | hover 态底色 / mono 标签背景 |
| `--card` | `#FFFFFF` | 卡片 / 容器底色 |
| `--border` | `#E9E5DB` | 主分隔线 · 1px 实线 |
| `--border-soft` | `#EFEBE0` | 次级边框 / 缩略图边框 |
### 2.2 文字层级(4 层)
| Token | Hex | 权重 / 用途 |
| ---------- | --------- | ----------------------------- |
| `--ink` | `#15140F` | 一级 · 标题 / 数值 / 主文本 |
| `--ink-2` | `#5A584F` | 二级 · 描述 / 次要按钮文字 |
| `--ink-3` | `#9C988C` | 三级 · 标签 / 辅助说明 / 占位 |
| `--ink-4` | `#C8C4B8` | 四级 · ASCII 散点 / 极弱装饰 |
### 2.3 主题色(只用于关键点)
| Token | Hex | 用途 |
| ----------------- | --------- | --------------------------------- |
| `--orange` | `#E55B26` | **唯一 accent** · 主 CTA / active 态 / 关键单词强调 |
| `--orange-soft` | `#FAE8DC` | 主色边框软化 / pill 边框 |
| `--orange-tint` | `#FFF4ED` | 主色背景软化 / active nav 底色 |
### 2.4 状态色(仅状态使用,克制)
| 含义 | Token | Hex | 配套底色 | 配套边框 |
| -------- | ---------- | --------- | ---------- | -------- |
| 成功 | `--green` | `#3F6B3F` | `#EAF2EA` | `#D5E5D5` |
| 失败/告警 | `--red` | `#B33A2A` | `#FBEBE7` | `#F2D6CE` |
> **注:** 没有蓝、紫、黄、青等「企业 dashboard 五彩斑斓色」。状态用色仅在 pill / 进度条段位上出现,绝不用作大面积背景。
---
## 3. 字体系统
### 3.1 字体族
| 用途 | 字体 |
| ---------- | --------------------------------- |
| 正文 / UI | `Inter, 'PingFang SC', 'Microsoft YaHei', sans-serif` |
| Mono 装饰 | `'JetBrains Mono', monospace` |
> Mono 字体不是用来写代码的,是用来做**装饰元素**:`[ 200 OK ]` 标签、时间戳 `// 05.14`、小注释 `/v2`、deltas `↑ 本月 +3` 等。
### 3.2 字号 / 字重 / 行高
| 角色 | 字号 | 字重 | 字距 | 用途 |
| ------------- | ------- | ---- | ---------- | ---------------- |
| H1 欢迎语 | 26 px | 600 | -0.018em | 页面主标题 |
| 数值 (KPI) | 30 px | 600 | -0.02em | 统计大数字 |
| H2 区块标题 | 15 px | 600 | -0.01em | "最近项目""快捷入口" |
| 卡片标题 | 13.5 px | 600 | normal | 项目名 / 商品名 |
| 正文 / nav | 13.5 px | 500 | normal | 默认 |
| 按钮 | 13 px | 500/600 | normal | btn / pill-btn |
| 描述次级 | 12.5 px | 400 | normal | meta / 描述行 |
| Pill 文字 | 11.5 px | 500 | normal | 状态徽标 |
| Mono 标签 | 10.511.5 px | 400/500 | 0.04em (letter-spacing) | `[ STATUS ]` |
| Mono 散点 | 8.5 px | 400 | 0.04em | 背景 ASCII 装饰 |
**行高:** 默认 `1.5`,标题 `1.2`,提示文本 `1.61.7`
**关键属性:** 数值类必须加 `font-variant-numeric: tabular-nums`,保证等宽对齐。
---
## 4. 圆角规则(最关键的一条)
> **核心原则:结构性容器 = 0 圆角 · 交互元素 = 适度圆角 · 状态徽标 = 完全圆**
| 元素类型 | 圆角值 | 例子 |
| -------------------- | ---------- | ------------------------------- |
| 大卡片 / 区块容器 | **0 px** | `.stats` `.list-card` `.shortcut` `.tip` `.modal` |
| 缩略图 / 画面占位 | **0 px** | `.thumb` `.ic` |
| 按钮 / 输入框 / icon-btn | **9 px** | `.btn` `.pill-btn` `.icon-btn` `.search` |
| nav 项 | **7 px** | `nav a` |
| 头像 / 小色块 | **6 px** | `.av` `.team .p` |
| Mono 标签 / badge | **34 px** | `.kbd` `.badge` |
| Pill 状态徽标 / dot | **999 px** | `.pill` `.dot` |
| 进度条段位 | **23 px** | `.prog span` `.bar` |
**为什么这样定:** 大容器走硬边像「图纸框」,有工程感;按钮走中圆角保留可点击的友好感;状态徽标走全圆形成强对比,视觉跳脱出来。
---
## 5. 边框 / 阴影 / 描边
### 5.1 边框
- **统一规格:** `1px solid var(--border)`
- 次级:`1px solid var(--border-soft)`
- **不允许 2px / 3px 实线**(粗边破坏轻盈感)
- 虚线仅用于 `.tip` 提示框:`1px dashed var(--border)`
### 5.2 阴影
- **默认无阴影**(关键!)
- 唯一例外:Toast 浮层 `box-shadow: 0 4px 20px rgba(21,20,15,0.06)`
- 弹窗用半透明遮罩 `rgba(21,20,15,0.42)` 拉开层级,**不靠 shadow**
### 5.3 容器四角"+" 注册标(签名元素)
所有重要区块容器的四角放 `+` 字符,模拟印刷套版准星:
```css
.stats::before,.stats::after { content:'+'; position:absolute; color:var(--ink-3); font-family:'JetBrains Mono',monospace; font-size:13px; }
.stats::before { top:-8px; left:-8px }
.stats::after { bottom:-8px; right:-8px }
/* 另外两角用额外 .corner-tr / .corner-bl span 补齐 */
```
---
## 6. 间距 / 栅格
**基础栅格:** 4 px
**常用间距:** 4 / 6 / 8 / 12 / 14 / 16 / 18 / 22 / 24 / 36 / 48 / 56
**主区块布局:**
- 侧边栏宽度:`248px`
- 主内容 padding:`48px 56px 56px`
- 内容最大宽度:`1480px`
- 区块之间垂直间距:`36px`(welcome → stats → grid2)
- 卡片网格间距:`24px`(主区) / `12px`(子区)
- 卡片内 padding:`22px 24px`(stats) / `14px 18px`(列表行) / `14px`(快捷入口)
---
## 7. 背景:制图纸网格
### 7.1 三层叠加
```
图层 1(最上):主交叉点 "+" 准星 — 240×240 重复
图层 2(中间):子交叉点小圆点 — 60×60 重复
图层 3(最下):虚线网格 — 240×240 重复(stroke-dasharray: 1.5 4)
```
**配色:**
- "+" 准星:`#B8B3A4`(stroke 1px)
- 小点:`#CFCABB`(r=0.9)
- 虚线:`#E2DED2`(stroke 1px)
### 7.2 视觉聚焦遮罩
整个网格背景用径向遮罩,中心实、四周淡:
```css
mask-image: radial-gradient(ellipse 95% 80% at 50% 35%, #000 25%, transparent 95%);
```
效果:网格在屏幕中心隐约可见,边缘消失,不抢内容。
### 7.3 装饰散点(Mono ASCII)
主区域 4 个固定位置撒 ASCII 散点,模拟手稿污点 / 颗粒感:
```
· · +
· +XX+
+XXXX·
+X·
```
字号 8.5px / 颜色 `--ink-4` / 透明度 0.8 / `pointer-events:none`
### 7.4 边角 Mono 标签
主区域 4 个角各放一个 Mono 标签,样例:
```
左上 [ 200 OK ] 右上 [ /v2 ]
左下 [ .MP4 · 9:16 ] 右下 [ STUDIO ]
```
字号 10.5px / 颜色 `--ink-3` / 字距 0.06em。
> **作用:** 让页面看起来像「在某个开发环境 / 调试视图里」,而不是普通官网。这是区分 AI 味和高级感的关键。
---
## 8. 组件规范
### 8.1 按钮
**3 种且仅 3 种:**
| 类型 | 背景 | 文字 | 边框 | 圆角 | 字重 |
| --------- | ---------- | ---------- | ---------- | ---- | ---- |
| `.btn` 默认 | `--card` 白 | `--ink` | `--border` | 9px | 500 |
| `.btn-primary` 主 CTA | `--orange` | 白 | `--orange` | 9px | 600 |
| `.btn-sm` 小按钮 | 同上,padding `5px 11px` / fontsize 12px | | | | |
Hover:默认 → `--bg-soft`;主按钮 → `#D04E1F`(橙色加深一档)。
### 8.2 Pill(状态徽标)
```
[ ● 状态文字 ] 完全圆角 / 1px 边框 / 11.5px 字号 / 4px 10px padding
```
| 状态 | 文字色 | 底色 | 边框 |
| ---- | ----------- | --------- | ----------- |
| info | `--orange` | `--orange-tint` | `--orange-soft` |
| ok | `--green` | `#EAF2EA` | `#D5E5D5` |
| err | `--red` | `#FBEBE7` | `#F2D6CE` |
每个 pill 前置 6px 圆点(`.dot`,颜色继承文字色)。
### 8.3 输入框 / 搜索
- 背景白 / 1px 边框 / 9px 圆角 / 9px 12px padding
- 占位字色 `--ink-3`
- 聚焦态可选橙色边框,但克制使用
- 右侧带 mono `⌘K` 提示徽标
### 8.4 KPI 统计行(`.stats`)
- 1 行 4 格,共用一个 1px 边框容器,无 gap
- 列与列之间用 `border-right: 1px` 分隔(最后一列去掉)
- 容器四角加 "+" 注册标
- 每格结构:label + badge → 大数字(30px) → delta / progress
### 8.5 列表(`.recent`)
```
[缩略图 54×70] [标题 + meta] [进度条 5 段] [pill] [按钮]
```
- grid 5 列:`54px 1fr auto auto auto`
- 行高 padding `14px 18px`
- 行间 1px 分隔线,最后行去掉
- 整行 hover → `--bg-soft`
### 8.6 进度条段位(流水线 5 阶段专用)
5 个 `18×5px` 小段,3px 间距,每段独立颜色:
| 状态 | 颜色 |
| ---- | ------------- |
| 未开始 | `--bg-soft` |
| 已完成 | `--ink-2` |
| 当前 | `--orange` |
| 失败 | `--red` |
### 8.7 快捷入口卡(`.shortcut`)
- 白底 / 1px 边框 / 0 圆角 / 14px padding
- 左侧 30×30 橙色 tint 图标块(0 圆角)
- 右侧:标题(13px 600)+ Mono 描述(11.5px,`--ink-3`)
- Hover → `--bg-soft`
### 8.8 提示框(`.tip`)
- 白底 / **1px 虚线**边框(`dashed`)/ 0 圆角
- 加粗标题独立一行 + 正文
- 内联代码用 `.mono` 类:橙色文字 + 橙色 tint 底 + 3px 圆角
### 8.9 Toast
- 右下角 24px 偏移 / 白底 / 1px 边框 / 0 圆角
- 唯一允许阴影的元素:`0 4px 20px rgba(21,20,15,0.06)`
- 进入动画:`translateX(420px → 0)`,缓动 `cubic-bezier(0.34, 1.56, 0.64, 1)`,300ms
- 自动消失:2400ms
- 内容结构:左侧 24×24 橙色 tint 图标 + 右侧标题 + Mono 副文本(`[ 200 OK ]`)
### 8.10 弹窗(Modal)
- 居中,460480px 宽,白底,**0 圆角**
- 四角加 "+" 准星(同 stats 容器规则)
- 遮罩 `rgba(21,20,15,0.42)`,带 `backdrop-filter: blur(8px)`
- 进入动画:`scale(0.96 → 1)`,250ms 弹性缓动
- 三段结构:
- **Header**:36px 橙色 tint 图标 + 标题 + Mono 副标
- **Body**:13px / `--ink-2` / 行高 1.7
- **Footer**:右对齐两个按钮(取消 + 主 CTA)
- ESC 关闭 / 点击遮罩关闭
---
## 9. 微观细节(决定"高级感"的)
### 9.1 标签全部包方括号
不要写 `200 OK`,要写 `[ 200 OK ]`(方括号 + 单空格)。
不要写 `MP4 9:16`,要写 `[ .MP4 · 9:16 ]`(中点分隔)。
路径用 `/v2``/sidebar collapse` 这种伪命令。
### 9.2 注释样式时间
`// 05.14 · 周五` —— 双斜杠开头,模拟代码注释。
### 9.3 数值后缀
`¥327` 主体大字 + `.40` 小字次级(`<small>`),让数字主体更突出。
### 9.4 强调单词上色
正文里 `<b style="color:var(--ink)">3 个项目</b>` —— 让具体数值/名词比周围文字更深一档(不是变橙,只是变更黑)。**橙色只留给 CTA。**
### 9.5 ASCII 字符做装饰
进度箭头:`↑ 本月 +3` `↓ -1.2%` 用 Unicode 箭头。
中文标点 + 英文单位混排:`本月 +3` `较上月 +33%` 中间用半角空格分隔。
### 9.6 链接式"更多"按钮
"查看全部"不要写成按钮,写成 `[ ALL · 12 ] →`(Mono 标签 + 箭头),颜色 `--ink-3`,hover 橙色。
### 9.7 缩略图不放图,放比例
`9:16` 这种 Mono 字符占位,模拟「未渲染图片的草稿状态」,呼应工程感。
---
## 10. Don't List(绝对禁止)
- ❌ 渐变背景(只有 hero 区可考虑,但首选纯色)
- ❌ 玻璃拟态(`backdrop-filter` 只用于 modal 遮罩,不用于卡片)
- ❌ 彩色 emoji 图标(用 SVG line icon,1.8px stroke)
- ❌ 多个 accent 色(全场只有橙色一个 accent)
- ❌ 大圆角容器(>=10px 的卡片直接判错)
- ❌ 投影 / 内阴影 / 文字阴影
- ❌ 鲜艳的状态色(避免荧光绿、电光蓝、霓虹粉这类)
- ❌ 居中对齐大段正文(全部左对齐)
- ❌ 把装饰当主角(场记板、丝绒、霓虹灯都不要)
- ❌ 无意义的微动效(hover 旋转、缩放、彩虹流光等)
---
## 11. Sass / CSS Token 速查
```css
:root{
/* 中性 */
--bg:#FAF9F5; --bg-soft:#F4F2EC; --card:#FFFFFF;
--border:#E9E5DB; --border-soft:#EFEBE0;
/* 文字 4 层 */
--ink:#15140F; --ink-2:#5A584F; --ink-3:#9C988C; --ink-4:#C8C4B8;
/* 主色 */
--orange:#E55B26; --orange-soft:#FAE8DC; --orange-tint:#FFF4ED;
--orange-hover:#D04E1F;
/* 状态 */
--green:#3F6B3F; --green-bg:#EAF2EA; --green-bd:#D5E5D5;
--red:#B33A2A; --red-bg:#FBEBE7; --red-bd:#F2D6CE;
}
```
---
## 12. 参考与来源
- **视觉灵感:** [Firecrawl](https://www.firecrawl.dev/)(克制 / 单橙 accent / mono 装饰)
- **结构灵感:** Linear / Stripe Dashboard(信息密度 / 1px 分层)
- **图纸感来源:** 印刷套版准星 + 老 Unix 终端
- **当前实现:** [demo.html](demo.html) · [restraint-2.html](_design_src/untitled/project/directions/v3/restraint-2.html)

169
v1/account.html Normal file
View File

@ -0,0 +1,169 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>账户 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css">
<style>
.acc-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 24px; align-items: start; }
.balance-banner {
background: var(--ink);
color: #FFF;
padding: 28px 32px;
margin-bottom: 24px;
position: relative;
border: 1px solid var(--ink);
}
.balance-banner::before, .balance-banner::after { content: '+'; position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 13px; }
.balance-banner::before { top: -8px; left: -8px; }
.balance-banner::after { bottom: -8px; right: -8px; }
.balance-banner .corner-tr, .balance-banner .corner-bl { position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 13px; }
.balance-banner .corner-tr { top: -8px; right: -8px; }
.balance-banner .corner-bl { bottom: -8px; left: -8px; }
.balance-banner .lbl { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: rgba(255,255,255,.55); letter-spacing: .04em; }
.balance-banner .v { font-size: 42px; font-weight: 700; letter-spacing: -.018em; margin-top: 8px; font-variant-numeric: tabular-nums; }
.balance-banner .meta { font-size: 12.5px; color: rgba(255,255,255,.5); margin-top: 8px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
.balance-banner .actions { display: flex; gap: 8px; margin-top: 18px; }
.balance-banner .btn { background: #FFF; color: var(--ink); border-color: #FFF; }
.balance-banner .btn:hover { background: var(--bg); }
.balance-banner .btn-ghost { background: transparent; color: #FFF; border: 1px solid rgba(255,255,255,.25); }
.balance-banner .btn-ghost:hover { background: rgba(255,255,255,.1); color: #FFF; }
.recharge-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 12px; }
.recharge-card { border: 1px solid var(--border); padding: 18px; text-align: center; cursor: pointer; background: var(--card); position: relative; }
.recharge-card:hover { background: var(--bg-soft); }
.recharge-card.selected { border-color: var(--orange); background: var(--orange-tint); }
.recharge-card.selected::before, .recharge-card.selected::after { content: '+'; position: absolute; color: var(--orange); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.recharge-card.selected::before { top: -7px; left: -7px; }
.recharge-card.selected::after { bottom: -7px; right: -7px; }
.recharge-card .amt { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }
.recharge-card .gift { font-size: 11px; color: var(--ink-3); margin-top: 4px; font-family: 'JetBrains Mono', monospace; }
.recharge-card .gift.bonus { color: var(--green); font-weight: 600; }
.recharge-card .ribbon { position: absolute; top: -8px; right: 8px; font-family: 'JetBrains Mono', monospace; font-size: 9.5px; padding: 1px 6px; background: var(--orange); color: #FFF; letter-spacing: .04em; font-weight: 600; }
.pane { background: var(--card); border: 1px solid var(--border); padding: 20px; margin-bottom: 16px; }
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 14px; }
.pane h3 + .desc { font-size: 12px; color: var(--ink-3); margin-top: -10px; margin-bottom: 14px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
.bills .neg { color: var(--ink); font-variant-numeric: tabular-nums; font-weight: 500; }
.bills .pos { color: var(--green); font-variant-numeric: tabular-nums; font-weight: 500; }
.bills .ref { color: var(--ink-3); font-size: 11px; font-family: 'JetBrains Mono', monospace; }
.usage-line { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; }
.usage-line .v { font-variant-numeric: tabular-nums; color: var(--ink); font-weight: 600; }
.usage-bar { height: 4px; background: var(--bg-soft); border-radius: 2px; margin: 6px 0 12px; overflow: hidden; }
.usage-bar > span { display: block; height: 100%; }
.rule-list { font-size: 12.5px; color: var(--ink-2); line-height: 1.7; }
.rule-list strong { color: var(--ink); font-weight: 600; }
.rule-list .mono { font-family: 'JetBrains Mono', monospace; color: var(--orange); background: var(--orange-tint); padding: 1px 5px; font-size: 11.5px; }
</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="acc-grid">
<div>
<div class="balance-banner">
<span class="corner-tr">+</span><span class="corner-bl">+</span>
<div class="lbl">[ CURRENT BALANCE ]</div>
<div class="v">¥327.40</div>
<div class="meta">// 本月已消费 ¥162.60 · 可使用约 32 个项目</div>
<div class="actions">
<button class="btn btn-lg" onclick="Shell.toast('充值', '/billing/topup')">充值</button>
<button class="btn btn-ghost btn-lg" onclick="Shell.toast('提取明细', '/billing/export')">提取消费明细</button>
</div>
</div>
<div class="pane">
<h3>快速充值</h3>
<div class="desc">// 充值后立刻到账,可开发票</div>
<div class="recharge-row">
<div class="recharge-card" onclick="Shell.toast('选择 ¥100')"><div class="amt">¥100</div><div class="gift">无赠送</div></div>
<div class="recharge-card selected"><span class="ribbon">推荐</span><div class="amt">¥500</div><div class="gift bonus">+ ¥30 赠送</div></div>
<div class="recharge-card" onclick="Shell.toast('选择 ¥1000')"><div class="amt">¥1000</div><div class="gift bonus">+ ¥80 赠送</div></div>
<div class="recharge-card" onclick="Shell.toast('选择 ¥3000')"><div class="amt">¥3000</div><div class="gift bonus">+ ¥300 赠送</div></div>
</div>
<div style="display:flex; gap:10px; margin-top:14px;">
<input class="input" placeholder="自定义金额(最低 ¥50)" style="flex:1;">
<button class="btn btn-primary" onclick="Shell.toast('微信支付', '¥500 · WechatPay')">微信支付 ¥500</button>
<button class="btn" onclick="Shell.toast('支付宝', '¥500 · Alipay')">支付宝</button>
</div>
</div>
<div style="display:flex; align-items:baseline; margin-bottom:12px;">
<h2 style="font-size:15px; font-weight:600;">消费明细</h2>
<span class="spacer"></span>
<div class="hstack">
<button class="chip" style="height:28px; font-size:12px;">近 30 天</button>
<button class="chip" style="height:28px; font-size:12px;">导出</button>
</div>
</div>
<table class="t bills">
<thead>
<tr><th>时间</th><th>项目 / 类型</th><th>详情</th><th style="text-align:right;">金额</th></tr>
</thead>
<tbody>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.09 14:08</td><td>补水面膜 · v3</td><td class="muted">故事板 image-2 · 1 次</td><td style="text-align:right;" class="neg">-¥0.45</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.09 14:02</td><td>补水面膜 · v3</td><td class="muted">脚本 LLM · 2.4k tokens</td><td style="text-align:right;" class="neg">-¥0.04</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.09 13:38</td><td>补水面膜 · v3</td><td class="muted">基础资产 · 5 张图</td><td style="text-align:right;" class="neg">-¥1.05</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.08 18:21</td><td>透真防晒 · 通勤对比</td><td class="muted">视频片段 · 6 镜</td><td style="text-align:right;" class="neg">-¥1.20</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.08 11:02</td><td>充值</td><td class="muted">微信支付 · <span class="ref">TX2024050811021Z</span></td><td style="text-align:right;" class="pos">+¥500.00</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.07 20:14</td><td>蓝牙耳机 · 开箱</td><td class="muted">视频片段 · 5 镜(1 镜重跑不扣)</td><td style="text-align:right;" class="neg">-¥0.94</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.07 15:48</td><td>咖啡冻干 · 早八</td><td class="muted">故事板生成失败 · <span style="color:var(--ink-3);">不扣费</span></td><td style="text-align:right;" class="muted-2">¥0.00</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.06 10:30</td><td>瑜伽裤 · 通勤穿搭</td><td class="muted">项目导出 · 1 次</td><td style="text-align:right;" class="neg">-¥3.20</td></tr>
</tbody>
</table>
</div>
<div>
<div class="pane">
<h3>本月消费分布</h3>
<div class="usage-line"><span>视频片段(Seedance)</span><span class="v">¥98.40</span></div>
<div class="usage-bar"><span style="width:60%; background:var(--orange);"></span></div>
<div class="usage-line"><span>故事板(image-2)</span><span class="v">¥36.00</span></div>
<div class="usage-bar"><span style="width:22%; background:var(--green);"></span></div>
<div class="usage-line"><span>基础资产</span><span class="v">¥21.00</span></div>
<div class="usage-bar"><span style="width:13%; background:var(--ink-2);"></span></div>
<div class="usage-line"><span>脚本 LLM</span><span class="v">¥7.20</span></div>
<div class="usage-bar"><span style="width:5%; background:var(--ink-3);"></span></div>
<div class="divider"></div>
<div class="usage-line" style="font-weight:600;"><span>合计</span><span class="v">¥162.60</span></div>
</div>
<div class="pane">
<h3>扣费规则</h3>
<div class="rule-list">
<strong>① 失败不扣</strong>:模型超时、内容审核拦截、生成异常一律不扣费。<br>
<strong>② 用户重跑不扣首次</strong>:第一次重跑保留原扣费,第二次起按次结算。<br>
<strong>③ 仅在你点击 <span class="mono">[ 确认通过 ]</span> 时入账</strong><br>
<strong>④ 导出不再扣费</strong>,所有 token 已在过程中结算。
</div>
</div>
<div class="pane">
<h3>开发票</h3>
<div style="font-size:12.5px; color:var(--ink-2); margin-bottom:10px;">本月可开发票额度:<strong style="color:var(--ink);">¥162.60</strong></div>
<button class="btn" style="width:100%;" onclick="Shell.toast('申请发票', '/billing/invoice')">申请发票</button>
</div>
</div>
</div>
</div>
<script src="assets/shell.js"></script>
<script>Shell.render({ active: 'account', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '账户' }] });</script>
</body>
</html>

546
v1/assets/restraint.css Normal file
View File

@ -0,0 +1,546 @@
/* ============================================================
·Studio · Restraint 风格(Firecrawl-inspired)
适用所有页面 · 米白底 + 单橙 accent + 0 圆角硬边容器
============================================================ */
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #FAF9F5;
--bg-soft: #F4F2EC;
--card: #FFFFFF;
--border: #E9E5DB;
--border-soft: #EFEBE0;
--ink: #15140F;
--ink-2: #5A584F;
--ink-3: #9C988C;
--ink-4: #C8C4B8;
--orange: #E55B26;
--orange-soft: #FAE8DC;
--orange-tint: #FFF4ED;
--orange-hover: #D04E1F;
--green: #3F6B3F;
--green-bg: #EAF2EA;
--green-bd: #D5E5D5;
--red: #B33A2A;
--red-bg: #FBEBE7;
--red-bd: #F2D6CE;
}
html, body {
background: var(--bg);
color: var(--ink);
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', -apple-system, sans-serif;
font-size: 13px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
button { font: inherit; cursor: pointer; border: 0; background: none; color: inherit; }
input, textarea, select { font: inherit; color: inherit; outline: none; }
img, svg, video { display: block; max-width: 100%; }
.num, .tnum { font-variant-numeric: tabular-nums; }
.mono { font-family: 'JetBrains Mono', monospace; }
.muted { color: var(--ink-2); }
.muted-2 { color: var(--ink-3); }
.spacer { flex: 1; }
.hstack { display: flex; align-items: center; gap: 8px; }
.vstack { display: flex; flex-direction: column; gap: 8px; }
.divider { height: 1px; background: var(--border); margin: 14px 0; }
/* ─── App shell ─── */
.app { display: grid; grid-template-columns: 248px 1fr; min-height: 100vh; }
/* ─── Sidebar ─── */
aside.sidebar {
padding: 18px 14px;
border-right: 1px solid var(--border);
background: var(--bg);
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.brand { display: flex; align-items: center; gap: 10px; padding: 6px 8px 14px; }
.flame { width: 22px; height: 22px; color: var(--orange); }
.flame svg { width: 100%; height: 100%; }
.brand .name { font-weight: 700; font-size: 18px; letter-spacing: -.012em; }
.search-box {
display: flex; align-items: center; gap: 8px;
padding: 9px 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 9px;
color: var(--ink-3);
margin-bottom: 14px;
cursor: text;
}
.search-box svg { width: 14px; height: 14px; }
.search-box input { flex: 1; border: 0; background: transparent; font-size: 13px; color: var(--ink); }
.search-box input::placeholder { color: var(--ink-3); }
.search-box .kbd {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
background: var(--bg-soft);
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--border-soft);
}
.nav-section { font-size: 10.5px; color: var(--ink-3); padding: 14px 12px 6px; letter-spacing: .08em; text-transform: uppercase; font-weight: 600; }
nav { display: flex; flex-direction: column; gap: 1px; }
nav a {
display: flex; align-items: center; gap: 11px;
padding: 8px 12px;
color: var(--ink-2);
font-size: 13.5px; font-weight: 500;
border-radius: 7px;
cursor: pointer;
user-select: none;
}
nav a:hover { background: var(--bg-soft); color: var(--ink); }
nav a.active { background: var(--orange-tint); color: var(--orange); }
nav a svg { width: 14px; height: 14px; opacity: .85; }
nav a.active svg { opacity: 1; }
nav a .pill-mini {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 9.5px; font-weight: 600;
padding: 2px 6px;
background: var(--card);
color: var(--ink-3);
border: 1px solid var(--border);
border-radius: 999px;
letter-spacing: .04em;
}
nav a.disabled { color: var(--ink-4); cursor: not-allowed; }
nav a.disabled:hover { background: transparent; color: var(--ink-4); }
.aside-foot { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
.user {
display: flex; align-items: center; gap: 9px;
padding: 8px;
border-radius: 8px;
cursor: pointer;
}
.user:hover { background: var(--bg-soft); }
.user .av {
width: 24px; height: 24px;
border-radius: 6px;
background: var(--ink);
color: #FFF;
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 11px;
}
.user .em { font-size: 12.5px; }
/* ─── Main + grid background ─── */
main { position: relative; overflow: hidden; background: var(--bg); }
.grid-bg {
position: absolute; inset: 0; pointer-events: none;
background-image:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><g stroke='%23B8B3A4' stroke-width='1' fill='none'><path d='M-5 0 L5 0 M0 -5 L0 5'/><path d='M235 0 L245 0 M240 -5 L240 5'/><path d='M-5 240 L5 240 M0 235 L0 245'/><path d='M235 240 L245 240 M240 235 L240 245'/></g></svg>"),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='60' height='60'><circle cx='60' cy='60' r='0.9' fill='%23CFCABB'/></svg>"),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><g stroke='%23E2DED2' stroke-width='1' fill='none' stroke-dasharray='1.5 4'><path d='M240 0 L240 240'/><path d='M0 240 L240 240'/></g></svg>");
background-size: 240px 240px, 60px 60px, 240px 240px;
mask-image: radial-gradient(ellipse 95% 80% at 50% 35%, #000 25%, transparent 95%);
-webkit-mask-image: radial-gradient(ellipse 95% 80% at 50% 35%, #000 25%, transparent 95%);
}
.scatter { position: absolute; font-family: 'JetBrains Mono', monospace; font-size: 8.5px; line-height: 1.05; color: var(--ink-4); white-space: pre; pointer-events: none; opacity: .8; letter-spacing: .04em; }
.tag-corner { position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 10.5px; letter-spacing: .06em; pointer-events: none; opacity: .85; z-index: 1; }
.sq-mark { position: absolute; width: 5px; height: 5px; background: var(--ink-3); opacity: .55; pointer-events: none; }
/* ─── Topbar ─── */
.topbar {
display: flex; align-items: center; gap: 12px;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg);
position: relative; z-index: 2;
}
.crumbs { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-3); }
.crumbs .sep { color: var(--ink-4); }
.crumbs .here { color: var(--ink); font-weight: 500; }
.crumbs a:hover { color: var(--ink); }
.topbar .right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.balance-chip {
display: inline-flex; align-items: center; gap: 7px;
padding: 6px 12px 6px 10px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12.5px;
color: var(--ink-2);
cursor: pointer;
}
.balance-chip:hover { background: var(--bg-soft); }
.balance-chip strong { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }
.balance-chip svg { width: 13px; height: 13px; color: var(--orange); }
.icon-btn {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
background: var(--card);
border: 1px solid var(--border);
border-radius: 9px;
color: var(--ink-2);
cursor: pointer;
position: relative;
}
.icon-btn:hover { background: var(--bg-soft); color: var(--ink); }
.icon-btn svg { width: 15px; height: 15px; }
.icon-btn .dot-noti { position: absolute; top: 8px; right: 9px; width: 7px; height: 7px; border-radius: 50%; background: var(--orange); border: 1.5px solid var(--card); }
/* ─── Content ─── */
.content { padding: 36px 48px 60px; position: relative; z-index: 1; max-width: 1480px; }
.page-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 28px; gap: 16px; flex-wrap: wrap; }
.page-head h1 { font-size: 26px; font-weight: 600; letter-spacing: -.018em; line-height: 1.2; }
.page-head .sub { font-size: 13.5px; color: var(--ink-2); margin-top: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.page-head .sub .mono { font-family: 'JetBrains Mono', monospace; font-size: 11.5px; color: var(--ink-3); }
.page-head .actions { display: flex; gap: 8px; align-items: center; }
.section-h { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 12px; }
.section-h h2 { font-size: 15px; font-weight: 600; letter-spacing: -.01em; }
.section-h .more { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; cursor: pointer; }
.section-h .more:hover { color: var(--orange); }
/* ─── Buttons ─── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 14px;
border-radius: 9px;
font-size: 13px; font-weight: 500;
border: 1px solid var(--border);
background: var(--card);
color: var(--ink);
cursor: pointer;
white-space: nowrap;
font-family: inherit;
}
.btn:hover { background: var(--bg-soft); }
.btn:disabled, .btn.disabled { color: var(--ink-3); background: var(--bg-soft); cursor: not-allowed; }
.btn svg { width: 13px; height: 13px; }
.btn-primary {
background: var(--orange);
color: #FFF;
border-color: var(--orange);
font-weight: 600;
padding: 8px 16px;
}
.btn-primary:hover { background: var(--orange-hover); border-color: var(--orange-hover); }
.btn-primary:disabled { background: var(--bg-soft); color: var(--ink-3); border-color: var(--border); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--ink-2); }
.btn-ghost:hover { background: var(--bg-soft); color: var(--ink); }
.btn-sm { padding: 5px 11px; font-size: 12px; border-radius: 7px; }
.btn-lg { padding: 10px 18px; font-size: 13.5px; }
/* ─── Pills ─── */
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11.5px; font-weight: 500;
border: 1px solid var(--border);
background: var(--card);
color: var(--ink-2);
white-space: nowrap;
}
.pill.info { background: var(--orange-tint); color: var(--orange); border-color: var(--orange-soft); }
.pill.ok { background: var(--green-bg); color: var(--green); border-color: var(--green-bd); }
.pill.err { background: var(--red-bg); color: var(--red); border-color: var(--red-bd); }
.pill.neutral { background: var(--bg-soft); color: var(--ink-2); border-color: var(--border); }
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
/* ─── Cards / containers (0 圆角硬边) ─── */
.card-hard {
background: var(--card);
border: 1px solid var(--border);
position: relative;
}
.card-hard.with-corners::before, .card-hard.with-corners::after,
.with-corners .corner-tr, .with-corners .corner-bl {
position: absolute;
color: var(--ink-3);
font-family: 'JetBrains Mono', monospace;
font-size: 13px; line-height: 1;
}
.card-hard.with-corners::before { content: '+'; top: -8px; left: -8px; }
.card-hard.with-corners::after { content: '+'; bottom: -8px; right: -8px; }
.with-corners .corner-tr { top: -8px; right: -8px; }
.with-corners .corner-bl { bottom: -8px; left: -8px; }
/* ─── Stats (KPI bar) ─── */
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
background: var(--card);
border: 1px solid var(--border);
position: relative;
margin-bottom: 32px;
}
.stat {
padding: 22px 24px;
border-right: 1px solid var(--border);
position: relative;
cursor: pointer;
transition: background .15s;
}
.stat:hover { background: var(--bg-soft); }
.stat:last-child { border-right: 0; }
.stat .lbl { font-size: 12.5px; color: var(--ink-3); font-weight: 500; display: flex; align-items: center; gap: 6px; }
.stat .lbl .badge {
font-family: 'JetBrains Mono', monospace;
font-size: 9.5px; color: var(--ink-3);
background: var(--bg-soft);
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--border-soft);
}
.stat .v { font-size: 30px; font-weight: 600; letter-spacing: -.02em; line-height: 1; margin-top: 14px; font-variant-numeric: tabular-nums; }
.stat .v small { font-size: 14px; color: var(--ink-3); font-weight: 500; margin-left: 2px; }
.stat .delta { font-family: 'JetBrains Mono', monospace; font-size: 11px; margin-top: 8px; color: var(--ink-3); letter-spacing: .02em; }
.stat .delta.up { color: var(--green); }
.stat .bar { height: 5px; background: var(--bg-soft); border-radius: 3px; margin-top: 12px; overflow: hidden; }
.stat .bar > span { display: block; height: 100%; background: var(--orange); border-radius: 3px; }
.stat .sub { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); margin-top: 8px; letter-spacing: .02em; }
/* ─── Form fields ─── */
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
.field-label { font-size: 12.5px; font-weight: 500; color: var(--ink); }
.field-label .req { color: var(--red); margin-left: 2px; }
.field-hint { font-size: 12px; color: var(--ink-3); }
.input, .textarea, .select {
height: 36px;
padding: 0 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 9px;
font-size: 13px;
width: 100%;
font-family: inherit;
color: var(--ink);
}
.input:focus, .textarea:focus, .select:focus { border-color: var(--orange); box-shadow: 0 0 0 3px rgba(229, 91, 38, 0.10); }
.input::placeholder, .textarea::placeholder { color: var(--ink-3); }
.textarea { height: auto; min-height: 80px; padding: 10px 12px; line-height: 1.55; resize: vertical; }
.select { appearance: none; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 16 16' fill='none'><path d='M4 6l4 4 4-4' stroke='%239C988C' stroke-width='1.4'/></svg>"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px; }
/* ─── Tabs ─── */
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
.tab { padding: 9px 14px; font-size: 13px; color: var(--ink-2); border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; font-weight: 500; user-select: none; }
.tab:hover { color: var(--ink); }
.tab.active { color: var(--orange); border-bottom-color: var(--orange); }
.tab .count { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); margin-left: 6px; padding: 1px 6px; background: var(--bg-soft); border-radius: 4px; }
.tab.active .count { background: var(--orange-tint); color: var(--orange); }
/* ─── Filter chips ─── */
.chip {
height: 32px; padding: 0 12px;
border: 1px solid var(--border);
background: var(--card);
border-radius: 8px;
font-size: 12.5px;
color: var(--ink-2);
display: inline-flex; align-items: center; gap: 6px;
cursor: pointer;
font-family: inherit;
}
.chip:hover { background: var(--bg-soft); }
.chip.active { border-color: var(--orange); color: var(--orange); background: var(--orange-tint); }
.chip svg { width: 12px; height: 12px; }
/* ─── Toolbar ─── */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
.toolbar .search-inline {
position: relative; flex: 1; max-width: 320px;
}
.toolbar .search-inline svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--ink-3); width: 14px; height: 14px; }
.toolbar .search-inline input { padding-left: 34px; }
/* ─── Progress (5 段流水线) ─── */
.prog { display: flex; gap: 3px; }
.prog span { width: 18px; height: 5px; border-radius: 2px; background: var(--bg-soft); }
.prog span.done { background: var(--ink-2); }
.prog span.cur { background: var(--orange); }
.prog span.fail { background: var(--red); }
/* ─── Table ─── */
table.t {
width: 100%; border-collapse: collapse;
background: var(--card);
border: 1px solid var(--border);
}
table.t thead th {
text-align: left;
font-size: 11.5px;
font-weight: 500;
color: var(--ink-3);
padding: 12px 14px;
background: var(--bg-soft);
border-bottom: 1px solid var(--border);
letter-spacing: .04em;
text-transform: uppercase;
font-family: 'JetBrains Mono', monospace;
}
table.t tbody td {
padding: 14px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
table.t tbody tr:last-child td { border-bottom: 0; }
table.t tbody tr { cursor: pointer; }
table.t tbody tr:hover { background: var(--bg-soft); }
/* ─── Placeholder thumb ─── */
.placeholder {
background:
repeating-linear-gradient(135deg, rgba(0,0,0,0.025) 0 1px, transparent 1px 12px),
var(--bg-soft);
border: 1px solid var(--border-soft);
display: grid; place-items: center;
color: var(--ink-3);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: .04em;
user-select: none;
overflow: hidden;
position: relative;
text-align: center;
padding: 6px;
}
.placeholder .ph-frame {
background: rgba(255, 255, 255, 0.92);
border: 1px solid var(--border);
padding: 3px 8px;
font-size: 10.5px;
color: var(--ink-2);
font-weight: 500;
}
/* ─── Toast ─── */
.toast {
position: fixed; bottom: 24px; right: 24px;
background: var(--card);
border: 1px solid var(--border);
padding: 12px 16px;
display: flex; align-items: center; gap: 10px;
box-shadow: 0 4px 20px rgba(21, 20, 15, 0.06);
transform: translateX(420px);
transition: transform .3s cubic-bezier(0.34, 1.56, 0.64, 1);
z-index: 1000;
min-width: 240px;
}
.toast.show { transform: translateX(0); }
.toast .ic-t {
width: 24px; height: 24px;
background: var(--orange-tint);
color: var(--orange);
border: 1px solid var(--orange-soft);
display: grid; place-items: center;
flex-shrink: 0;
}
.toast .ic-t svg { width: 12px; height: 12px; }
.toast .txt { font-size: 12.5px; color: var(--ink); }
.toast .txt .mono { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); display: block; margin-top: 2px; letter-spacing: .02em; }
/* ─── Modal ─── */
.modal-bg {
position: fixed; inset: 0;
background: rgba(21, 20, 15, .42);
display: none; align-items: center; justify-content: center;
z-index: 999;
opacity: 0;
transition: opacity .2s;
}
.modal-bg.show { display: flex; opacity: 1; }
.modal {
background: var(--card);
border: 1px solid var(--border);
max-width: 480px; width: 90%;
position: relative;
transform: scale(.96);
transition: transform .25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-bg.show .modal { transform: scale(1); }
.modal::before, .modal::after { content: '+'; position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 13px; line-height: 1; }
.modal::before { top: -8px; left: -8px; }
.modal::after { bottom: -8px; right: -8px; }
.modal .corner-tr, .modal .corner-bl { position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 13px; line-height: 1; }
.modal .corner-tr { top: -8px; right: -8px; }
.modal .corner-bl { bottom: -8px; left: -8px; }
.modal-h { padding: 20px 24px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
.modal-h .ic-m { width: 36px; height: 36px; background: var(--orange-tint); color: var(--orange); border: 1px solid var(--orange-soft); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.modal-h .ic-m svg { width: 17px; height: 17px; }
.modal-h .ti { font-size: 15px; font-weight: 600; }
.modal-h .ti span { display: block; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); font-weight: 400; margin-top: 3px; letter-spacing: .02em; }
.modal-b { padding: 18px 24px; font-size: 13px; color: var(--ink-2); line-height: 1.7; }
.modal-b .mono-acc { font-family: 'JetBrains Mono', monospace; color: var(--orange); background: var(--orange-tint); padding: 1px 5px; font-size: 11.5px; border-radius: 3px; }
.modal-f { padding: 14px 24px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
/* ─── Drawer ─── */
.drawer-bg {
position: fixed; inset: 0;
background: rgba(21, 20, 15, .32);
display: none;
z-index: 90;
}
.drawer-bg.show { display: block; }
.drawer {
position: fixed; right: 0; top: 0; bottom: 0;
width: 540px; max-width: 100vw;
background: var(--card);
border-left: 1px solid var(--border);
z-index: 95;
transform: translateX(100%);
transition: transform .25s cubic-bezier(.32, .72, 0, 1);
display: flex; flex-direction: column;
}
.drawer.show { transform: translateX(0); }
.drawer-h {
padding: 18px 24px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center;
}
.drawer-h h3 { font-size: 16px; font-weight: 600; }
.drawer-h .x {
margin-left: auto; width: 28px; height: 28px;
border-radius: 6px;
display: grid; place-items: center;
color: var(--ink-2); cursor: pointer;
}
.drawer-h .x:hover { background: var(--bg-soft); color: var(--ink); }
.drawer-b { padding: 20px 24px; overflow-y: auto; flex: 1; }
.drawer-f {
padding: 14px 24px;
border-top: 1px solid var(--border);
display: flex; gap: 8px; justify-content: flex-end;
background: var(--bg-soft);
}
/* ─── Scrollbar ─── */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--ink-3); }
/* ─── Responsive ─── */
@media (max-width: 1100px) {
.app { grid-template-columns: 1fr; }
aside.sidebar { display: none; }
.stats { grid-template-columns: repeat(2, 1fr); }
.stat:nth-child(2) { border-right: 0; }
.stat:nth-child(1), .stat:nth-child(2) { border-bottom: 1px solid var(--border); }
.content { padding: 24px; }
}
/* ─── Spinner ─── */
.spinner {
width: 18px; height: 18px;
border: 2px solid var(--border);
border-top-color: var(--orange);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

182
v1/assets/shell.js Normal file
View File

@ -0,0 +1,182 @@
/* ============================================================
·Studio · Shell renderer
渲染 sidebar / topbar / 网格背景装饰 / Toast / Modal helpers
每个页面调用 Shell.render({ active, crumbs, balance, topActions })
============================================================ */
const NAV = [
{
id: 'dashboard', label: '工作台', href: 'index.html',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 12 12 3l9 9"/><path d="M5 10v10h14V10"/></svg>'
},
{
id: 'products', label: '商品库', href: 'products.html', badge: '12',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><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>'
},
{
id: 'projects', label: '视频项目', href: 'projects.html',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="16"/><path d="M7 4v16M16 4v16M3 9h18M3 15h18"/></svg>'
},
{
id: 'library', label: '资产库', href: 'library.html',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="6" height="16"/><rect x="11" y="4" width="4" height="16"/><rect x="17" y="6" width="4" height="14"/></svg>'
},
{
id: 'account', label: '账户', href: 'account.html',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 14h2"/></svg>'
},
{
id: 'settings', label: '设置', href: '#',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8 2 2 0 0 1-2.8 2.8 1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5 2 2 0 0 1-4 0 1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3 2 2 0 0 1-2.8-2.8 1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1 2 2 0 0 1 0-4 1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8 2 2 0 0 1 2.8-2.8 1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5 2 2 0 0 1 4 0 1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3 2 2 0 0 1 2.8 2.8 1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1 2 2 0 0 1 0 4 1.7 1.7 0 0 0-1.5 1.1Z"/></svg>'
}
];
const TEAM_NAV = {
label: '团队', icon:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="9" cy="8" 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>',
badge: 'V1.5'
};
window.Shell = {
render({ active = '', crumbs = [], balance = '¥327.40', topActions = '' } = {}) {
const navHtml = NAV.map(n => `
<a href="${n.href}" class="${active === n.id ? 'active' : ''}">
${n.icon}
<span>${n.label}</span>
${n.badge ? `<span class="pill-mini">${n.badge}</span>` : ''}
</a>
`).join('');
const sidebar = `
<aside class="sidebar">
<div class="brand">
<div class="flame"><svg viewBox="0 0 24 24" fill="currentColor"><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 class="name">·Studio</div></div>
</div>
<div class="search-box" onclick="document.getElementById('global-search').focus()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="global-search" placeholder="搜索"/>
<span class="kbd">K</span>
</div>
<div class="nav-section">主要</div>
<nav>${navHtml}</nav>
<div class="nav-section">协作</div>
<nav>
<a class="disabled" title="V1.5 上线,敬请期待">
${TEAM_NAV.icon}
<span>${TEAM_NAV.label}</span>
<span class="pill-mini">${TEAM_NAV.badge}</span>
</a>
</nav>
<div class="aside-foot">
<div class="user" onclick="Shell.toast('账户菜单', 'li@shop.com')">
<div class="av"></div>
<div class="em">小李的店</div>
</div>
</div>
</aside>
`;
const crumbHtml = crumbs.length ? `
<div class="crumbs">
${crumbs.map((c, i) => {
const last = i === crumbs.length - 1;
const sep = i > 0 ? '<span class="sep">/</span>' : '';
if (last) return `${sep}<span class="here">${c.label}</span>`;
return `${sep}<a href="${c.href || '#'}">${c.label}</a>`;
}).join('')}
</div>
` : '';
const topbar = `
<header class="topbar">
${crumbHtml}
<div class="right">
<span class="balance-chip" onclick="location.href='account.html'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
余额 <strong>${balance}</strong>
</span>
<button class="icon-btn" onclick="Shell.toast('通知中心', '3 条未读')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21a2 2 0 0 0 4 0"/></svg>
<span class="dot-noti"></span>
</button>
${topActions}
</div>
</header>
`;
const decorations = `
<div class="grid-bg"></div>
<pre class="scatter" style="top:96px;left:280px"> · · +
· +XX+
+XXXX·
+X· </pre>
<pre class="scatter" style="top:340px;right:96px">+ · ·
XX· ·
·XXXX·+
·++· </pre>
<pre class="scatter" style="bottom:160px;left:42%"> · +
+·XX·
·X+ ·
· </pre>
<pre class="scatter" style="top:580px;left:60px"> +X·
·XX·
+·X·+</pre>
<span class="sq-mark" style="top:238px;left:478px"></span>
<span class="sq-mark" style="top:478px;left:1198px"></span>
<span class="sq-mark" style="bottom:300px;left:238px"></span>
<span class="sq-mark" style="top:718px;right:240px"></span>
<span class="tag-corner" style="top:158px;left:34px">[ 200 OK ]</span>
<span class="tag-corner" style="top:158px;right:34px">[ /v2 ]</span>
<span class="tag-corner" style="bottom:36px;left:34px">[ .MP4 · 9:16 ]</span>
<span class="tag-corner" style="bottom:36px;right:34px">[ STUDIO ]</span>
`;
const toastHtml = `
<div class="toast" id="__toast">
<div class="ic-t"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></div>
<div class="txt" id="__toast-txt">操作成功<span class="mono">[ 200 OK ]</span></div>
</div>
`;
const app = document.createElement('div');
app.className = 'app';
app.innerHTML = sidebar + `<main>${decorations}${topbar}<div class="content" id="page-content"></div></main>`;
const src = document.getElementById('page');
document.body.prepend(app);
if (src) {
document.getElementById('page-content').innerHTML = src.innerHTML;
src.remove();
}
document.body.insertAdjacentHTML('beforeend', toastHtml);
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
document.getElementById('global-search')?.focus();
}
});
},
toast(text, mono) {
const t = document.getElementById('__toast');
const txt = document.getElementById('__toast-txt');
if (!t || !txt) return;
txt.innerHTML = text + (mono ? `<span class="mono">[ ${mono} ]</span>` : '');
t.classList.add('show');
clearTimeout(this._tt);
this._tt = setTimeout(() => t.classList.remove('show'), 2400);
},
openModal(id) { document.getElementById(id)?.classList.add('show'); },
closeModal(id) { document.getElementById(id)?.classList.remove('show'); },
openDrawer(id) {
document.getElementById(id)?.classList.add('show');
document.getElementById(id + '-bg')?.classList.add('show');
},
closeDrawer(id) {
document.getElementById(id)?.classList.remove('show');
document.getElementById(id + '-bg')?.classList.remove('show');
}
};

173
v1/index.html Normal file
View File

@ -0,0 +1,173 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>工作台 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css">
<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 auto auto auto; align-items: center; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); cursor: pointer; }
.recent-row:last-child { border-bottom: 0; }
.recent-row:hover { background: var(--bg-soft); }
.recent-row .thumb { width: 54px; height: 70px; }
.recent-meta .name { font-weight: 600; font-size: 13.5px; color: var(--ink); }
.recent-meta .sub { font-size: 12px; color: var(--ink-3); margin-top: 3px; font-family: 'JetBrains Mono', monospace; letter-spacing: .01em; }
.shortcuts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.shortcut { background: var(--card); border: 1px solid var(--border); padding: 14px; display: flex; align-items: flex-start; gap: 11px; cursor: pointer; }
.shortcut:hover { background: var(--bg-soft); }
.shortcut .ic { width: 30px; height: 30px; background: var(--orange-tint); color: var(--orange); display: grid; place-items: center; border: 1px solid var(--orange-soft); flex-shrink: 0; }
.shortcut .ic svg { width: 15px; height: 15px; }
.shortcut .t { font-size: 13px; font-weight: 600; }
.shortcut .d { font-size: 11.5px; color: var(--ink-3); margin-top: 2px; font-family: 'JetBrains Mono', monospace; letter-spacing: .01em; }
.tip { background: var(--card); border: 1px dashed var(--border); padding: 14px 16px; font-size: 12.5px; color: var(--ink-2); line-height: 1.6; }
.tip strong { color: var(--ink); font-weight: 600; display: block; margin-bottom: 4px; }
.tip .mono { font-family: 'JetBrains Mono', monospace; color: var(--orange); background: var(--orange-tint); padding: 1px 5px; border-radius: 3px; 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(--ink)">3 个项目</b> 正在进行中</span>
</div>
</div>
<div class="actions">
<a class="btn" href="products.html">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
新建商品
</a>
<a class="btn btn-primary btn-lg" href="projects-new.html">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M12 5v14M5 12h14"/></svg>
新建项目
</a>
</div>
</div>
<div class="stats with-corners">
<span class="corner-tr">+</span><span class="corner-bl">+</span>
<a class="stat" href="projects.html">
<div class="lbl">总项目 <span class="badge">ALL</span></div>
<div class="v">12</div>
<div class="delta up">↑ 本月 +3</div>
</a>
<a class="stat" href="projects.html">
<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">
<div class="lbl">本月成片 <span class="badge">DONE</span></div>
<div class="v">8</div>
<div class="delta up">↑ 较上月 +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 · 12 ] →</a>
</div>
<div class="card-hard">
<a class="recent-row" href="pipeline.html#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">补水面膜 / AI 全生 / 6 镜</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#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">南卡 Lite Pro / 自带脚本 / 5 镜</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#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">滋啦速食 / 一句话 / 4 镜</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#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">透真防晒 / AI 全生 / 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#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"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="m3 7 9-4 9 4-9 4-9-4z"/><path d="m3 12 9 4 9-4"/></svg></div>
<div><div class="t">商品库</div><div class="d">12 SKU</div></div>
</a>
<a class="shortcut" href="library.html">
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="6" height="16"/><rect x="11" y="4" width="4" height="16"/><rect x="17" y="6" width="4" height="14"/></svg></div>
<div><div class="t">资产库</div><div class="d">人 8 · 景 14 · 片 8</div></div>
</a>
<a class="shortcut" href="account.html">
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><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><div class="t">充值</div><div class="d">¥327.40</div></div>
</a>
<a class="shortcut" href="projects.html">
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="16"/><path d="M7 4v16M16 4v16M3 9h18M3 15h18"/></svg></div>
<div><div class="t">所有项目</div><div class="d">12 个</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/shell.js"></script>
<script>Shell.render({ active: 'dashboard', crumbs: [{ label: '工作台' }] });</script>
</body>
</html>

120
v1/library.html Normal file
View File

@ -0,0 +1,120 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>资产库 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css">
<style>
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }
.asset-card { background: var(--card); border: 1px solid var(--border); cursor: pointer; transition: background .15s; }
.asset-card:hover { background: var(--bg-soft); border-color: var(--ink-3); }
.asset-thumb { aspect-ratio: 1; }
.asset-card.video .asset-thumb { aspect-ratio: 9/16; max-height: 280px; }
.asset-body { padding: 10px 12px; }
.asset-name { font-size: 13px; font-weight: 600; }
.asset-meta { font-size: 11px; color: var(--ink-3); margin-top: 3px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
</style>
</head>
<body>
<div id="page">
<div class="page-head">
<div>
<h1>资产库</h1>
<div class="sub"><span class="mono">// 跨项目复用 · 人 8 · 景 14 · 商 12 · 片 8</span></div>
</div>
<div class="actions">
<button class="btn" onclick="Shell.toast('上传资产', '/library/upload')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
上传资产
</button>
</div>
</div>
<div class="tabs">
<div class="tab active">人物 <span class="count">8</span></div>
<div class="tab">场景 <span class="count">14</span></div>
<div class="tab">商品图 <span class="count">12</span></div>
<div class="tab">成片 <span class="count">8</span></div>
<div class="tab">我的上传 <span class="count">3</span></div>
</div>
<div class="toolbar">
<div class="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input class="input" placeholder="搜索人物特征">
</div>
<button class="chip active">全部 <span class="mono" style="opacity:.7">8</span></button>
<button class="chip">女性</button>
<button class="chip">男性</button>
<button class="chip">25-30 岁</button>
<button class="chip">都市白领</button>
<span class="spacer"></span>
<button class="chip">最近使用 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
</div>
<div class="asset-grid">
<div class="asset-card" onclick="Shell.toast('查看资产', '林夕')">
<div class="placeholder asset-thumb"><span class="ph-frame">林夕 · 都市白领</span></div>
<div class="asset-body">
<div class="asset-name">林夕</div>
<div class="asset-meta">女 · 25-30 · 用过 4 次</div>
</div>
</div>
<div class="asset-card" onclick="Shell.toast('查看资产', '阿楠')">
<div class="placeholder asset-thumb"><span class="ph-frame">阿楠 · 同事女</span></div>
<div class="asset-body">
<div class="asset-name">阿楠</div>
<div class="asset-meta">女 · 25-30 · 用过 2 次</div>
</div>
</div>
<div class="asset-card" onclick="Shell.toast('查看资产', '小七')">
<div class="placeholder asset-thumb"><span class="ph-frame">小七 · 学生女</span></div>
<div class="asset-body">
<div class="asset-name">小七</div>
<div class="asset-meta">女 · 18-22 · 用过 3 次</div>
</div>
</div>
<div class="asset-card" onclick="Shell.toast('查看资产', '阿杰')">
<div class="placeholder asset-thumb"><span class="ph-frame">阿杰 · 通勤男</span></div>
<div class="asset-body">
<div class="asset-name">阿杰</div>
<div class="asset-meta">男 · 28-35 · 用过 2 次</div>
</div>
</div>
<div class="asset-card" onclick="Shell.toast('查看资产', '王姐')">
<div class="placeholder asset-thumb"><span class="ph-frame">妈妈 · 居家</span></div>
<div class="asset-body">
<div class="asset-name">妈妈 · 王姐</div>
<div class="asset-meta">女 · 38-45 · 用过 1 次</div>
</div>
</div>
<div class="asset-card" onclick="Shell.toast('查看资产', '阿强')">
<div class="placeholder asset-thumb"><span class="ph-frame">阿强 · 健身男</span></div>
<div class="asset-body">
<div class="asset-name">阿强</div>
<div class="asset-meta">男 · 22-28 · 用过 2 次</div>
</div>
</div>
<div class="asset-card" onclick="Shell.toast('查看资产', '小苏')">
<div class="placeholder asset-thumb"><span class="ph-frame">研究生 · 文艺女</span></div>
<div class="asset-body">
<div class="asset-name">小苏</div>
<div class="asset-meta">女 · 22-26 · 用过 1 次</div>
</div>
</div>
<div class="asset-card" onclick="Shell.toast('查看资产', '闺蜜组合')">
<div class="placeholder asset-thumb"><span class="ph-frame">闺蜜组合 · 双人</span></div>
<div class="asset-body">
<div class="asset-name">闺蜜组合</div>
<div class="asset-meta">双人 · 25-30 · 用过 1 次</div>
</div>
</div>
</div>
</div>
<script src="assets/shell.js"></script>
<script>Shell.render({ active: 'library', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '资产库' }] });</script>
</body>
</html>

495
v1/mockups/index.html Normal file
View File

@ -0,0 +1,495 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>新建商品 · 三版交互方案对比 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../assets/restraint.css">
<style>
body { background: var(--bg); padding: 40px 24px 80px; }
.wrap { max-width: 1200px; margin: 0 auto; }
.head { margin-bottom: 36px; padding-bottom: 24px; border-bottom: 1px solid var(--border); position: relative; }
.head::before { content: '+'; position: absolute; top: -8px; left: -8px; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.head h1 { font-size: 28px; font-weight: 700; letter-spacing: -.018em; }
.head .sub { font-size: 13.5px; color: var(--ink-2); margin-top: 8px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.head .sub .mono { font-family: 'JetBrains Mono', monospace; font-size: 11.5px; color: var(--ink-3); letter-spacing: .04em; }
.ctx-box {
background: var(--card); border: 1px solid var(--border); padding: 18px 22px;
margin-bottom: 28px; position: relative;
}
.ctx-box::before, .ctx-box::after { content: '+'; position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.ctx-box::before { top: -7px; left: -7px; }
.ctx-box::after { bottom: -7px; right: -7px; }
.ctx-box h3 { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
.ctx-box p { font-size: 12.5px; color: var(--ink-2); line-height: 1.7; }
.ctx-box .flow-line {
margin-top: 14px;
padding: 12px 14px;
background: var(--bg-soft);
border: 1px solid var(--border-soft);
font-family: 'JetBrains Mono', monospace; font-size: 11.5px;
color: var(--ink-2); letter-spacing: .02em;
line-height: 1.8;
}
.ctx-box .flow-line .arrow { color: var(--orange); margin: 0 6px; }
.versions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
.ver-card {
background: var(--card); border: 1px solid var(--border);
display: flex; flex-direction: column;
text-decoration: none; color: var(--ink);
transition: border-color .15s;
position: relative;
}
.ver-card:hover { border-color: var(--orange); }
.ver-card:hover::before, .ver-card:hover::after { color: var(--orange); }
.ver-card::before, .ver-card::after { content: '+'; position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; transition: color .15s; }
.ver-card::before { top: -7px; left: -7px; }
.ver-card::after { bottom: -7px; right: -7px; }
.ver-preview {
aspect-ratio: 4/3;
background:
repeating-linear-gradient(135deg, rgba(0,0,0,0.02) 0 1px, transparent 1px 12px),
var(--bg-soft);
border-bottom: 1px solid var(--border);
position: relative;
padding: 14px;
}
.ver-preview .mock { position: absolute; inset: 14px; background: var(--card); border: 1px solid var(--border); display: flex; flex-direction: column; }
/* Mock A: stepper */
.mock-A-top { padding: 8px; border-bottom: 1px solid var(--border); display: flex; justify-content: center; gap: 8px; }
.mock-A-top span { width: 12px; height: 12px; background: var(--bg-soft); border: 1px solid var(--border); }
.mock-A-top span:nth-child(2) { background: var(--orange); border-color: var(--orange); }
.mock-A-body { flex: 1; display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 10px; }
.mock-A-body div { background: var(--bg-soft); border: 1px solid var(--border); }
.mock-A-body div.sel { background: var(--orange-tint); border-color: var(--orange); }
.mock-A-foot { padding: 6px 10px; border-top: 1px solid var(--border); display: flex; justify-content: space-between; }
.mock-A-foot span { width: 28px; height: 8px; background: var(--bg-soft); border: 1px solid var(--border); }
.mock-A-foot span.primary { background: var(--orange); border-color: var(--orange); }
/* Mock B: accordion */
.mock-B-top { padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 4px; }
.mock-B-top .bar { flex: 1; height: 3px; background: var(--bg-soft); }
.mock-B-top .bar span { display: block; width: 50%; height: 100%; background: var(--orange); }
.mock-B-rows { flex: 1; display: flex; flex-direction: column; padding: 6px; gap: 4px; }
.mock-B-row { padding: 6px 8px; background: var(--bg-soft); border: 1px solid var(--border); display: flex; align-items: center; gap: 6px; }
.mock-B-row.done .num { background: var(--ink); color: #FFF; }
.mock-B-row.active { background: var(--orange-tint); border-color: var(--orange); padding: 8px; flex: 1; }
.mock-B-row .num { width: 14px; height: 14px; background: var(--card); border: 1px solid var(--border); font-size: 8px; display: grid; place-items: center; font-family: 'JetBrains Mono', monospace; }
.mock-B-row.active .num { background: var(--orange); color: #FFF; border-color: var(--orange); }
.mock-B-row .lbl { flex: 1; height: 6px; background: var(--card); border: 1px solid var(--border); }
/* Mock C: workbench */
.mock-C-top { padding: 6px 8px; border-bottom: 1px solid var(--border); }
.mock-C-top .ti { width: 60%; height: 6px; background: var(--bg-soft); }
.mock-C-body { flex: 1; display: grid; grid-template-columns: 36px 1fr 50px; gap: 0; }
.mock-C-side { background: var(--bg-soft); border-right: 1px solid var(--border); padding: 6px; display: flex; flex-direction: column; gap: 4px; }
.mock-C-side div { width: 100%; height: 8px; background: var(--card); border: 1px solid var(--border); }
.mock-C-side div.act { background: var(--orange); border-color: var(--orange); }
.mock-C-canvas { padding: 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
.mock-C-canvas div { background: var(--bg-soft); border: 1px solid var(--border); }
.mock-C-canvas div.sel { background: var(--orange-tint); border-color: var(--orange); }
.mock-C-controls { background: var(--bg-soft); border-left: 1px solid var(--border); padding: 6px; display: flex; flex-direction: column; gap: 4px; }
.mock-C-controls div { background: var(--card); border: 1px solid var(--border); height: 14px; }
.ver-info { padding: 18px 20px; }
.ver-info .badge {
display: inline-block;
font-family: 'JetBrains Mono', monospace; font-size: 10.5px;
color: var(--orange); background: var(--orange-tint); border: 1px solid var(--orange-soft);
padding: 2px 8px; letter-spacing: .04em;
margin-bottom: 10px;
}
.ver-info h2 { font-size: 16px; font-weight: 700; margin-bottom: 6px; }
.ver-info .tagline { font-size: 12.5px; color: var(--ink-2); line-height: 1.6; margin-bottom: 14px; }
.ver-info ul { list-style: none; padding: 0; }
.ver-info ul li { font-size: 11.5px; color: var(--ink-2); padding: 5px 0; display: flex; gap: 8px; align-items: flex-start; }
.ver-info ul li::before { content: ''; width: 5px; height: 5px; background: var(--orange); border-radius: 50%; margin-top: 7px; flex-shrink: 0; }
.ver-info .cta { margin-top: 16px; }
.ver-info .pros, .ver-info .cons { margin-top: 12px; padding: 10px 12px; font-size: 11.5px; line-height: 1.6; }
.ver-info .pros { background: var(--green-bg); border: 1px solid var(--green-bd); color: var(--green); }
.ver-info .cons { background: var(--bg-soft); border: 1px solid var(--border); color: var(--ink-2); }
.ver-info .pros strong, .ver-info .cons strong { font-family: 'JetBrains Mono', monospace; font-size: 10px; letter-spacing: .04em; display: block; margin-bottom: 3px; }
.compare-table {
margin-top: 36px;
background: var(--card); border: 1px solid var(--border);
position: relative;
}
.compare-table::before, .compare-table::after { content: '+'; position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.compare-table::before { top: -7px; left: -7px; }
.compare-table::after { bottom: -7px; right: -7px; }
.compare-table h3 { padding: 14px 18px; border-bottom: 1px solid var(--border); font-size: 14px; font-weight: 600; }
table.cmp { width: 100%; border-collapse: collapse; }
table.cmp th { text-align: left; padding: 12px 18px; background: var(--bg-soft); font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .04em; text-transform: uppercase; font-weight: 600; border-bottom: 1px solid var(--border); }
table.cmp td { padding: 14px 18px; border-bottom: 1px solid var(--border); font-size: 12.5px; vertical-align: top; }
table.cmp tr:last-child td { border-bottom: 0; }
table.cmp .row-h { font-weight: 600; color: var(--ink); }
table.cmp .stars { color: var(--orange); font-weight: 700; }
table.cmp .small { font-size: 11px; color: var(--ink-3); display: block; margin-top: 2px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
</style>
</head>
<body>
<div class="wrap">
<div class="head">
<h1>新建商品 · 三版交互方案对比</h1>
<div class="sub">
<span class="mono">// 流·Studio · AI 生成模式</span>
<span>·</span>
<span>三个独立 HTML,选定方案后再合并到 <a href="../products.html" style="color:var(--orange);">products.html</a></span>
</div>
</div>
<div class="ctx-box">
<h3>需求背景</h3>
<p>商家可能<strong>没有现成的商品图</strong>。需要在原有"上传图片"的基础上,新增一种「AI 生成」模式:商家上传一张随手拍的原图,AI 生成 4 张满意的头图(可重跑),选 1 张;然后选 AI 模特模板,生成 4 张模特上身参考图(可重跑),勾选满意的。</p>
<div class="flow-line">
[ 上传原图 + 信息 ] <span class="arrow"></span> [ AI 生成头图 · 4 选 1 ] <span class="arrow"></span> [ 选模特 + 生成上身图 · 4 选 N ] <span class="arrow"></span> [ 预览 · 创建 ]
</div>
</div>
<div class="versions">
<!-- ===== 方案 A ===== -->
<a class="ver-card" href="mockup-A.html">
<div class="ver-preview">
<div class="mock">
<div class="mock-A-top"><span></span><span></span><span></span><span></span></div>
<div class="mock-A-body">
<div class="sel"></div>
<div></div>
<div></div>
<div></div>
</div>
<div class="mock-A-foot"><span></span><span class="primary"></span></div>
</div>
</div>
<div class="ver-info">
<span class="badge">[ A · WIZARD ]</span>
<h2>分步向导</h2>
<p class="tagline">全屏 4 步,顶部 stepper,底部固定工具栏。每步只关注一件事,前进/后退清晰。</p>
<ul>
<li>跟「新建项目向导」一脉相承,商家熟悉</li>
<li>每步沉浸式,不被其他信息干扰</li>
<li>4 张候选图大网格 + 居中显示</li>
</ul>
<div class="pros">
<strong>// PROS</strong>
最经典最不容易迷路。新手友好,学习成本最低。
</div>
<div class="cons">
<strong>// CONS</strong>
不能轻松看到"前面已经选了什么",回头改要点上一步。
</div>
<div class="cta"><span class="btn btn-primary">查看 Demo →</span></div>
</div>
</a>
<!-- ===== 方案 B ===== -->
<a class="ver-card" href="mockup-B.html">
<div class="ver-preview">
<div class="mock">
<div class="mock-B-top"><div class="bar"><span></span></div></div>
<div class="mock-B-rows">
<div class="mock-B-row done"><span class="num"></span><span class="lbl"></span></div>
<div class="mock-B-row active"><span class="num"></span><span class="lbl"></span></div>
<div class="mock-B-row"><span class="num"></span><span class="lbl"></span></div>
<div class="mock-B-row"><span class="num"></span><span class="lbl"></span></div>
</div>
</div>
</div>
<div class="ver-info">
<span class="badge">[ B · ACCORDION ]</span>
<h2>单页折叠流</h2>
<p class="tagline">右侧抽屉,4 步从上到下流式排列。已完成的折叠成"摘要 + 编辑"按钮,当前步展开。</p>
<ul>
<li>能看到完整流程上下文</li>
<li>顶部模式切换:[我有商品图 / AI 生成] 一目了然</li>
<li>已选过的随时可以回去改,不丢失</li>
</ul>
<div class="pros">
<strong>// PROS</strong>
最完整,适合反复对比和回溯。能跟原有"我有图"模式天然兼容。
</div>
<div class="cons">
<strong>// CONS</strong>
页面信息密度略高,初次接触可能有"信息过载"感。
</div>
<div class="cta"><span class="btn btn-primary">查看 Demo →</span></div>
</div>
</a>
<!-- ===== 方案 C ===== -->
<a class="ver-card" href="mockup-C.html">
<div class="ver-preview">
<div class="mock">
<div class="mock-C-top"><div class="ti"></div></div>
<div class="mock-C-body">
<div class="mock-C-side">
<div></div><div class="act"></div><div></div><div></div>
</div>
<div class="mock-C-canvas">
<div class="sel"></div><div></div>
<div></div><div></div>
</div>
<div class="mock-C-controls">
<div></div><div></div><div></div>
</div>
</div>
</div>
</div>
<div class="ver-info">
<span class="badge">[ C · WORKBENCH ]</span>
<h2>双栏工作台</h2>
<p class="tagline">全屏覆盖。左侧步骤导航 + 已选素材,中央大预览图(超大网格),右侧操作面板 + 重新生成参数。</p>
<ul>
<li>视觉空间最大,选图体验最爽</li>
<li>右侧可调"背景风格 / 氛围"等参数后再重跑</li>
<li>左侧实时显示已选素材缩略图</li>
</ul>
<div class="pros">
<strong>// PROS</strong>
专业感最强,选图最清晰。参考 Pebblely / Booth AI 的成熟方案。
</div>
<div class="cons">
<strong>// CONS</strong>
覆盖整个屏幕,跟原有抽屉式新建商品形态差异大。学习成本略高。
</div>
<div class="cta"><span class="btn btn-primary">查看 Demo →</span></div>
</div>
</a>
</div>
<div class="compare-table">
<h3>横向对比</h3>
<table class="cmp">
<thead>
<tr>
<th style="width: 22%;">维度</th>
<th>A · 分步向导</th>
<th>B · 单页折叠流</th>
<th>C · 双栏工作台</th>
</tr>
</thead>
<tbody>
<tr>
<td class="row-h">傻瓜程度</td>
<td><span class="stars">★★★★★</span><span class="small">最简单 · 一次只看一件事</span></td>
<td><span class="stars">★★★★</span><span class="small">略复杂 · 但全程可见</span></td>
<td><span class="stars">★★★</span><span class="small">需要适应工作台布局</span></td>
</tr>
<tr>
<td class="row-h">选图视觉空间</td>
<td><span class="stars">★★★★</span><span class="small">大,但有页面边距</span></td>
<td><span class="stars">★★★</span><span class="small">中等,抽屉宽度限制</span></td>
<td><span class="stars">★★★★★</span><span class="small">最大 · 参考 Pebblely</span></td>
</tr>
<tr>
<td class="row-h">回头修改</td>
<td><span class="stars">★★★</span><span class="small">点上一步 / 顶部 stepper</span></td>
<td><span class="stars">★★★★★</span><span class="small">点已完成的"编辑"即可</span></td>
<td><span class="stars">★★★★</span><span class="small">点左侧 step 切换</span></td>
</tr>
<tr>
<td class="row-h">"我有图 / AI 生成"双模式融合</td>
<td>需要在第 1 步加分支选择</td>
<td><strong>顶部就有切换</strong>,无缝</td>
<td>需要在顶部或左侧加切换</td>
</tr>
<tr>
<td class="row-h">改原 products.html 的成本</td>
<td>中 · 替换抽屉 → 全屏向导</td>
<td><strong>低 · 抽屉宽度加大,内部改</strong></td>
<td>高 · 完全替换交互形态</td>
</tr>
<tr>
<td class="row-h">参考产品</td>
<td>稿定 AI 商品图 · 万相实验室</td>
<td>Notion 引导 · Linear 流程</td>
<td>Pebblely · Booth AI · 即梦</td>
</tr>
</tbody>
</table>
</div>
<div style="margin-top: 36px; padding: 18px 22px; background: var(--orange-tint); border: 1px solid var(--orange-soft); position: relative;">
<div style="font-family:'JetBrains Mono',monospace; font-size:10.5px; color:var(--orange); letter-spacing:.04em; margin-bottom:6px;">// MY RECOMMENDATION</div>
<p style="font-size:13px; color:var(--ink); line-height:1.7;">
<strong>建议优先选 B(单页折叠流)</strong> ——
用户说优先「最简单傻瓜」,但 A 的弱点是「我有图 / AI 生成」两条路要分支处理,流程会割裂;
B 在顶部直接放 [我有商品图 / AI 生成] 模式切换,**两种模式共用一套抽屉框架**,改起来也最省事。
C 视觉最爽但跟现有抽屉式新建商品差距太大,**留作 V1.5 升级方向**。
</p>
</div>
<!-- ============= 第二轮:C 方案的"模特挑选"细化 ============= -->
<div style="margin-top:48px; padding-bottom:24px; border-bottom:1px solid var(--border); position:relative;">
<div style="font-family:'JetBrains Mono',monospace; font-size:10.5px; color:var(--ink-3); letter-spacing:.08em; margin-bottom:8px;">// ROUND 2 · MODEL PICKER REFINEMENT</div>
<h2 style="font-size:22px; font-weight:700; letter-spacing:-.012em;">C 方案 · 模特挑选交互细化</h2>
<p style="font-size:13px; color:var(--ink-2); margin-top:8px; line-height:1.6;">
用户已选 C 方案。模特库规模 <strong>50+</strong> ,需要更重的展示空间(立绘网格 + 悬浮三视图 + 多筛选 + 多选)。
下面两版对比:把"选模特"独立成一个 step,还是放在原 Step 3 内部用子 Tab 切换。
</p>
</div>
<div class="versions" style="margin-top:24px; grid-template-columns: repeat(2, 1fr);">
<!-- ===== Step 1 ===== -->
<a class="ver-card" href="mockup-C-step1.html">
<div class="ver-preview">
<div class="mock">
<div class="mock-C-top"><div class="ti"></div></div>
<div class="mock-C-body">
<div class="mock-C-side">
<div></div><div></div><div class="act"></div><div></div><div></div>
</div>
<div class="mock-C-canvas" style="grid-template-columns:repeat(3,1fr); grid-template-rows:repeat(2,1fr);">
<div class="sel"></div><div></div><div></div>
<div></div><div class="sel"></div><div></div>
</div>
<div class="mock-C-controls">
<div></div><div style="height:24px;"></div><div></div>
</div>
</div>
</div>
</div>
<div class="ver-info">
<span class="badge">[ C-STEP1 · 5 STEPS ]</span>
<h2>方案 1 · 独立 Step</h2>
<p class="tagline">把「选模特」独立成第 3 步,流程从 4 步变 5 步。模特库占满整个 canvas,有完整筛选空间。</p>
<ul>
<li>左侧 sidebar 出现 NEW 标记,5 步清晰可见</li>
<li>顶部搜索 + 7-8 个 filter chips,多选无压力</li>
<li>50+ 模特滚动加载,适合大型素材库</li>
</ul>
<div class="pros">
<strong>// PROS</strong>
每步只做一件事,模特库展示空间最大,筛选/多选最从容。适合 50+ 大库。
</div>
<div class="cons">
<strong>// CONS</strong>
流程多一步,跟之前敲定的"4 步"轻量节奏冲突。"选模特→看效果"被切成两步,要点下一步才看到结果。
</div>
<div class="cta"><span class="btn btn-primary">查看 Demo →</span></div>
</div>
</a>
<!-- ===== Step 3 (sub-tab) ===== -->
<a class="ver-card" href="mockup-C-step3.html">
<div class="ver-preview">
<div class="mock">
<div class="mock-C-top"><div class="ti"></div></div>
<div class="mock-C-body">
<div class="mock-C-side">
<div></div><div></div><div class="act"></div><div></div>
</div>
<div style="display:flex; flex-direction:column; padding:0;">
<div style="display:flex; gap:0; border-bottom:1px solid var(--border); padding:4px 6px; background:var(--bg-soft);">
<div style="height:6px; flex:1; background:var(--orange); margin-right:2px;"></div>
<div style="height:6px; flex:1; background:var(--bg-soft); border:1px solid var(--border);"></div>
</div>
<div class="mock-C-canvas" style="grid-template-columns:repeat(3,1fr); grid-template-rows:repeat(2,1fr); flex:1;">
<div class="sel"></div><div></div><div></div>
<div></div><div class="sel"></div><div></div>
</div>
</div>
<div class="mock-C-controls">
<div></div><div style="height:24px;"></div><div></div>
</div>
</div>
</div>
</div>
<div class="ver-info">
<span class="badge">[ C-STEP3 · 4 STEPS + SUB-TAB ]</span>
<h2>方案 3 · 子步骤分屏</h2>
<p class="tagline">保持 4 步主流程,但 Step 3 内部分两个子 Tab:① 挑选模特 → ② 生成上身图。在同一个 Step 里递进。</p>
<ul>
<li>主 sidebar 4 个 step 不变,只在 active step 下展开 2 个子项</li>
<li>顶部 canvas 上方的子 Tab 条:① → ② 视觉递进</li>
<li>选完模特点"下一步"自动跳子 Tab 并触发生成</li>
<li>子 Tab 可来回切换,改模特立刻重新生成</li>
</ul>
<div class="pros">
<strong>// PROS</strong>
流程不变多。模特挑选 + 看效果在同一个 Step 内,有"递进"感。改模特 → 看新效果链路最短。
</div>
<div class="cons">
<strong>// CONS</strong>
子 Tab 比单 step 略复杂一点。主 Step 3 名字"模特上身图"略含糊(其实包含两件事)。
</div>
<div class="cta"><span class="btn btn-primary">查看 Demo →</span></div>
</div>
</a>
</div>
<div class="compare-table" style="margin-top:36px;">
<h3>横向对比 · C 方案模特挑选</h3>
<table class="cmp">
<thead>
<tr>
<th style="width: 24%;">维度</th>
<th>方案 1 · 独立 Step</th>
<th>方案 3 · 子步骤分屏</th>
</tr>
</thead>
<tbody>
<tr>
<td class="row-h">主流程步数</td>
<td>5 步</td>
<td><strong>4 步(子 Tab 内嵌)</strong></td>
</tr>
<tr>
<td class="row-h">模特库展示空间</td>
<td><span class="stars">★★★★★</span><span class="small">整个 canvas 都给模特库</span></td>
<td><span class="stars">★★★★★</span><span class="small">同样整个 canvas,只少一条 24 px 子 Tab 条</span></td>
</tr>
<tr>
<td class="row-h">"选模特 → 看上身图效果"链路</td>
<td>选 → 下一步 → 等生成 → 看效果(3 步)</td>
<td><strong>选 → 切子 Tab → 看效果(2 步,可反复切换)</strong></td>
</tr>
<tr>
<td class="row-h">改模特(已生成上身图后)</td>
<td>"上一步" 回 Step 3 → 改 → 再 "下一步" 回 Step 4</td>
<td><strong>点子 Tab ① → 改 → 子 Tab ② 自动重新生成</strong></td>
</tr>
<tr>
<td class="row-h">学习成本</td>
<td><span class="stars">★★★★★</span><span class="small">最熟悉 · 跟其他 step 一致</span></td>
<td><span class="stars">★★★★</span><span class="small">需要适应"主步骤里有子步骤"的概念</span></td>
</tr>
<tr>
<td class="row-h">侧边栏复杂度</td>
<td>5 个 step,均匀</td>
<td>4 个 step + Step 3 下展开 2 个子项(可折叠)</td>
</tr>
<tr>
<td class="row-h">参考产品</td>
<td>Adobe Express 模板挑选 · 阿里万相</td>
<td>Figma 工作区子模式 · Photoshop Workspace · 即梦的 Tab 切换</td>
</tr>
</tbody>
</table>
</div>
<div style="margin-top: 24px; padding: 18px 22px; background: var(--orange-tint); border: 1px solid var(--orange-soft); position: relative;">
<div style="font-family:'JetBrains Mono',monospace; font-size:10.5px; color:var(--orange); letter-spacing:.04em; margin-bottom:6px;">// MY RECOMMENDATION FOR ROUND 2</div>
<p style="font-size:13px; color:var(--ink); line-height:1.7;">
<strong>建议选方案 3(子步骤分屏)</strong> ——
模特库展示空间一样大(都是整个 canvas),但方案 3 把"挑模特→看上身图效果"做成可来回切换的子 Tab,**改模特时链路最短**(适合反复试不同模特对比效果);
流程数也保持 4 步,跟原 C 节奏一致。
方案 1 唯一胜出的场景是"用户根本不想改"的一次性流程,但电商商家选模特通常会反复试。
</p>
</div>
</div>
</body>
</html>

471
v1/mockups/mockup-A.html Normal file
View File

@ -0,0 +1,471 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>方案 A · 分步向导 · 新建商品(AI 生成)</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../assets/restraint.css">
<style>
body { background: var(--bg); }
.demo-bar {
padding: 16px 24px;
background: var(--card);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 16px;
font-family: 'Inter', sans-serif;
}
.demo-bar .ti { font-weight: 700; font-size: 15px; }
.demo-bar .desc { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--ink-3); }
/* Wizard - full screen modal */
.wiz-shell {
background: var(--bg);
min-height: calc(100vh - 60px);
display: flex; flex-direction: column;
position: relative; overflow: hidden;
}
.wiz-shell .grid-bg {
position: absolute; inset: 0; pointer-events: none;
background-image:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><g stroke='%23B8B3A4' stroke-width='1' fill='none'><path d='M-5 0 L5 0 M0 -5 L0 5'/><path d='M235 0 L245 0 M240 -5 L240 5'/></g></svg>"),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><g stroke='%23E2DED2' stroke-width='1' fill='none' stroke-dasharray='1.5 4'><path d='M240 0 L240 240'/><path d='M0 240 L240 240'/></g></svg>");
background-size: 240px 240px, 240px 240px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, #000 30%, transparent 90%);
-webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, #000 30%, transparent 90%);
}
/* Top bar */
.wiz-top {
position: relative; z-index: 2;
padding: 14px 32px;
border-bottom: 1px solid var(--border);
background: var(--bg);
display: flex; align-items: center; gap: 24px;
}
.wiz-top .home {
display: flex; align-items: center; gap: 8px;
color: var(--ink-2);
cursor: pointer;
font-size: 13px;
}
.wiz-top .home:hover { color: var(--ink); }
.wiz-top .ti { font-weight: 700; font-size: 16px; }
.wiz-top .ti span { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); margin-left: 6px; letter-spacing: .04em; }
/* Stepper */
.wiz-stepper {
flex: 1;
display: flex; align-items: center; justify-content: center; gap: 0;
max-width: 640px; margin: 0 auto;
}
.ws-step {
display: flex; align-items: center; gap: 8px;
cursor: pointer; user-select: none;
}
.ws-step .num {
width: 24px; height: 24px;
border: 1px solid var(--border);
background: var(--card);
color: var(--ink-3);
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700;
display: grid; place-items: center;
}
.ws-step.done .num { background: var(--ink); color: #FFF; border-color: var(--ink); }
.ws-step.active .num { background: var(--orange); color: #FFF; border-color: var(--orange); }
.ws-step .lbl { font-size: 12.5px; color: var(--ink-3); font-weight: 500; }
.ws-step.done .lbl { color: var(--ink-2); }
.ws-step.active .lbl { color: var(--ink); font-weight: 600; }
.ws-line { width: 60px; height: 1px; background: var(--border); margin: 0 12px; }
.ws-line.done { background: var(--ink); }
.wiz-top .x {
width: 32px; height: 32px;
border: 1px solid var(--border);
display: grid; place-items: center;
cursor: pointer;
color: var(--ink-2);
}
.wiz-top .x:hover { color: var(--orange); border-color: var(--orange); }
/* Body */
.wiz-body {
position: relative; z-index: 1;
flex: 1;
padding: 40px 60px 100px;
overflow-y: auto;
max-width: 1200px;
width: 100%;
margin: 0 auto;
}
.step-h { margin-bottom: 28px; text-align: center; }
.step-h h2 { font-size: 24px; font-weight: 700; letter-spacing: -.018em; }
.step-h p { font-size: 13px; color: var(--ink-2); margin-top: 8px; }
.step-h .mono-tag { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; margin-top: 8px; display: inline-block; padding: 2px 8px; background: var(--bg-soft); border: 1px solid var(--border-soft); }
.step-pane { display: none; }
.step-pane.active { display: block; animation: fade .2s ease; }
@keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
/* Step 1: 商品信息 */
.s1-grid { display: grid; grid-template-columns: 380px 1fr; gap: 32px; max-width: 880px; margin: 0 auto; }
.upload-big {
aspect-ratio: 4/5;
background: var(--card);
border: 1.5px dashed var(--border);
display: grid; place-items: center;
text-align: center;
cursor: pointer;
padding: 24px;
color: var(--ink-2);
transition: border-color .15s, background .15s;
}
.upload-big:hover { border-color: var(--orange); background: var(--orange-tint); color: var(--orange); }
.upload-big .ic-cam { width: 48px; height: 48px; border: 1px solid currentColor; display: grid; place-items: center; margin: 0 auto 14px; }
.upload-big.has-file { border-style: solid; border-color: var(--orange); padding: 0; background: var(--bg-soft); }
.upload-big.has-file .placeholder { width: 100%; height: 100%; }
.up-hint { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); text-align: center; margin-top: 10px; letter-spacing: .02em; }
/* Step 2: 生成头图 4 选 1 */
.gen-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; max-width: 1100px; margin: 0 auto; }
.gen-card {
aspect-ratio: 1;
background: var(--card);
border: 1px solid var(--border);
cursor: pointer;
position: relative;
transition: border-color .12s;
overflow: hidden;
}
.gen-card:hover { border-color: var(--ink-3); }
.gen-card .placeholder { width: 100%; height: 100%; }
.gen-card.selected { border: 2px solid var(--orange); }
.gen-card.selected::after {
content: '✓';
position: absolute; top: 8px; right: 8px;
width: 24px; height: 24px;
background: var(--orange); color: #FFF;
display: grid; place-items: center;
font-weight: 700; font-size: 13px;
}
.gen-card .badge {
position: absolute; bottom: 8px; left: 8px;
font-family: 'JetBrains Mono', monospace; font-size: 10px;
background: rgba(255,255,255,.92); color: var(--ink-2);
border: 1px solid var(--border);
padding: 2px 6px; letter-spacing: .04em;
}
.regen-bar {
display: flex; align-items: center; justify-content: center; gap: 12px;
margin-top: 24px;
}
/* Step 3: 模特 + 上身图 */
.s3-grid { max-width: 1100px; margin: 0 auto; }
.s3-section { margin-bottom: 32px; }
.s3-section h3 { font-size: 14px; font-weight: 600; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }
.s3-section h3 .num-mono { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); padding: 1px 6px; background: var(--bg-soft); border: 1px solid var(--border-soft); letter-spacing: .04em; }
.model-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; }
.model-card {
aspect-ratio: 3/4;
background: var(--card);
border: 1px solid var(--border);
cursor: pointer;
position: relative;
}
.model-card:hover { border-color: var(--ink-3); }
.model-card.selected { border: 2px solid var(--orange); }
.model-card.selected::after { content: '✓'; position: absolute; top: 6px; right: 6px; width: 20px; height: 20px; background: var(--orange); color: #FFF; display: grid; place-items: center; font-weight: 700; font-size: 11px; }
.model-card .lbl-bottom { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(255,255,255,.92); border-top: 1px solid var(--border); padding: 4px 8px; font-size: 10.5px; color: var(--ink-2); font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
.model-card .placeholder { width: 100%; height: 100%; }
.gen-grid.multi .gen-card.selected { border: 2px solid var(--orange); }
.gen-grid.multi .gen-card.selected::after { content: '✓'; }
/* Step 4: 确认 */
.s4-shell { max-width: 760px; margin: 0 auto; }
.preview-box { background: var(--card); border: 1px solid var(--border); padding: 0; margin-bottom: 16px; }
.preview-box .ph-stack { display: grid; grid-template-columns: 240px 1fr; gap: 0; }
.preview-box .ph-stack > .placeholder { aspect-ratio: 4/5; }
.preview-box .info { padding: 24px 28px; }
.preview-box .info h3 { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
.preview-box .info .meta { font-family: 'JetBrains Mono', monospace; font-size: 11.5px; color: var(--ink-3); margin-bottom: 14px; letter-spacing: .02em; }
.preview-box .info dl { font-size: 12.5px; color: var(--ink-2); }
.preview-box .info dl > div { display: flex; padding: 6px 0; border-bottom: 1px solid var(--border); }
.preview-box .info dl > div:last-child { border-bottom: 0; }
.preview-box .info dl dt { width: 80px; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: .04em; }
.preview-box .info dl dd { flex: 1; color: var(--ink); }
.selected-thumbs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 12px; }
.selected-thumbs .placeholder { aspect-ratio: 1; }
/* Foot bar */
.wiz-foot {
position: sticky; bottom: 0; z-index: 5;
padding: 14px 32px;
background: var(--card);
border-top: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
}
.wiz-foot .info { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; }
.wiz-foot .actions { display: flex; gap: 10px; align-items: center; }
/* Field overrides */
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.bullet-list { list-style: none; padding: 0; }
.bullet-list li { display: flex; gap: 8px; align-items: center; padding: 8px 10px; background: var(--bg-soft); border: 1px solid var(--border); margin-bottom: 6px; font-size: 13px; }
.bullet-list .num { width: 18px; height: 18px; background: var(--card); border: 1px solid var(--border); font-size: 11px; color: var(--ink-2); display: grid; place-items: center; flex-shrink: 0; font-family: 'JetBrains Mono', monospace; }
.form-card { background: var(--card); border: 1px solid var(--border); padding: 24px; position: relative; }
.form-card::before, .form-card::after { content: '+'; position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.form-card::before { top: -7px; left: -7px; }
.form-card::after { bottom: -7px; right: -7px; }
</style>
</head>
<body>
<!-- Demo bar (上面只是说明) -->
<div class="demo-bar">
<a href="index.html" class="btn btn-ghost btn-sm">← 返回方案对比</a>
<span class="ti">方案 A · 分步向导(Stepper Wizard)</span>
<span class="desc">// 全屏 4 步,顶部 stepper,底部固定工具栏 · 最经典最不容易迷路</span>
</div>
<!-- Wizard -->
<div class="wiz-shell">
<div class="grid-bg"></div>
<header class="wiz-top">
<div class="home"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3 8L8 3l5 5M5 7v6h6V7"/></svg> 工作台 / 商品库</div>
<div class="ti">新建商品 <span>[ AI 生成模式 ]</span></div>
<nav class="wiz-stepper">
<a class="ws-step done" data-step="1"><div class="num"></div><div class="lbl">商品信息</div></a>
<span class="ws-line done"></span>
<a class="ws-step active" data-step="2"><div class="num">2</div><div class="lbl">生成头图</div></a>
<span class="ws-line"></span>
<a class="ws-step" data-step="3"><div class="num">3</div><div class="lbl">模特上身</div></a>
<span class="ws-line"></span>
<a class="ws-step" data-step="4"><div class="num">4</div><div class="lbl">完成创建</div></a>
</nav>
<div class="x" onclick="alert('演示原型 · 关闭按钮')"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
</header>
<div class="wiz-body">
<!-- ============= STEP 1 · 商品信息 + 上传原图 ============= -->
<section class="step-pane" data-pane="1">
<div class="step-h">
<h2>第 1 步 · 上传你的商品图,填写基础信息</h2>
<p>上传一张你自己拍摄的商品图(任何角度都行,我们会用 AI 优化),然后填一下商品的基本信息。</p>
<span class="mono-tag">// PHOTO + INFO</span>
</div>
<div class="s1-grid">
<div>
<div class="upload-big has-file">
<div class="placeholder"><span class="ph-frame">补水面膜.jpg<br>1200×1500</span></div>
</div>
<div class="up-hint">// 已上传 · 点击重新选择 · JPG / PNG · 建议 1000×1000+</div>
</div>
<div class="form-card">
<div class="field">
<label class="field-label">商品名称<span class="req">*</span></label>
<input class="input" value="透真玻尿酸补水面膜">
</div>
<div class="field-row">
<div class="field">
<label class="field-label">品类</label>
<select class="select"><option>美妆个护</option><option>数码 3C</option><option>食品饮料</option></select>
</div>
<div class="field">
<label class="field-label">参考价</label>
<input class="input" value="¥39.9">
</div>
</div>
<div class="field">
<label class="field-label">核心卖点<span class="req">*</span></label>
<ul class="bullet-list">
<li><span class="num">1</span> 透明质酸 + B5,敷完不黏不闷</li>
<li><span class="num">2</span> 30g 大容量精华液</li>
<li><span class="num">+</span> <input class="input" style="height:24px; border:0; padding:0 4px; background:transparent;" placeholder="添加新卖点"></li>
</ul>
</div>
<div class="field">
<label class="field-label">目标人群</label>
<input class="input" value="22-32 岁女性、熬夜党、敏感肌">
</div>
</div>
</div>
</section>
<!-- ============= STEP 2 · 生成头图 4 选 1 ============= -->
<section class="step-pane active" data-pane="2">
<div class="step-h">
<h2>第 2 步 · 选一张你最满意的头图</h2>
<p>AI 已基于你上传的图生成了 4 张优化后的头图,选 1 张作为商品主图。不满意可以重新生成。</p>
<span class="mono-tag">// AI generated · 4 candidates</span>
</div>
<div class="gen-grid" id="head-grid">
<div class="gen-card selected" data-id="h1"><div class="placeholder"><span class="ph-frame">候选 A · 白底简约</span></div><span class="badge">A · 1920×1920</span></div>
<div class="gen-card" data-id="h2"><div class="placeholder"><span class="ph-frame">候选 B · 木纹背景</span></div><span class="badge">B · 1920×1920</span></div>
<div class="gen-card" data-id="h3"><div class="placeholder"><span class="ph-frame">候选 C · 浅米石材</span></div><span class="badge">C · 1920×1920</span></div>
<div class="gen-card" data-id="h4"><div class="placeholder"><span class="ph-frame">候选 D · 暖光氛围</span></div><span class="badge">D · 1920×1920</span></div>
</div>
<div class="regen-bar">
<button class="btn" onclick="regen('head')">↻ 全部重新生成</button>
<span style="font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--ink-3); letter-spacing:.04em;">// ~¥0.20 / 4 张 · 不满意不扣费</span>
</div>
</section>
<!-- ============= STEP 3 · 选模特 + 生成上身图 ============= -->
<section class="step-pane" data-pane="3">
<div class="step-h">
<h2>第 3 步 · 选模特,生成模特上身图</h2>
<p>从模板库选一个 AI 模特,我们会让 ta 拿着你刚选的商品图生成 4 张上身参考图,选 1-4 张你想要的。</p>
<span class="mono-tag">// MODEL + WEAR</span>
</div>
<div class="s3-grid">
<div class="s3-section">
<h3>① 选模特 <span class="num-mono">12 个 AI 模板 · 已选 1</span></h3>
<div class="model-row">
<div class="model-card selected"><div class="placeholder"><span class="ph-frame">林夕 · 25</span></div><div class="lbl-bottom">林夕 · 都市女</div></div>
<div class="model-card"><div class="placeholder"><span class="ph-frame">阿楠</span></div><div class="lbl-bottom">阿楠 · 同事女</div></div>
<div class="model-card"><div class="placeholder"><span class="ph-frame">小七</span></div><div class="lbl-bottom">小七 · 学生</div></div>
<div class="model-card"><div class="placeholder"><span class="ph-frame">王姐</span></div><div class="lbl-bottom">王姐 · 居家</div></div>
<div class="model-card"><div class="placeholder"><span class="ph-frame">阿杰</span></div><div class="lbl-bottom">阿杰 · 男</div></div>
<div class="model-card"><div class="placeholder"><span class="ph-frame">+ 更多</span></div><div class="lbl-bottom">浏览全部</div></div>
</div>
</div>
<div class="s3-section">
<h3>② 选你要的上身图(可多选) <span class="num-mono">已选 2 / 4</span></h3>
<div class="gen-grid multi" id="wear-grid">
<div class="gen-card selected" data-id="w1"><div class="placeholder"><span class="ph-frame">上身 A · 持物半身</span></div><span class="badge">A</span></div>
<div class="gen-card" data-id="w2"><div class="placeholder"><span class="ph-frame">上身 B · 敷面膜中</span></div><span class="badge">B</span></div>
<div class="gen-card selected" data-id="w3"><div class="placeholder"><span class="ph-frame">上身 C · 镜前自拍</span></div><span class="badge">C</span></div>
<div class="gen-card" data-id="w4"><div class="placeholder"><span class="ph-frame">上身 D · 床边特写</span></div><span class="badge">D</span></div>
</div>
<div class="regen-bar">
<button class="btn" onclick="regen('wear')">↻ 重新生成上身图</button>
<span style="font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--ink-3); letter-spacing:.04em;">// ~¥0.40 / 4 张</span>
</div>
</div>
</div>
</section>
<!-- ============= STEP 4 · 完成 ============= -->
<section class="step-pane" data-pane="4">
<div class="step-h">
<h2>第 4 步 · 确认创建</h2>
<p>检查一下信息,确认无误就可以创建商品了。后续在新建项目时,这些素材会自动可用。</p>
<span class="mono-tag">// REVIEW + CREATE</span>
</div>
<div class="s4-shell">
<div class="preview-box">
<div class="ph-stack">
<div class="placeholder"><span class="ph-frame">头图 A · 1920×1920</span></div>
<div class="info">
<h3>透真玻尿酸补水面膜</h3>
<div class="meta">// 美妆个护 · ¥39.9 · AI 生成 · 模特: 林夕</div>
<dl>
<div><dt>卖点</dt><dd>透明质酸 + B5 · 30g 大容量精华</dd></div>
<div><dt>人群</dt><dd>22-32 岁女性、熬夜党、敏感肌</dd></div>
<div><dt>原图</dt><dd>1 张(已上传)</dd></div>
<div><dt>头图</dt><dd>1 张(候选 A · AI 生成)</dd></div>
<div><dt>上身图</dt><dd>2 张(候选 A · C · AI 模特林夕)</dd></div>
</dl>
</div>
</div>
</div>
<h3 style="font-size:13px; font-weight:600; margin-bottom:8px; color:var(--ink-2);">已选素材</h3>
<div class="selected-thumbs">
<div class="placeholder"><span class="ph-frame">原图</span></div>
<div class="placeholder"><span class="ph-frame">头图 A</span></div>
<div class="placeholder"><span class="ph-frame">上身 A</span></div>
<div class="placeholder"><span class="ph-frame">上身 C</span></div>
</div>
</div>
</section>
</div>
<footer class="wiz-foot">
<span class="info" id="foot-info">// STEP 2 / 4 · 已选 1 张头图</span>
<div class="actions">
<button class="btn" id="btn-prev">← 上一步</button>
<button class="btn btn-primary btn-lg" id="btn-next">下一步 →</button>
</div>
</footer>
</div>
<script>
let curStep = 2;
const TOTAL = 4;
const labels = ['', '商品信息', '生成头图', '模特上身', '完成创建'];
function go(n) {
if (n < 1 || n > TOTAL) return;
curStep = n;
document.querySelectorAll('.step-pane').forEach(p => p.classList.remove('active'));
document.querySelector(`[data-pane="${n}"]`).classList.add('active');
document.querySelectorAll('.ws-step').forEach((s, i) => {
s.classList.remove('done', 'active');
if (i + 1 < n) s.classList.add('done');
if (i + 1 === n) s.classList.add('active');
});
document.querySelectorAll('.ws-line').forEach((l, i) => {
l.classList.toggle('done', i + 1 < n);
});
document.getElementById('btn-prev').style.visibility = n === 1 ? 'hidden' : 'visible';
document.getElementById('btn-next').textContent = n === TOTAL ? '✓ 创建商品' : '下一步 →';
document.getElementById('foot-info').textContent = `// STEP ${n} / ${TOTAL} · ${labels[n]}`;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
document.getElementById('btn-prev').onclick = () => go(curStep - 1);
document.getElementById('btn-next').onclick = () => {
if (curStep === TOTAL) {
alert('✓ 商品已创建!\n\n(演示原型,实际会跳回商品库)');
return;
}
go(curStep + 1);
};
document.querySelectorAll('.ws-step').forEach(s => {
s.onclick = () => go(+s.dataset.step);
});
// single select for head
document.querySelectorAll('#head-grid .gen-card').forEach(c => {
c.onclick = () => {
document.querySelectorAll('#head-grid .gen-card').forEach(x => x.classList.remove('selected'));
c.classList.add('selected');
};
});
// single select for model
document.querySelectorAll('.model-card').forEach(c => {
c.onclick = () => {
document.querySelectorAll('.model-card').forEach(x => x.classList.remove('selected'));
c.classList.add('selected');
};
});
// multi select for wear
document.querySelectorAll('#wear-grid .gen-card').forEach(c => {
c.onclick = () => c.classList.toggle('selected');
});
function regen(t) {
const el = document.querySelector(t === 'head' ? '#head-grid' : '#wear-grid');
el.style.opacity = .35;
setTimeout(() => { el.style.opacity = 1; }, 700);
}
</script>
</body>
</html>

440
v1/mockups/mockup-B.html Normal file
View File

@ -0,0 +1,440 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>方案 B · 单页折叠流 · 新建商品(AI 生成)</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../assets/restraint.css">
<style>
body { background: var(--bg); }
.demo-bar { padding: 16px 24px; background: var(--card); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 16px; font-family: 'Inter', sans-serif; }
.demo-bar .ti { font-weight: 700; font-size: 15px; }
.demo-bar .desc { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--ink-3); }
/* Drawer wrapper - simulating right-side drawer */
.drawer-shell {
background: var(--card);
border-left: 1px solid var(--border);
width: 920px; max-width: 100vw;
margin-left: auto;
min-height: calc(100vh - 60px);
display: flex; flex-direction: column;
position: relative;
}
.drawer-shell::before, .drawer-shell::after { content: '+'; position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.drawer-shell::before { top: -7px; left: -7px; }
.drawer-shell::after { bottom: -7px; right: -7px; }
.dr-head {
padding: 18px 28px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 14px;
}
.dr-head h2 { font-size: 17px; font-weight: 700; }
.dr-head .mode-pill {
font-family: 'JetBrains Mono', monospace; font-size: 10.5px;
color: var(--orange); background: var(--orange-tint); border: 1px solid var(--orange-soft);
padding: 2px 8px; letter-spacing: .04em;
}
.dr-head .x { margin-left: auto; width: 28px; height: 28px; display: grid; place-items: center; cursor: pointer; color: var(--ink-2); }
.dr-head .x:hover { background: var(--bg-soft); color: var(--ink); }
/* Mode toggle at top */
.mode-toggle {
padding: 14px 28px;
border-bottom: 1px solid var(--border);
background: var(--bg-soft);
}
.mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.mode-card {
padding: 14px 16px;
background: var(--card);
border: 1px solid var(--border);
cursor: pointer;
display: flex; align-items: center; gap: 12px;
}
.mode-card:hover { background: var(--bg); border-color: var(--ink-3); }
.mode-card.active { border-color: var(--orange); background: var(--orange-tint); }
.mode-card .ic-md { width: 36px; height: 36px; background: var(--card); border: 1px solid var(--border); display: grid; place-items: center; color: var(--orange); flex-shrink: 0; }
.mode-card.active .ic-md { background: var(--orange); color: #FFF; border-color: var(--orange); }
.mode-card .t { font-size: 13px; font-weight: 600; }
.mode-card .d { font-size: 11px; color: var(--ink-3); margin-top: 2px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
/* Top progress bar */
.progress-strip {
padding: 16px 28px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 14px;
}
.progress-strip .lbl { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; min-width: 70px; }
.progress-strip .bar { flex: 1; height: 4px; background: var(--bg-soft); }
.progress-strip .bar > span { display: block; height: 100%; background: var(--orange); transition: width .3s; }
.progress-strip .pct { font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 700; color: var(--orange); }
/* Body - vertical accordion sections */
.dr-body { flex: 1; overflow-y: auto; }
.acc-section {
border-bottom: 1px solid var(--border);
}
.acc-section:last-child { border-bottom: 0; }
.acc-head {
padding: 18px 28px;
display: flex; align-items: center; gap: 14px;
cursor: pointer;
user-select: none;
position: relative;
}
.acc-head:hover { background: var(--bg-soft); }
.acc-section.locked .acc-head { cursor: not-allowed; opacity: .55; }
.acc-section.locked .acc-head:hover { background: transparent; }
.acc-num {
width: 26px; height: 26px;
border: 1px solid var(--border); background: var(--card);
color: var(--ink-3);
font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 700;
display: grid; place-items: center;
flex-shrink: 0;
}
.acc-section.done .acc-num { background: var(--ink); color: #FFF; border-color: var(--ink); }
.acc-section.active .acc-num { background: var(--orange); color: #FFF; border-color: var(--orange); }
.acc-meta { flex: 1; }
.acc-meta h3 { font-size: 14px; font-weight: 600; }
.acc-meta .sm { font-size: 11.5px; color: var(--ink-3); margin-top: 3px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
.acc-section.done .acc-meta .sm { color: var(--ink-2); font-family: 'Inter', sans-serif; letter-spacing: 0; }
.acc-summary {
display: none;
align-items: center; gap: 8px;
padding: 6px 12px;
background: var(--green-bg); color: var(--green); border: 1px solid var(--green-bd);
font-size: 11.5px; font-weight: 600;
}
.acc-section.done .acc-summary { display: inline-flex; }
.acc-section.done .acc-toggle { display: none; }
.acc-toggle {
padding: 4px 10px;
background: var(--bg-soft); border: 1px solid var(--border);
font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-2);
cursor: pointer;
letter-spacing: .04em;
}
.acc-section.active .acc-toggle { display: none; }
.acc-section.locked .acc-toggle { display: none; }
.acc-body {
padding: 0 28px 28px;
display: none;
}
.acc-section.active .acc-body { display: block; animation: fade .2s ease; }
@keyframes fade { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
.acc-section.done .acc-body-summary {
display: block;
padding: 0 28px 16px 68px;
}
.acc-section:not(.done) .acc-body-summary { display: none; }
.acc-body-summary { display: none; }
.preview-row { display: flex; gap: 8px; align-items: center; }
.preview-row .placeholder { width: 40px; height: 40px; }
/* Step contents */
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.upload-mini {
aspect-ratio: 4/5;
background: var(--bg-soft);
border: 1.5px dashed var(--border);
display: grid; place-items: center;
text-align: center;
cursor: pointer;
color: var(--ink-2);
}
.upload-mini.has-file { border-style: solid; padding: 0; background: var(--bg-soft); border-color: var(--orange); }
.upload-mini.has-file .placeholder { width: 100%; height: 100%; }
.upload-mini .ic-cam { width: 36px; height: 36px; border: 1px solid currentColor; display: grid; place-items: center; margin: 0 auto 10px; }
.s1-row { display: grid; grid-template-columns: 200px 1fr; gap: 24px; }
.gen-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.gen-card { aspect-ratio: 1; background: var(--card); border: 1px solid var(--border); cursor: pointer; position: relative; overflow: hidden; }
.gen-card:hover { border-color: var(--ink-3); }
.gen-card .placeholder { width: 100%; height: 100%; }
.gen-card.selected { border: 2px solid var(--orange); }
.gen-card.selected::after {
content: '✓'; position: absolute; top: 6px; right: 6px;
width: 22px; height: 22px; background: var(--orange); color: #FFF;
display: grid; place-items: center; font-weight: 700; font-size: 12px;
}
.gen-card .badge {
position: absolute; bottom: 6px; left: 6px;
font-family: 'JetBrains Mono', monospace; font-size: 10px;
background: rgba(255,255,255,.95); color: var(--ink-2);
border: 1px solid var(--border);
padding: 1px 5px; letter-spacing: .04em;
}
.regen-line { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); }
.regen-line .info { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; }
.model-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-bottom: 18px; }
.model-card { aspect-ratio: 3/4; background: var(--card); border: 1px solid var(--border); cursor: pointer; position: relative; }
.model-card:hover { border-color: var(--ink-3); }
.model-card.selected { border: 2px solid var(--orange); }
.model-card.selected::after { content: '✓'; position: absolute; top: 5px; right: 5px; width: 18px; height: 18px; background: var(--orange); color: #FFF; display: grid; place-items: center; font-weight: 700; font-size: 10px; }
.model-card .lbl-bottom { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(255,255,255,.95); padding: 3px 6px; font-size: 10px; color: var(--ink-2); font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; text-align: center; }
.model-card .placeholder { width: 100%; height: 100%; }
.sub-h { display: flex; align-items: center; justify-content: space-between; margin: 14px 0 10px; }
.sub-h h4 { font-size: 12.5px; font-weight: 600; color: var(--ink-2); }
.sub-h .info { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .04em; }
.bullet-list { list-style: none; padding: 0; }
.bullet-list li { display: flex; gap: 8px; align-items: center; padding: 8px 10px; background: var(--bg-soft); border: 1px solid var(--border); margin-bottom: 6px; font-size: 13px; }
.bullet-list .num { width: 18px; height: 18px; background: var(--card); border: 1px solid var(--border); font-size: 11px; color: var(--ink-2); display: grid; place-items: center; flex-shrink: 0; font-family: 'JetBrains Mono', monospace; }
/* Footer */
.dr-foot {
padding: 14px 28px;
border-top: 1px solid var(--border);
background: var(--bg-soft);
display: flex; align-items: center; justify-content: space-between;
position: sticky; bottom: 0;
}
.dr-foot .info { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; }
</style>
</head>
<body>
<div class="demo-bar">
<a href="index.html" class="btn btn-ghost btn-sm">← 返回方案对比</a>
<span class="ti">方案 B · 单页折叠流(Accordion Flow)</span>
<span class="desc">// 全流程在一页,选过的折叠摘要,可随时回去改 · 适合反复对比</span>
</div>
<div class="drawer-shell">
<header class="dr-head">
<h2>新建商品</h2>
<span class="mode-pill">[ AI 生成模式 ]</span>
<div class="x" onclick="alert('演示原型')"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
</header>
<!-- Mode select at top -->
<div class="mode-toggle">
<div class="mode-grid">
<div class="mode-card">
<div class="ic-md"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg></div>
<div><div class="t">我有商品图</div><div class="d">// upload-only · 直接上传图片</div></div>
</div>
<div class="mode-card active">
<div class="ic-md"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9.663 17h4.673M12 3v1M3.34 7l.7.7M20.66 7l-.7.7M2 12h1M21 12h1M12 19a7 7 0 100-14 7 7 0 000 14z"/></svg></div>
<div><div class="t">AI 帮我生成图</div><div class="d">// 上传 1 张原图,AI 出 4 选 1 + 模特上身</div></div>
</div>
</div>
</div>
<div class="progress-strip">
<span class="lbl">// PROGRESS</span>
<div class="bar"><span style="width:50%" id="bar"></span></div>
<span class="pct" id="pct">2 / 4</span>
</div>
<div class="dr-body">
<!-- ============= 1 · 商品信息 (DONE - collapsed) ============= -->
<section class="acc-section done" data-step="1">
<div class="acc-head">
<div class="acc-num"></div>
<div class="acc-meta">
<h3>商品信息 + 上传原图</h3>
<div class="sm">透真玻尿酸补水面膜 · 美妆个护 · ¥39.9 · 已上传 1 张原图</div>
</div>
<span class="acc-summary">✓ 已完成</span>
<button class="acc-toggle">编辑</button>
</div>
<div class="acc-body-summary">
<div class="preview-row">
<div class="placeholder"><span class="ph-frame">原图</span></div>
<div style="font-size:12px; color:var(--ink-3); font-family:'JetBrains Mono',monospace;">// 已记录 · 点击右侧"编辑"修改</div>
</div>
</div>
</section>
<!-- ============= 2 · 生成头图 (ACTIVE) ============= -->
<section class="acc-section active" data-step="2">
<div class="acc-head">
<div class="acc-num">2</div>
<div class="acc-meta">
<h3>生成头图 · 选 1 张</h3>
<div class="sm">// AI 已生成 4 张候选 · 点击你最满意的那张</div>
</div>
</div>
<div class="acc-body">
<div class="gen-grid" id="head-grid">
<div class="gen-card selected" data-id="h1"><div class="placeholder"><span class="ph-frame">候选 A · 白底简约</span></div><span class="badge">A</span></div>
<div class="gen-card" data-id="h2"><div class="placeholder"><span class="ph-frame">候选 B · 木纹背景</span></div><span class="badge">B</span></div>
<div class="gen-card" data-id="h3"><div class="placeholder"><span class="ph-frame">候选 C · 浅米石材</span></div><span class="badge">C</span></div>
<div class="gen-card" data-id="h4"><div class="placeholder"><span class="ph-frame">候选 D · 暖光氛围</span></div><span class="badge">D</span></div>
</div>
<div class="regen-line">
<span class="info">// ~¥0.20 / 4 张 · 不满意不扣费</span>
<div class="hstack">
<button class="btn btn-sm" onclick="regen('head')">↻ 全部重新生成</button>
<button class="btn btn-primary btn-sm" onclick="confirmStep(2)">确认这张 →</button>
</div>
</div>
</div>
</section>
<!-- ============= 3 · 模特上身 (LOCKED until step 2 done) ============= -->
<section class="acc-section locked" data-step="3">
<div class="acc-head">
<div class="acc-num">3</div>
<div class="acc-meta">
<h3>选模特 · 生成上身图</h3>
<div class="sm">// 完成上一步后开启</div>
</div>
</div>
<div class="acc-body">
<div class="sub-h">
<h4>① 选一个 AI 模特</h4>
<span class="info">// 12 个模板 · 1 选</span>
</div>
<div class="model-row">
<div class="model-card selected"><div class="placeholder"><span class="ph-frame">林夕 · 25</span></div><div class="lbl-bottom">林夕</div></div>
<div class="model-card"><div class="placeholder"><span class="ph-frame">阿楠</span></div><div class="lbl-bottom">阿楠</div></div>
<div class="model-card"><div class="placeholder"><span class="ph-frame">小七</span></div><div class="lbl-bottom">小七</div></div>
<div class="model-card"><div class="placeholder"><span class="ph-frame">王姐</span></div><div class="lbl-bottom">王姐</div></div>
<div class="model-card"><div class="placeholder"><span class="ph-frame">阿杰</span></div><div class="lbl-bottom">阿杰</div></div>
<div class="model-card"><div class="placeholder"><span class="ph-frame">+ 更多</span></div><div class="lbl-bottom">浏览全部</div></div>
</div>
<div class="sub-h">
<h4>② 选你要的上身图(可多选)</h4>
<span class="info">// 已选 2 / 4</span>
</div>
<div class="gen-grid" id="wear-grid">
<div class="gen-card selected" data-id="w1"><div class="placeholder"><span class="ph-frame">上身 A · 持物半身</span></div><span class="badge">A</span></div>
<div class="gen-card" data-id="w2"><div class="placeholder"><span class="ph-frame">上身 B · 敷面膜中</span></div><span class="badge">B</span></div>
<div class="gen-card selected" data-id="w3"><div class="placeholder"><span class="ph-frame">上身 C · 镜前自拍</span></div><span class="badge">C</span></div>
<div class="gen-card" data-id="w4"><div class="placeholder"><span class="ph-frame">上身 D · 床边特写</span></div><span class="badge">D</span></div>
</div>
<div class="regen-line">
<span class="info">// ~¥0.40 / 4 张</span>
<div class="hstack">
<button class="btn btn-sm" onclick="regen('wear')">↻ 重新生成上身图</button>
<button class="btn btn-primary btn-sm" onclick="confirmStep(3)">下一步 →</button>
</div>
</div>
</div>
</section>
<!-- ============= 4 · 完成 ============= -->
<section class="acc-section locked" data-step="4">
<div class="acc-head">
<div class="acc-num">4</div>
<div class="acc-meta">
<h3>预览 · 创建</h3>
<div class="sm">// 完成上一步后开启</div>
</div>
</div>
<div class="acc-body">
<p style="font-size:13px; color:var(--ink-2); margin-bottom:14px;">检查信息无误后,点击下方「创建商品」。</p>
<div class="preview-row" style="gap:12px;">
<div class="placeholder" style="width:120px; height:150px;"><span class="ph-frame">头图 A</span></div>
<div class="placeholder" style="width:120px; height:150px;"><span class="ph-frame">上身 A</span></div>
<div class="placeholder" style="width:120px; height:150px;"><span class="ph-frame">上身 C</span></div>
</div>
<button class="btn btn-primary btn-lg" style="margin-top:18px; width:100%;" onclick="alert('✓ 商品已创建!')">✓ 创建商品</button>
</div>
</section>
</div>
<footer class="dr-foot">
<span class="info" id="foot">// STEP 2 / 4 · 选头图</span>
<div class="hstack">
<button class="btn">取消</button>
</div>
</footer>
</div>
<script>
const TOTAL = 4;
let activeStep = 2;
function refreshUI() {
const sections = document.querySelectorAll('.acc-section');
sections.forEach((s, i) => {
const n = i + 1;
s.classList.remove('active', 'locked');
if (n < activeStep) s.classList.add('done');
else s.classList.remove('done');
if (n === activeStep) s.classList.add('active');
else if (n > activeStep) s.classList.add('locked');
});
const pct = Math.round(((activeStep - 1) / TOTAL) * 100);
document.getElementById('bar').style.width = pct + '%';
document.getElementById('pct').textContent = `${activeStep - 1} / ${TOTAL}`;
document.getElementById('foot').textContent = `// STEP ${activeStep} / ${TOTAL}`;
}
function confirmStep(n) {
activeStep = n + 1;
refreshUI();
const next = document.querySelector(`[data-step="${activeStep}"]`);
if (next) next.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// Click "编辑" on done sections
document.querySelectorAll('.acc-section.done .acc-toggle').forEach(b => {
b.onclick = (e) => {
e.stopPropagation();
const sec = b.closest('.acc-section');
const n = +sec.dataset.step;
activeStep = n;
refreshUI();
sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
});
// Click on done section header to expand
document.querySelectorAll('.acc-section.done .acc-head').forEach(h => {
h.onclick = () => {
const sec = h.closest('.acc-section');
const n = +sec.dataset.step;
activeStep = n;
refreshUI();
};
});
// single select for head
document.querySelectorAll('#head-grid .gen-card').forEach(c => {
c.onclick = () => {
document.querySelectorAll('#head-grid .gen-card').forEach(x => x.classList.remove('selected'));
c.classList.add('selected');
};
});
// single select for model
document.querySelectorAll('.model-card').forEach(c => {
c.onclick = () => {
document.querySelectorAll('.model-card').forEach(x => x.classList.remove('selected'));
c.classList.add('selected');
};
});
// multi select for wear
document.querySelectorAll('#wear-grid .gen-card').forEach(c => {
c.onclick = () => c.classList.toggle('selected');
});
function regen(t) {
const el = document.querySelector(t === 'head' ? '#head-grid' : '#wear-grid');
el.style.opacity = .35;
setTimeout(() => { el.style.opacity = 1; }, 700);
}
refreshUI();
</script>
</body>
</html>

View File

@ -0,0 +1,383 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>方案 1 · 独立 Step · 模特挑选 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../assets/restraint.css">
<style>
body { background: var(--bg); }
.demo-bar { padding: 16px 24px; background: var(--card); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 16px; font-family: 'Inter', sans-serif; }
.demo-bar .ti { font-weight: 700; font-size: 15px; }
.demo-bar .desc { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--ink-3); }
/* Workbench - full screen */
.wb { height: calc(100vh - 60px); display: grid; grid-template-rows: 56px 1fr; background: var(--bg); }
/* Top bar */
.wb-top { padding: 0 24px; background: var(--card); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 18px; }
.wb-top .ti { font-size: 15px; font-weight: 700; }
.wb-top .ti span { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--orange); background: var(--orange-tint); border: 1px solid var(--orange-soft); padding: 2px 6px; margin-left: 8px; letter-spacing: .04em; }
.wb-top .meta-line { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; }
.wb-top .right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.wb-top .x { width: 32px; height: 32px; display: grid; place-items: center; cursor: pointer; color: var(--ink-2); border: 1px solid var(--border); }
.wb-top .x:hover { color: var(--orange); border-color: var(--orange); }
/* Main split */
.wb-main { display: grid; grid-template-columns: 280px 1fr 360px; min-height: 0; }
/* Left sidebar - 5 steps */
.wb-side { border-right: 1px solid var(--border); background: var(--bg); overflow-y: auto; padding: 18px 0; }
.step-item { padding: 14px 20px; display: flex; align-items: flex-start; gap: 12px; cursor: pointer; border-left: 3px solid transparent; position: relative; }
.step-item:hover { background: var(--bg-soft); }
.step-item.active { background: var(--orange-tint); border-left-color: var(--orange); }
.step-item .num { width: 24px; height: 24px; border: 1px solid var(--border); background: var(--card); color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; display: grid; place-items: center; flex-shrink: 0; }
.step-item.done .num { background: var(--ink); color: #FFF; border-color: var(--ink); }
.step-item.active .num { background: var(--orange); color: #FFF; border-color: var(--orange); }
.step-item .info { flex: 1; min-width: 0; }
.step-item .ti2 { font-size: 13.5px; font-weight: 600; color: var(--ink-2); }
.step-item.active .ti2 { color: var(--ink); }
.step-item .sub { font-size: 11px; color: var(--ink-3); margin-top: 3px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
.step-item.done .sub { color: var(--ink-2); font-family: 'Inter', sans-serif; letter-spacing: 0; }
.step-item.new { position: relative; }
.step-item.new::after { content: 'NEW'; position: absolute; top: 14px; right: 16px; font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 1px 5px; background: var(--orange); color: #FFF; letter-spacing: .04em; font-weight: 700; }
.side-divider { height: 1px; background: var(--border); margin: 14px 20px; }
.side-section-h { padding: 4px 20px 8px; font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .08em; text-transform: uppercase; font-weight: 600; }
/* Center canvas */
.wb-canvas { background: var(--bg); overflow-y: auto; padding: 28px 32px; display: flex; flex-direction: column; min-width: 0; }
.canvas-h { margin-bottom: 18px; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.canvas-h h2 { font-size: 22px; font-weight: 700; letter-spacing: -.012em; }
.canvas-h p { font-size: 13px; color: var(--ink-2); margin-top: 4px; }
.canvas-h .step-tag { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); padding: 3px 8px; background: var(--bg-soft); border: 1px solid var(--border-soft); letter-spacing: .04em; flex-shrink: 0; }
/* Search + filter toolbar */
.filter-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; flex-wrap: wrap; }
.search-input { position: relative; width: 280px; }
.search-input svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--ink-3); width: 14px; height: 14px; }
.search-input input { padding-left: 32px; height: 30px; font-size: 12.5px; background: var(--card); border: 1px solid var(--border); color: var(--ink); outline: none; width: 100%; }
.filter-chip { height: 30px; padding: 0 10px; border: 1px solid var(--border); background: var(--card); font-size: 12px; color: var(--ink-2); display: inline-flex; align-items: center; gap: 4px; cursor: pointer; font-family: inherit; }
.filter-chip:hover { background: var(--bg-soft); }
.filter-chip.active { border-color: var(--orange); color: var(--orange); background: var(--orange-tint); font-weight: 600; }
.filter-chip svg { width: 11px; height: 11px; }
.results-meta { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; }
/* Model grid */
.model-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; }
@media (max-width: 1400px) { .model-grid { grid-template-columns: repeat(5, 1fr); } }
@media (max-width: 1200px) { .model-grid { grid-template-columns: repeat(4, 1fr); } }
.model-card {
aspect-ratio: 3/4;
background: var(--card);
border: 1px solid var(--border);
cursor: pointer;
position: relative;
overflow: hidden;
}
.model-card:hover { border-color: var(--ink-3); }
.model-card.selected { border: 2px solid var(--orange); }
.model-card.selected::after {
content: '✓'; position: absolute; top: 6px; right: 6px;
width: 22px; height: 22px; background: var(--orange); color: #FFF;
display: grid; place-items: center; font-weight: 700; font-size: 12px; z-index: 2;
}
.model-card .placeholder { width: 100%; height: 100%; }
.model-card .lbl-bottom {
position: absolute; bottom: 0; left: 0; right: 0;
background: rgba(255,255,255,.95); border-top: 1px solid var(--border);
padding: 6px 8px;
display: flex; flex-direction: column; gap: 2px;
}
.model-card .lbl-bottom .nm { font-size: 12px; font-weight: 600; color: var(--ink); }
.model-card .lbl-bottom .tags {
font-family: 'JetBrains Mono', monospace; font-size: 9.5px;
color: var(--ink-3); letter-spacing: .02em;
}
.model-card .pose-tag {
position: absolute; top: 6px; left: 6px;
background: rgba(0,0,0,.55); color: #FFF;
font-family: 'JetBrains Mono', monospace; font-size: 9px;
padding: 1px 5px; letter-spacing: .04em;
z-index: 1;
}
/* Right controls - hover detail */
.wb-controls { border-left: 1px solid var(--border); background: var(--bg); overflow-y: auto; padding: 22px 22px 100px; display: flex; flex-direction: column; position: relative; }
.ctrl-section { margin-bottom: 22px; }
.ctrl-section h3 { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .08em; text-transform: uppercase; font-weight: 600; margin-bottom: 10px; }
/* Hover detail card */
.hover-detail { background: var(--card); border: 1px solid var(--border); padding: 14px; }
.hover-detail .hd-name { font-size: 15px; font-weight: 700; }
.hover-detail .hd-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
.hover-detail .hd-tags span { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--ink-2); background: var(--bg-soft); border: 1px solid var(--border-soft); padding: 1px 5px; letter-spacing: .02em; }
.hover-detail .hd-views { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; margin-top: 12px; }
.hover-detail .hd-views .placeholder { aspect-ratio: 3/4; }
.hover-detail .hd-meta { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 11.5px; color: var(--ink-2); }
.hover-detail .hd-meta div { display: flex; gap: 8px; }
.hover-detail .hd-meta .k { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); width: 56px; letter-spacing: .04em; }
/* Selected list */
.selected-list { display: flex; flex-direction: column; gap: 6px; }
.sel-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: var(--card); border: 1px solid var(--border); }
.sel-row .placeholder { width: 28px; height: 36px; flex-shrink: 0; }
.sel-row .nm { flex: 1; font-size: 12px; font-weight: 600; }
.sel-row .x-btn { width: 18px; height: 18px; display: grid; place-items: center; cursor: pointer; color: var(--ink-3); }
.sel-row .x-btn:hover { color: var(--red); }
.sel-empty { padding: 14px 12px; text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); border: 1px dashed var(--border); letter-spacing: .02em; }
/* Tip */
.tip-mini { padding: 10px 12px; background: var(--orange-tint); border: 1px solid var(--orange-soft); font-size: 11.5px; color: var(--ink-2); line-height: 1.5; }
.tip-mini strong { color: var(--orange); display: block; font-family: 'JetBrains Mono', monospace; font-size: 10px; letter-spacing: .04em; margin-bottom: 4px; }
/* Bottom bar */
.wb-bottom { position: absolute; left: 0; right: 0; bottom: 0; padding: 14px 22px; background: var(--card); border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
.wb-bottom .row1 { display: flex; align-items: center; justify-content: space-between; }
.cost-info { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; }
.cost-info .price { color: var(--orange); font-weight: 700; }
/* Banner: 5-step indicator */
.step-count-banner { padding: 8px 14px; background: var(--orange-tint); border: 1px solid var(--orange-soft); margin-bottom: 16px; display: flex; align-items: center; gap: 10px; font-size: 11.5px; color: var(--ink-2); }
.step-count-banner .ic { width: 16px; height: 16px; background: var(--orange); color: #FFF; display: grid; place-items: center; font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; }
</style>
</head>
<body>
<div class="demo-bar">
<a href="index.html" class="btn btn-ghost btn-sm">← 返回方案对比</a>
<span class="ti">方案 1 · 独立 Step(5 步流程)</span>
<span class="desc">// "选模特" 独立成第 3 步,canvas 充分展示模特库 · 适合 50+ 大型素材库</span>
</div>
<div class="wb">
<header class="wb-top">
<div class="ti">透真玻尿酸补水面膜 <span>[ AI 生成模式 ]</span></div>
<div class="meta-line">// 美妆个护 · ¥39.9 · 已选头图 A · WHITE-BG</div>
<div class="right">
<button class="btn btn-sm">保存草稿</button>
<div class="x"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
</div>
</header>
<div class="wb-main">
<!-- Left: 5 steps -->
<aside class="wb-side">
<div class="side-section-h">// CREATE FLOW · 5 STEPS</div>
<div class="step-item done"><div class="num"></div><div class="info"><div class="ti2">商品信息</div><div class="sub">名称、品类、价格、卖点</div></div></div>
<div class="step-item done"><div class="num"></div><div class="info"><div class="ti2">生成头图</div><div class="sub">已选 1 / 4 · 候选 A</div></div></div>
<div class="step-item active new"><div class="num">3</div><div class="info"><div class="ti2">挑选模特</div><div class="sub">// 50+ 模特库 · 单选/多选</div></div></div>
<div class="step-item"><div class="num">4</div><div class="info"><div class="ti2">生成上身图</div><div class="sub">// 4 选 N · 多选保留</div></div></div>
<div class="step-item"><div class="num">5</div><div class="info"><div class="ti2">完成创建</div><div class="sub">// 预览 · 提交</div></div></div>
<div class="side-divider"></div>
<div class="side-section-h">// SELECTED ASSETS</div>
<div style="padding: 0 20px;">
<div style="font-size:11.5px; color:var(--ink-3); margin-bottom:6px; font-family:'JetBrains Mono',monospace; letter-spacing:.02em;">原图 · 1 张</div>
<div class="placeholder" style="width:100%; aspect-ratio: 4/5; margin-bottom:12px;"><span class="ph-frame">补水面膜<br>1200×1500</span></div>
<div style="font-size:11.5px; color:var(--ink-3); margin-bottom:6px; font-family:'JetBrains Mono',monospace; letter-spacing:.02em;">头图 · 候选 A</div>
<div class="placeholder" style="width:100%; aspect-ratio: 1; margin-bottom:12px;"><span class="ph-frame">A · 白底简约</span></div>
<div style="font-size:11.5px; color:var(--ink-3); font-family:'JetBrains Mono',monospace; letter-spacing:.02em;">模特 · 已选 <span style="color:var(--orange); font-weight:700;" id="sel-count-side">2</span> / 50+</div>
</div>
</aside>
<!-- Center canvas: model picker -->
<main class="wb-canvas">
<div class="canvas-h">
<div>
<h2>挑选模特</h2>
<p>从 AI 模特库选 1 个或多个模特,统一白 T + 白短裤立绘。悬浮看详情和三视图。</p>
</div>
<span class="step-tag">// STEP 3 / 5 · MODEL SELECT</span>
</div>
<div class="step-count-banner">
<span class="ic">i</span>
独立成步的好处:筛选 + 搜索 + 多选都有完整空间,适合 50+ 模特库。代价是流程多一步。
</div>
<div class="filter-bar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input type="text" placeholder="搜索模特名称、风格…">
</div>
<button class="filter-chip active">全部 <span style="font-family:'JetBrains Mono',monospace; opacity:.7;">52</span></button>
<button class="filter-chip">女性</button>
<button class="filter-chip">男性</button>
<button class="filter-chip">25-30 岁</button>
<button class="filter-chip">都市白领</button>
<button class="filter-chip">学生</button>
<button class="filter-chip">居家</button>
<button class="filter-chip">健身</button>
<button class="filter-chip">+ 更多筛选 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
<span class="results-meta">// 12 / 52 · 已选 <span id="sel-count" style="color:var(--orange); font-weight:700;">2</span></span>
</div>
<div class="model-grid" id="model-grid">
<div class="model-card selected" data-id="m1" onmouseenter="hoverModel('林夕','女 · 25-30','都市白领',['温柔','日常','OL','通勤'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">林夕 · 都市女</span></div>
<div class="lbl-bottom"><div class="nm">林夕</div><div class="tags">女 · 25-30 · 都市白领</div></div>
</div>
<div class="model-card" data-id="m2" onmouseenter="hoverModel('阿楠','女 · 25-30','姐妹/同事',['精致','干练','短发'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">阿楠</span></div>
<div class="lbl-bottom"><div class="nm">阿楠</div><div class="tags">女 · 25-30 · 同事</div></div>
</div>
<div class="model-card selected" data-id="m3" onmouseenter="hoverModel('小七','女 · 18-22','学生/Z世代',['青春','元气','校园'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">小七 · 学生</span></div>
<div class="lbl-bottom"><div class="nm">小七</div><div class="tags">女 · 18-22 · 学生</div></div>
</div>
<div class="model-card" data-id="m4" onmouseenter="hoverModel('王姐','女 · 38-45','妈妈/居家',['亲和','成熟','温暖'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">王姐 · 居家</span></div>
<div class="lbl-bottom"><div class="nm">王姐</div><div class="tags">女 · 38-45 · 妈妈</div></div>
</div>
<div class="model-card" data-id="m5" onmouseenter="hoverModel('阿杰','男 · 28-35','通勤男',['商务','干练','西装'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">阿杰 · 通勤男</span></div>
<div class="lbl-bottom"><div class="nm">阿杰</div><div class="tags">男 · 28-35 · 通勤</div></div>
</div>
<div class="model-card" data-id="m6" onmouseenter="hoverModel('阿强','男 · 22-28','健身男',['阳光','运动','肌肉'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">阿强 · 健身男</span></div>
<div class="lbl-bottom"><div class="nm">阿强</div><div class="tags">男 · 22-28 · 健身</div></div>
</div>
<div class="model-card" data-id="m7" onmouseenter="hoverModel('小苏','女 · 22-26','文艺研究生',['书卷气','静谧','文艺'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">小苏 · 文艺女</span></div>
<div class="lbl-bottom"><div class="nm">小苏</div><div class="tags">女 · 22-26 · 文艺</div></div>
</div>
<div class="model-card" data-id="m8" onmouseenter="hoverModel('文文','女 · 30-38','母亲',['温柔','母性','温暖'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">文文 · 母亲</span></div>
<div class="lbl-bottom"><div class="nm">文文</div><div class="tags">女 · 30-38 · 母亲</div></div>
</div>
<div class="model-card" data-id="m9" onmouseenter="hoverModel('老王','男 · 45-55','大叔',['沉稳','成熟','商务'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">老王 · 大叔</span></div>
<div class="lbl-bottom"><div class="nm">老王</div><div class="tags">男 · 45-55 · 大叔</div></div>
</div>
<div class="model-card" data-id="m10" onmouseenter="hoverModel('萌萌','女 · 18-22','Z世代',['可爱','潮流','二次元'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">萌萌 · Z世代</span></div>
<div class="lbl-bottom"><div class="nm">萌萌</div><div class="tags">女 · 18-22 · Z 世代</div></div>
</div>
<div class="model-card" data-id="m11" onmouseenter="hoverModel('Leo','男 · 30-38','健身教练',['专业','力量','健身房'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">Leo · 教练</span></div>
<div class="lbl-bottom"><div class="nm">Leo</div><div class="tags">男 · 30-38 · 教练</div></div>
</div>
<div class="model-card" data-id="m12" onmouseenter="hoverModel('双人组合','双人 · 25-30','闺蜜',['亲密','日常','搭档'])">
<span class="pose-tag">[ 立绘 · 双人 ]</span>
<div class="placeholder"><span class="ph-frame">闺蜜双人</span></div>
<div class="lbl-bottom"><div class="nm">闺蜜组合</div><div class="tags">双人 · 25-30</div></div>
</div>
</div>
<div style="margin-top: 18px; padding: 12px 16px; background: var(--card); border: 1px dashed var(--border); text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 11.5px; color: var(--ink-3); letter-spacing: .02em;">
// 还有 40 个模特 · 滚动加载 / 用上方筛选缩小范围
</div>
</main>
<!-- Right: hover detail + selected list -->
<aside class="wb-controls">
<div class="ctrl-section">
<h3>// 当前悬浮 · DETAIL</h3>
<div class="hover-detail" id="hover-detail">
<div class="hd-name">林夕</div>
<div class="hd-tags">
<span></span><span>25-30</span><span>都市白领</span><span>温柔</span><span>OL</span><span>通勤</span>
</div>
<div class="hd-views">
<div class="placeholder"><span class="ph-frame">正面</span></div>
<div class="placeholder"><span class="ph-frame">侧面</span></div>
<div class="placeholder"><span class="ph-frame">背面</span></div>
</div>
<div class="hd-meta">
<div><span class="k">// 服装</span><span>白 T + 白短裤(默认立绘)</span></div>
<div><span class="k">// 年龄</span><span>25-30 岁 · 可微调 ±3</span></div>
<div><span class="k">// 身材</span><span>普通女 · 165cm / 50kg</span></div>
<div><span class="k">// 用过</span><span>4 个项目 · 平均评分 4.5</span></div>
</div>
</div>
</div>
<div class="ctrl-section">
<h3>// 已选模特 · <span style="color:var(--orange); font-weight:700;" id="sel-count-3">2</span></h3>
<div class="selected-list" id="selected-list">
<div class="sel-row" data-id="m1">
<div class="placeholder"><span class="ph-frame"></span></div>
<div class="nm">林夕 · 都市白领</div>
<div class="x-btn" title="移除"></div>
</div>
<div class="sel-row" data-id="m3">
<div class="placeholder"><span class="ph-frame"></span></div>
<div class="nm">小七 · 学生</div>
<div class="x-btn" title="移除"></div>
</div>
</div>
</div>
<div class="ctrl-section">
<h3>// TIP</h3>
<div class="tip-mini">
<strong>多选会怎样?</strong>
每选一个模特,下一步会为其各自生成 4 张上身图。已选 2 个 = 8 张候选,可多选保留。
</div>
</div>
<div class="wb-bottom">
<div class="row1">
<span class="cost-info">// 预计 <span class="price">¥0.80</span> / 8 张</span>
<span class="cost-info">3 / 5</span>
</div>
<div class="hstack">
<button class="btn" style="flex:1;">← 上一步</button>
<button class="btn btn-primary" style="flex:1;">下一步 → 生成上身图</button>
</div>
</div>
</aside>
</div>
</div>
<script>
// multi select
document.querySelectorAll('.model-card').forEach(c => {
c.onclick = () => {
c.classList.toggle('selected');
updateCount();
};
});
function updateCount() {
const n = document.querySelectorAll('.model-card.selected').length;
document.getElementById('sel-count').textContent = n;
document.getElementById('sel-count-3').textContent = n;
document.getElementById('sel-count-side').textContent = n;
}
function hoverModel(name, base, role, tags) {
const hd = document.getElementById('hover-detail');
hd.querySelector('.hd-name').textContent = name;
hd.querySelector('.hd-tags').innerHTML = (base.split(' · ')).concat([role]).concat(tags).map(t => `<span>${t}</span>`).join('');
}
document.querySelectorAll('.x-btn').forEach(b => {
b.onclick = (e) => {
e.stopPropagation();
const row = b.closest('.sel-row');
const id = row.dataset.id;
document.querySelector(`.model-card[data-id="${id}"]`)?.classList.remove('selected');
row.remove();
updateCount();
};
});
</script>
</body>
</html>

View File

@ -0,0 +1,530 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>方案 3 · 子步骤分屏 · 模特挑选 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../assets/restraint.css">
<style>
body { background: var(--bg); }
.demo-bar { padding: 16px 24px; background: var(--card); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 16px; font-family: 'Inter', sans-serif; }
.demo-bar .ti { font-weight: 700; font-size: 15px; }
.demo-bar .desc { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--ink-3); }
.wb { height: calc(100vh - 60px); display: grid; grid-template-rows: 56px 1fr; background: var(--bg); }
.wb-top { padding: 0 24px; background: var(--card); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 18px; }
.wb-top .ti { font-size: 15px; font-weight: 700; }
.wb-top .ti span { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--orange); background: var(--orange-tint); border: 1px solid var(--orange-soft); padding: 2px 6px; margin-left: 8px; letter-spacing: .04em; }
.wb-top .meta-line { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; }
.wb-top .right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.wb-top .x { width: 32px; height: 32px; display: grid; place-items: center; cursor: pointer; color: var(--ink-2); border: 1px solid var(--border); }
.wb-top .x:hover { color: var(--orange); border-color: var(--orange); }
.wb-main { display: grid; grid-template-columns: 280px 1fr 360px; min-height: 0; }
/* Sidebar - 4 steps with sub-items */
.wb-side { border-right: 1px solid var(--border); background: var(--bg); overflow-y: auto; padding: 18px 0; }
.step-item { padding: 14px 20px; display: flex; align-items: flex-start; gap: 12px; cursor: pointer; border-left: 3px solid transparent; position: relative; }
.step-item:hover { background: var(--bg-soft); }
.step-item.active { background: var(--orange-tint); border-left-color: var(--orange); }
.step-item .num { width: 24px; height: 24px; border: 1px solid var(--border); background: var(--card); color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; display: grid; place-items: center; flex-shrink: 0; }
.step-item.done .num { background: var(--ink); color: #FFF; border-color: var(--ink); }
.step-item.active .num { background: var(--orange); color: #FFF; border-color: var(--orange); }
.step-item .info { flex: 1; min-width: 0; }
.step-item .ti2 { font-size: 13.5px; font-weight: 600; color: var(--ink-2); }
.step-item.active .ti2 { color: var(--ink); }
.step-item .sub { font-size: 11px; color: var(--ink-3); margin-top: 3px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
/* Sub items under active step */
.substep-list { padding: 4px 20px 8px 56px; }
.substep-item {
display: flex; align-items: center; gap: 10px;
padding: 7px 10px;
cursor: pointer;
font-size: 12px; color: var(--ink-3);
border-left: 2px solid var(--border);
margin-left: -4px;
}
.substep-item:hover { color: var(--ink-2); border-left-color: var(--ink-3); }
.substep-item.active { color: var(--orange); border-left-color: var(--orange); font-weight: 600; }
.substep-item.done { color: var(--ink-2); }
.substep-item .num-mini {
width: 16px; height: 16px;
border: 1px solid currentColor;
font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700;
display: grid; place-items: center;
flex-shrink: 0;
}
.substep-item.active .num-mini, .substep-item.done .num-mini { background: currentColor; color: #FFF; }
.substep-item.done .num-mini { background: var(--ink); border-color: var(--ink); }
.side-divider { height: 1px; background: var(--border); margin: 14px 20px; }
.side-section-h { padding: 4px 20px 8px; font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .08em; text-transform: uppercase; font-weight: 600; }
/* Center canvas */
.wb-canvas { background: var(--bg); overflow-y: auto; padding: 0; display: flex; flex-direction: column; min-width: 0; }
/* Sub-tab strip on top */
.subtab-strip {
padding: 0 32px;
border-bottom: 1px solid var(--border);
background: var(--card);
display: flex; align-items: center; gap: 0;
position: sticky; top: 0; z-index: 5;
}
.subtab {
padding: 14px 18px;
font-size: 13px; font-weight: 500;
color: var(--ink-2);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
display: flex; align-items: center; gap: 8px;
}
.subtab:hover { color: var(--ink); }
.subtab.active { color: var(--orange); border-bottom-color: var(--orange); font-weight: 600; }
.subtab.disabled { color: var(--ink-3); cursor: not-allowed; opacity: .55; }
.subtab .num-circ {
width: 20px; height: 20px;
border: 1px solid currentColor;
border-radius: 50%;
font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700;
display: grid; place-items: center;
}
.subtab.done .num-circ { background: var(--ink); color: #FFF; border-color: var(--ink); }
.subtab.active .num-circ { background: var(--orange); color: #FFF; border-color: var(--orange); }
.subtab .arrow { color: var(--ink-3); margin: 0 4px; }
.subtab-meta { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .04em; }
/* Canvas inner */
.canvas-inner { padding: 24px 32px 40px; flex: 1; }
.canvas-h { margin-bottom: 18px; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.canvas-h h2 { font-size: 22px; font-weight: 700; letter-spacing: -.012em; }
.canvas-h p { font-size: 13px; color: var(--ink-2); margin-top: 4px; }
.canvas-h .step-tag { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); padding: 3px 8px; background: var(--bg-soft); border: 1px solid var(--border-soft); letter-spacing: .04em; flex-shrink: 0; }
.filter-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; flex-wrap: wrap; }
.search-input { position: relative; width: 280px; }
.search-input svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--ink-3); width: 14px; height: 14px; }
.search-input input { padding-left: 32px; height: 30px; font-size: 12.5px; background: var(--card); border: 1px solid var(--border); color: var(--ink); outline: none; width: 100%; }
.filter-chip { height: 30px; padding: 0 10px; border: 1px solid var(--border); background: var(--card); font-size: 12px; color: var(--ink-2); display: inline-flex; align-items: center; gap: 4px; cursor: pointer; font-family: inherit; }
.filter-chip:hover { background: var(--bg-soft); }
.filter-chip.active { border-color: var(--orange); color: var(--orange); background: var(--orange-tint); font-weight: 600; }
.filter-chip svg { width: 11px; height: 11px; }
.results-meta { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; }
.model-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; }
@media (max-width: 1400px) { .model-grid { grid-template-columns: repeat(5, 1fr); } }
@media (max-width: 1200px) { .model-grid { grid-template-columns: repeat(4, 1fr); } }
.model-card { aspect-ratio: 3/4; background: var(--card); border: 1px solid var(--border); cursor: pointer; position: relative; overflow: hidden; }
.model-card:hover { border-color: var(--ink-3); }
.model-card.selected { border: 2px solid var(--orange); }
.model-card.selected::after { content: '✓'; position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; background: var(--orange); color: #FFF; display: grid; place-items: center; font-weight: 700; font-size: 12px; z-index: 2; }
.model-card .placeholder { width: 100%; height: 100%; }
.model-card .lbl-bottom { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(255,255,255,.95); border-top: 1px solid var(--border); padding: 6px 8px; display: flex; flex-direction: column; gap: 2px; }
.model-card .lbl-bottom .nm { font-size: 12px; font-weight: 600; color: var(--ink); }
.model-card .lbl-bottom .tags { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: var(--ink-3); letter-spacing: .02em; }
.model-card .pose-tag { position: absolute; top: 6px; left: 6px; background: rgba(0,0,0,.55); color: #FFF; font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 1px 5px; letter-spacing: .04em; z-index: 1; }
/* Wear (sub-tab 2) */
.wear-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; max-width: 760px; }
.wear-card { aspect-ratio: 1; background: var(--card); border: 1px solid var(--border); cursor: pointer; position: relative; overflow: hidden; }
.wear-card:hover { border-color: var(--ink-3); }
.wear-card .placeholder { width: 100%; height: 100%; }
.wear-card.selected { border: 3px solid var(--orange); }
.wear-card.selected::after { content: '✓'; position: absolute; top: 12px; right: 12px; width: 32px; height: 32px; background: var(--orange); color: #FFF; display: grid; place-items: center; font-weight: 700; font-size: 16px; }
.wear-card .corner-info { position: absolute; bottom: 12px; left: 12px; background: rgba(255,255,255,.94); border: 1px solid var(--border); padding: 4px 10px; font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: .04em; color: var(--ink-2); }
/* Right panel */
.wb-controls { border-left: 1px solid var(--border); background: var(--bg); overflow-y: auto; padding: 22px 22px 100px; display: flex; flex-direction: column; position: relative; }
.ctrl-section { margin-bottom: 22px; }
.ctrl-section h3 { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .08em; text-transform: uppercase; font-weight: 600; margin-bottom: 10px; }
.hover-detail { background: var(--card); border: 1px solid var(--border); padding: 14px; }
.hover-detail .hd-name { font-size: 15px; font-weight: 700; }
.hover-detail .hd-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
.hover-detail .hd-tags span { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--ink-2); background: var(--bg-soft); border: 1px solid var(--border-soft); padding: 1px 5px; letter-spacing: .02em; }
.hover-detail .hd-views { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; margin-top: 12px; }
.hover-detail .hd-views .placeholder { aspect-ratio: 3/4; }
.hover-detail .hd-meta { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 11.5px; color: var(--ink-2); }
.hover-detail .hd-meta div { display: flex; gap: 8px; }
.hover-detail .hd-meta .k { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); width: 56px; letter-spacing: .04em; }
.selected-list { display: flex; flex-direction: column; gap: 6px; }
.sel-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: var(--card); border: 1px solid var(--border); }
.sel-row .placeholder { width: 28px; height: 36px; flex-shrink: 0; }
.sel-row .nm { flex: 1; font-size: 12px; font-weight: 600; }
.sel-row .x-btn { width: 18px; height: 18px; display: grid; place-items: center; cursor: pointer; color: var(--ink-3); }
.sel-row .x-btn:hover { color: var(--red); }
.tip-mini { padding: 10px 12px; background: var(--orange-tint); border: 1px solid var(--orange-soft); font-size: 11.5px; color: var(--ink-2); line-height: 1.5; }
.tip-mini strong { color: var(--orange); display: block; font-family: 'JetBrains Mono', monospace; font-size: 10px; letter-spacing: .04em; margin-bottom: 4px; }
.wb-bottom { position: absolute; left: 0; right: 0; bottom: 0; padding: 14px 22px; background: var(--card); border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
.wb-bottom .row1 { display: flex; align-items: center; justify-content: space-between; }
.cost-info { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; }
.cost-info .price { color: var(--orange); font-weight: 700; }
/* Step pane switching */
.sub-pane { display: none; }
.sub-pane.active { display: block; animation: fade .2s ease; }
@keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body>
<div class="demo-bar">
<a href="index.html" class="btn btn-ghost btn-sm">← 返回方案对比</a>
<span class="ti">方案 3 · 子步骤分屏(4 步主流程,Step 3 内部 2 个子 Tab)</span>
<span class="desc">// 顶部子 Tab 切换:挑选模特 → 生成上身图 · 在同一步骤内递进</span>
</div>
<div class="wb">
<header class="wb-top">
<div class="ti">透真玻尿酸补水面膜 <span>[ AI 生成模式 ]</span></div>
<div class="meta-line">// 美妆个护 · ¥39.9 · 已选头图 A · WHITE-BG</div>
<div class="right">
<button class="btn btn-sm">保存草稿</button>
<div class="x"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
</div>
</header>
<div class="wb-main">
<!-- Left: 4 main steps + sub items under step 3 -->
<aside class="wb-side">
<div class="side-section-h">// CREATE FLOW · 4 STEPS</div>
<div class="step-item done"><div class="num"></div><div class="info"><div class="ti2">商品信息</div><div class="sub">名称、品类、价格、卖点</div></div></div>
<div class="step-item done"><div class="num"></div><div class="info"><div class="ti2">生成头图</div><div class="sub">已选 1 / 4 · 候选 A</div></div></div>
<div class="step-item active">
<div class="num">3</div>
<div class="info"><div class="ti2">模特上身图</div><div class="sub">// 子流程 · ① 挑模特 → ② 生上身</div></div>
</div>
<div class="substep-list" id="sub-nav">
<div class="substep-item active" data-sub="1">
<span class="num-mini"></span>
<span>挑选模特</span>
<span style="margin-left:auto; font-family:'JetBrains Mono',monospace; font-size:10px; opacity:.7;">2 / 50+</span>
</div>
<div class="substep-item" data-sub="2">
<span class="num-mini"></span>
<span>生成上身图</span>
<span style="margin-left:auto; font-family:'JetBrains Mono',monospace; font-size:10px; opacity:.6;">待开始</span>
</div>
</div>
<div class="step-item"><div class="num">4</div><div class="info"><div class="ti2">完成创建</div><div class="sub">// 预览 · 提交</div></div></div>
<div class="side-divider"></div>
<div class="side-section-h">// SELECTED ASSETS</div>
<div style="padding: 0 20px;">
<div style="font-size:11.5px; color:var(--ink-3); margin-bottom:6px; font-family:'JetBrains Mono',monospace; letter-spacing:.02em;">原图 · 1 张</div>
<div class="placeholder" style="width:100%; aspect-ratio: 4/5; margin-bottom:12px;"><span class="ph-frame">补水面膜<br>1200×1500</span></div>
<div style="font-size:11.5px; color:var(--ink-3); margin-bottom:6px; font-family:'JetBrains Mono',monospace; letter-spacing:.02em;">头图 · 候选 A</div>
<div class="placeholder" style="width:100%; aspect-ratio: 1; margin-bottom:12px;"><span class="ph-frame">A · 白底简约</span></div>
</div>
</aside>
<!-- Center canvas: with sub-tab strip on top -->
<main class="wb-canvas">
<!-- Sub-tab strip -->
<div class="subtab-strip">
<div class="subtab active" data-sub="1">
<span class="num-circ"></span>
挑选模特
</div>
<span class="arrow" style="color:var(--ink-3); padding: 0 6px;"></span>
<div class="subtab disabled" data-sub="2">
<span class="num-circ"></span>
生成上身图
</div>
<span class="subtab-meta" id="subtab-meta">// SUB-STEP 1 / 2 · 已选模特 <span style="color:var(--orange); font-weight:700;" id="meta-count">2</span></span>
</div>
<div class="canvas-inner">
<!-- ============= SUB 1 · 挑选模特 ============= -->
<section class="sub-pane active" data-pane="1">
<div class="canvas-h">
<div>
<h2>挑选模特 · 子步骤 ①</h2>
<p>从 AI 模特库选 1 个或多个,统一白 T + 白短裤立绘。选好后点右下「下一步 → 生成上身图」自动跳到 ②。</p>
</div>
<span class="step-tag">// STEP 3 / 4 · SUB 1</span>
</div>
<div class="filter-bar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input type="text" placeholder="搜索模特名称、风格…">
</div>
<button class="filter-chip active">全部 <span style="font-family:'JetBrains Mono',monospace; opacity:.7;">52</span></button>
<button class="filter-chip">女性</button>
<button class="filter-chip">男性</button>
<button class="filter-chip">25-30 岁</button>
<button class="filter-chip">都市白领</button>
<button class="filter-chip">学生</button>
<button class="filter-chip">居家</button>
<button class="filter-chip">+ 更多筛选 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
<span class="results-meta">// 12 / 52 · 已选 <span id="sel-count" style="color:var(--orange); font-weight:700;">2</span></span>
</div>
<div class="model-grid" id="model-grid">
<div class="model-card selected" data-id="m1" onmouseenter="hoverModel('林夕','女 · 25-30','都市白领',['温柔','日常','OL','通勤'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">林夕 · 都市女</span></div>
<div class="lbl-bottom"><div class="nm">林夕</div><div class="tags">女 · 25-30 · 都市白领</div></div>
</div>
<div class="model-card" data-id="m2" onmouseenter="hoverModel('阿楠','女 · 25-30','姐妹/同事',['精致','干练','短发'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">阿楠</span></div>
<div class="lbl-bottom"><div class="nm">阿楠</div><div class="tags">女 · 25-30 · 同事</div></div>
</div>
<div class="model-card selected" data-id="m3" onmouseenter="hoverModel('小七','女 · 18-22','学生/Z世代',['青春','元气','校园'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">小七 · 学生</span></div>
<div class="lbl-bottom"><div class="nm">小七</div><div class="tags">女 · 18-22 · 学生</div></div>
</div>
<div class="model-card" data-id="m4" onmouseenter="hoverModel('王姐','女 · 38-45','妈妈/居家',['亲和','成熟','温暖'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">王姐 · 居家</span></div>
<div class="lbl-bottom"><div class="nm">王姐</div><div class="tags">女 · 38-45 · 妈妈</div></div>
</div>
<div class="model-card" data-id="m5" onmouseenter="hoverModel('阿杰','男 · 28-35','通勤男',['商务','干练','西装'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">阿杰 · 通勤男</span></div>
<div class="lbl-bottom"><div class="nm">阿杰</div><div class="tags">男 · 28-35 · 通勤</div></div>
</div>
<div class="model-card" data-id="m6" onmouseenter="hoverModel('阿强','男 · 22-28','健身男',['阳光','运动','肌肉'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">阿强 · 健身男</span></div>
<div class="lbl-bottom"><div class="nm">阿强</div><div class="tags">男 · 22-28 · 健身</div></div>
</div>
<div class="model-card" data-id="m7" onmouseenter="hoverModel('小苏','女 · 22-26','文艺研究生',['书卷气','静谧','文艺'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">小苏 · 文艺女</span></div>
<div class="lbl-bottom"><div class="nm">小苏</div><div class="tags">女 · 22-26 · 文艺</div></div>
</div>
<div class="model-card" data-id="m8" onmouseenter="hoverModel('文文','女 · 30-38','母亲',['温柔','母性','温暖'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">文文 · 母亲</span></div>
<div class="lbl-bottom"><div class="nm">文文</div><div class="tags">女 · 30-38 · 母亲</div></div>
</div>
<div class="model-card" data-id="m9" onmouseenter="hoverModel('老王','男 · 45-55','大叔',['沉稳','成熟','商务'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">老王 · 大叔</span></div>
<div class="lbl-bottom"><div class="nm">老王</div><div class="tags">男 · 45-55 · 大叔</div></div>
</div>
<div class="model-card" data-id="m10" onmouseenter="hoverModel('萌萌','女 · 18-22','Z世代',['可爱','潮流','二次元'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">萌萌 · Z世代</span></div>
<div class="lbl-bottom"><div class="nm">萌萌</div><div class="tags">女 · 18-22 · Z 世代</div></div>
</div>
<div class="model-card" data-id="m11" onmouseenter="hoverModel('Leo','男 · 30-38','健身教练',['专业','力量','健身房'])">
<span class="pose-tag">[ 立绘 · 肩上 ]</span>
<div class="placeholder"><span class="ph-frame">Leo · 教练</span></div>
<div class="lbl-bottom"><div class="nm">Leo</div><div class="tags">男 · 30-38 · 教练</div></div>
</div>
<div class="model-card" data-id="m12" onmouseenter="hoverModel('双人组合','双人 · 25-30','闺蜜',['亲密','日常','搭档'])">
<span class="pose-tag">[ 立绘 · 双人 ]</span>
<div class="placeholder"><span class="ph-frame">闺蜜双人</span></div>
<div class="lbl-bottom"><div class="nm">闺蜜组合</div><div class="tags">双人 · 25-30</div></div>
</div>
</div>
<div style="margin-top: 18px; padding: 12px 16px; background: var(--card); border: 1px dashed var(--border); text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 11.5px; color: var(--ink-3); letter-spacing: .02em;">
// 还有 40 个模特 · 滚动加载 / 用上方筛选缩小范围
</div>
</section>
<!-- ============= SUB 2 · 生成上身图 ============= -->
<section class="sub-pane" data-pane="2">
<div class="canvas-h">
<div>
<h2>生成上身图 · 子步骤 ②</h2>
<p>已为你选的每个模特生成 4 张上身图,可多选保留。可点 ← 回到「① 挑选模特」改人。</p>
</div>
<span class="step-tag">// STEP 3 / 4 · SUB 2</span>
</div>
<div style="display:flex; align-items:center; gap:8px; margin-bottom:14px; padding:10px 14px; background: var(--card); border: 1px solid var(--border);">
<span style="font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--ink-3); letter-spacing:.02em;">// 当前模特:</span>
<button class="filter-chip active" style="height: 26px;">林夕 · 都市白领</button>
<button class="filter-chip" style="height: 26px;">小七 · 学生</button>
<span style="margin-left:auto; font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--ink-3); letter-spacing:.02em;">// 切换模特看不同上身效果</span>
</div>
<div class="wear-grid">
<div class="wear-card selected"><div class="placeholder"><span class="ph-frame">上身 A · 持物半身</span></div><span class="corner-info">[ A · 持物半身 ]</span></div>
<div class="wear-card"><div class="placeholder"><span class="ph-frame">上身 B · 敷面膜中</span></div><span class="corner-info">[ B · 敷面膜中 ]</span></div>
<div class="wear-card selected"><div class="placeholder"><span class="ph-frame">上身 C · 镜前自拍</span></div><span class="corner-info">[ C · 镜前自拍 ]</span></div>
<div class="wear-card"><div class="placeholder"><span class="ph-frame">上身 D · 床边特写</span></div><span class="corner-info">[ D · 床边特写 ]</span></div>
</div>
<div style="margin-top: 18px; display: flex; align-items: center; gap: 12px;">
<button class="btn btn-sm">↻ 全部重新生成(¥0.40)</button>
<span style="font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--ink-3); letter-spacing:.02em;">// 不满意不扣费 · 已选 2 / 4 张</span>
</div>
</section>
</div>
</main>
<!-- Right controls -->
<aside class="wb-controls">
<div class="ctrl-section">
<h3>// 当前悬浮 · DETAIL</h3>
<div class="hover-detail" id="hover-detail">
<div class="hd-name">林夕</div>
<div class="hd-tags">
<span></span><span>25-30</span><span>都市白领</span><span>温柔</span><span>OL</span><span>通勤</span>
</div>
<div class="hd-views">
<div class="placeholder"><span class="ph-frame">正面</span></div>
<div class="placeholder"><span class="ph-frame">侧面</span></div>
<div class="placeholder"><span class="ph-frame">背面</span></div>
</div>
<div class="hd-meta">
<div><span class="k">// 服装</span><span>白 T + 白短裤(默认立绘)</span></div>
<div><span class="k">// 年龄</span><span>25-30 岁 · 可微调 ±3</span></div>
<div><span class="k">// 身材</span><span>普通女 · 165cm / 50kg</span></div>
<div><span class="k">// 用过</span><span>4 个项目 · 平均评分 4.5</span></div>
</div>
</div>
</div>
<div class="ctrl-section">
<h3>// 已选模特 · <span style="color:var(--orange); font-weight:700;" id="sel-count-3">2</span></h3>
<div class="selected-list" id="selected-list">
<div class="sel-row" data-id="m1">
<div class="placeholder"><span class="ph-frame"></span></div>
<div class="nm">林夕 · 都市白领</div>
<div class="x-btn" title="移除"></div>
</div>
<div class="sel-row" data-id="m3">
<div class="placeholder"><span class="ph-frame"></span></div>
<div class="nm">小七 · 学生</div>
<div class="x-btn" title="移除"></div>
</div>
</div>
</div>
<div class="ctrl-section">
<h3>// TIP</h3>
<div class="tip-mini">
<strong>子步骤的好处</strong>
挑模特 → 生上身在同一个 Step 内推进,左侧 sidebar 不变多。Sub Tab 可来回切换不丢已选。
</div>
</div>
<div class="wb-bottom">
<div class="row1">
<span class="cost-info">// 预计 <span class="price">¥0.80</span> / 8 张</span>
<span class="cost-info" id="bottom-meta">SUB 1 / 2</span>
</div>
<div class="hstack">
<button class="btn" style="flex:1;" id="btn-prev-sub">← 上一步</button>
<button class="btn btn-primary" style="flex:1;" id="btn-next-sub">下一步 → 生成上身</button>
</div>
</div>
</aside>
</div>
</div>
<script>
let curSub = 1;
function switchSub(n) {
curSub = n;
document.querySelectorAll('.sub-pane').forEach(p => p.classList.remove('active'));
document.querySelector(`[data-pane="${n}"]`).classList.add('active');
// sub tabs in canvas
document.querySelectorAll('.subtab').forEach(t => {
t.classList.remove('active', 'done', 'disabled');
const tn = +t.dataset.sub;
if (tn < n) t.classList.add('done');
else if (tn === n) t.classList.add('active');
});
// sub items in sidebar
document.querySelectorAll('.substep-item').forEach(t => {
t.classList.remove('active', 'done');
const tn = +t.dataset.sub;
if (tn < n) t.classList.add('done');
else if (tn === n) t.classList.add('active');
});
document.getElementById('subtab-meta').innerHTML = n === 1
? '// SUB-STEP 1 / 2 · 已选模特 <span style="color:var(--orange); font-weight:700;">2</span>'
: '// SUB-STEP 2 / 2 · 上身图 4 选 N';
document.getElementById('bottom-meta').textContent = `SUB ${n} / 2`;
document.getElementById('btn-next-sub').textContent = n === 1 ? '下一步 → 生成上身' : '下一步 → 完成创建';
document.querySelector('.wb-canvas').scrollTop = 0;
}
document.querySelectorAll('.subtab').forEach(t => {
t.onclick = () => {
if (t.classList.contains('disabled')) {
const n = +t.dataset.sub;
// allow click after step 1 satisfied
switchSub(n);
} else {
switchSub(+t.dataset.sub);
}
};
});
document.querySelectorAll('.substep-item').forEach(t => {
t.onclick = () => switchSub(+t.dataset.sub);
});
document.getElementById('btn-next-sub').onclick = () => {
if (curSub === 1) switchSub(2);
else alert('→ 进入 Step 4 完成创建');
};
document.getElementById('btn-prev-sub').onclick = () => {
if (curSub === 2) switchSub(1);
else alert('← 返回 Step 2 头图');
};
// multi select
document.querySelectorAll('.model-card').forEach(c => {
c.onclick = () => {
c.classList.toggle('selected');
updateCount();
};
});
function updateCount() {
const n = document.querySelectorAll('.model-card.selected').length;
document.getElementById('sel-count').textContent = n;
document.getElementById('sel-count-3').textContent = n;
document.getElementById('meta-count').textContent = n;
}
function hoverModel(name, base, role, tags) {
const hd = document.getElementById('hover-detail');
hd.querySelector('.hd-name').textContent = name;
hd.querySelector('.hd-tags').innerHTML = (base.split(' · ')).concat([role]).concat(tags).map(t => `<span>${t}</span>`).join('');
}
// wear cards multi select
document.querySelectorAll('.wear-card').forEach(c => {
c.onclick = () => c.classList.toggle('selected');
});
document.querySelectorAll('.x-btn').forEach(b => {
b.onclick = (e) => {
e.stopPropagation();
const row = b.closest('.sel-row');
const id = row.dataset.id;
document.querySelector(`.model-card[data-id="${id}"]`)?.classList.remove('selected');
row.remove();
updateCount();
};
});
</script>
</body>
</html>

476
v1/mockups/mockup-C.html Normal file
View File

@ -0,0 +1,476 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>方案 C · 双栏工作台 · 新建商品(AI 生成)</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../assets/restraint.css">
<style>
body { background: var(--bg); }
.demo-bar { padding: 16px 24px; background: var(--card); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 16px; font-family: 'Inter', sans-serif; }
.demo-bar .ti { font-weight: 700; font-size: 15px; }
.demo-bar .desc { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--ink-3); }
/* Workbench - full screen */
.wb {
height: calc(100vh - 60px);
display: grid;
grid-template-rows: 56px 1fr;
background: var(--bg);
}
/* Top bar */
.wb-top {
padding: 0 24px;
background: var(--card);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 18px;
}
.wb-top .ti { font-size: 15px; font-weight: 700; }
.wb-top .ti span { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--orange); background: var(--orange-tint); border: 1px solid var(--orange-soft); padding: 2px 6px; margin-left: 8px; letter-spacing: .04em; }
.wb-top .meta-line { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; }
.wb-top .right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.wb-top .x { width: 32px; height: 32px; display: grid; place-items: center; cursor: pointer; color: var(--ink-2); border: 1px solid var(--border); }
.wb-top .x:hover { color: var(--orange); border-color: var(--orange); }
/* Main split */
.wb-main { display: grid; grid-template-columns: 280px 1fr 340px; min-height: 0; }
/* Left sidebar - steps */
.wb-side {
border-right: 1px solid var(--border);
background: var(--bg);
overflow-y: auto;
padding: 18px 0;
}
.step-item {
padding: 14px 20px;
display: flex; align-items: flex-start; gap: 12px;
cursor: pointer;
border-left: 3px solid transparent;
position: relative;
}
.step-item:hover { background: var(--bg-soft); }
.step-item.active { background: var(--orange-tint); border-left-color: var(--orange); }
.step-item .num {
width: 24px; height: 24px;
border: 1px solid var(--border); background: var(--card);
color: var(--ink-3);
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700;
display: grid; place-items: center;
flex-shrink: 0;
}
.step-item.done .num { background: var(--ink); color: #FFF; border-color: var(--ink); }
.step-item.active .num { background: var(--orange); color: #FFF; border-color: var(--orange); }
.step-item .info { flex: 1; min-width: 0; }
.step-item .ti2 { font-size: 13.5px; font-weight: 600; color: var(--ink-2); }
.step-item.active .ti2 { color: var(--ink); }
.step-item .sub { font-size: 11px; color: var(--ink-3); margin-top: 3px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; line-height: 1.5; }
.step-item.done .sub { color: var(--ink-2); font-family: 'Inter', sans-serif; letter-spacing: 0; }
.side-divider { height: 1px; background: var(--border); margin: 14px 20px; }
.side-section-h { padding: 4px 20px 8px; font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .08em; text-transform: uppercase; font-weight: 600; }
/* Center canvas - big preview */
.wb-canvas {
background: var(--bg);
overflow-y: auto;
padding: 32px;
display: flex; flex-direction: column;
position: relative;
min-width: 0;
}
.canvas-h {
margin-bottom: 24px;
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
}
.canvas-h h2 { font-size: 22px; font-weight: 700; letter-spacing: -.012em; }
.canvas-h p { font-size: 13px; color: var(--ink-2); margin-top: 4px; }
.canvas-h .step-tag { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); padding: 3px 8px; background: var(--bg-soft); border: 1px solid var(--border-soft); letter-spacing: .04em; flex-shrink: 0; }
/* Center grid - bigger images */
.big-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; max-width: 720px; }
.big-card {
aspect-ratio: 1;
background: var(--card);
border: 1px solid var(--border);
cursor: pointer;
position: relative;
overflow: hidden;
}
.big-card:hover { border-color: var(--ink-3); }
.big-card .placeholder { width: 100%; height: 100%; }
.big-card.selected { border: 3px solid var(--orange); }
.big-card.selected::after {
content: '✓'; position: absolute; top: 12px; right: 12px;
width: 32px; height: 32px; background: var(--orange); color: #FFF;
display: grid; place-items: center; font-weight: 700; font-size: 16px;
}
.big-card .corner-info {
position: absolute; bottom: 12px; left: 12px;
background: rgba(255,255,255,.94);
border: 1px solid var(--border);
padding: 4px 10px;
font-family: 'JetBrains Mono', monospace; font-size: 11px;
letter-spacing: .04em; color: var(--ink-2);
}
/* Right panel - controls */
.wb-controls {
border-left: 1px solid var(--border);
background: var(--bg);
overflow-y: auto;
padding: 22px 22px 100px;
display: flex; flex-direction: column;
position: relative;
}
.ctrl-section { margin-bottom: 24px; }
.ctrl-section h3 {
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
color: var(--ink-3);
letter-spacing: .08em;
text-transform: uppercase;
font-weight: 600;
margin-bottom: 10px;
}
.ctrl-card {
background: var(--card);
border: 1px solid var(--border);
padding: 14px;
font-size: 12.5px;
color: var(--ink-2);
line-height: 1.6;
}
.ctrl-card strong { color: var(--ink); font-weight: 600; }
.selected-mini { display: flex; gap: 6px; margin-top: 8px; }
.selected-mini .placeholder { width: 36px; height: 36px; }
.selected-mini .placeholder.empty { background: transparent; border: 1px dashed var(--border); }
/* Model row in controls */
.model-row-side { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.model-card-side { aspect-ratio: 3/4; background: var(--card); border: 1px solid var(--border); cursor: pointer; position: relative; }
.model-card-side:hover { border-color: var(--ink-3); }
.model-card-side.selected { border: 2px solid var(--orange); }
.model-card-side.selected::after { content: '✓'; position: absolute; top: 4px; right: 4px; width: 16px; height: 16px; background: var(--orange); color: #FFF; display: grid; place-items: center; font-weight: 700; font-size: 9px; }
.model-card-side .placeholder { width: 100%; height: 100%; }
.model-card-side .lbl { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(255,255,255,.95); padding: 2px 4px; font-size: 9.5px; color: var(--ink-2); font-family: 'JetBrains Mono', monospace; text-align: center; letter-spacing: .02em; }
/* Bottom bar */
.wb-bottom {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 14px 22px;
background: var(--card);
border-top: 1px solid var(--border);
display: flex; flex-direction: column; gap: 10px;
}
.wb-bottom .row1 { display: flex; align-items: center; justify-content: space-between; }
.cost-info { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); letter-spacing: .04em; }
.cost-info .price { color: var(--orange); font-weight: 700; }
/* Step 1 form (used when step 1 active) */
.form-card { background: var(--card); border: 1px solid var(--border); padding: 22px; max-width: 720px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.upload-c {
aspect-ratio: 4/5;
background: var(--card);
border: 1.5px dashed var(--border);
display: grid; place-items: center;
text-align: center;
cursor: pointer;
color: var(--ink-2);
margin-bottom: 18px;
}
.upload-c.has-file { border-style: solid; padding: 0; border-color: var(--orange); background: var(--bg-soft); }
.upload-c.has-file .placeholder { width: 100%; height: 100%; }
.upload-c .ic-cam { width: 40px; height: 40px; border: 1px solid currentColor; display: grid; place-items: center; margin: 0 auto 12px; }
.bullet-list { list-style: none; padding: 0; }
.bullet-list li { display: flex; gap: 8px; align-items: center; padding: 8px 10px; background: var(--bg-soft); border: 1px solid var(--border); margin-bottom: 6px; font-size: 13px; }
.bullet-list .num { width: 18px; height: 18px; background: var(--card); border: 1px solid var(--border); font-size: 11px; color: var(--ink-2); display: grid; place-items: center; flex-shrink: 0; font-family: 'JetBrains Mono', monospace; }
/* Tip box */
.tip-mini { padding: 10px 12px; background: var(--orange-tint); border: 1px solid var(--orange-soft); font-size: 11.5px; color: var(--ink-2); margin-bottom: 18px; line-height: 1.5; }
.tip-mini strong { color: var(--orange); display: block; font-family: 'JetBrains Mono', monospace; font-size: 10px; letter-spacing: .04em; margin-bottom: 4px; }
</style>
</head>
<body>
<div class="demo-bar">
<a href="index.html" class="btn btn-ghost btn-sm">← 返回方案对比</a>
<span class="ti">方案 C · 双栏工作台(Canvas + Sidebar)</span>
<span class="desc">// 全屏工作台,左侧导航 + 中间大预览 + 右侧操作 · 视觉空间最大</span>
</div>
<div class="wb">
<!-- Top -->
<header class="wb-top">
<div class="ti">透真玻尿酸补水面膜 <span>[ AI 生成模式 ]</span></div>
<div class="meta-line">// 美妆个护 · ¥39.9 · 已上传原图</div>
<div class="right">
<button class="btn btn-sm">保存草稿</button>
<div class="x" onclick="alert('演示原型')"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
</div>
</header>
<div class="wb-main">
<!-- Left sidebar -->
<aside class="wb-side">
<div class="side-section-h">// CREATE FLOW</div>
<div class="step-item done" data-step="1">
<div class="num"></div>
<div class="info">
<div class="ti2">商品信息</div>
<div class="sub">名称、品类、价格、卖点</div>
</div>
</div>
<div class="step-item active" data-step="2">
<div class="num">2</div>
<div class="info">
<div class="ti2">生成头图</div>
<div class="sub">// AI 生成 · 4 选 1</div>
</div>
</div>
<div class="step-item" data-step="3">
<div class="num">3</div>
<div class="info">
<div class="ti2">模特上身图</div>
<div class="sub">// 选模特 + 4 选 N</div>
</div>
</div>
<div class="step-item" data-step="4">
<div class="num">4</div>
<div class="info">
<div class="ti2">完成创建</div>
<div class="sub">// 预览 · 提交</div>
</div>
</div>
<div class="side-divider"></div>
<div class="side-section-h">// SELECTED ASSETS</div>
<div style="padding: 0 20px;">
<div style="font-size:11.5px; color:var(--ink-3); margin-bottom:8px; font-family:'JetBrains Mono',monospace; letter-spacing:.02em;">原图 · 1 张</div>
<div class="placeholder" style="width:100%; aspect-ratio: 4/5; margin-bottom:14px;"><span class="ph-frame">补水面膜<br>1200×1500</span></div>
<div style="font-size:11.5px; color:var(--ink-3); font-family:'JetBrains Mono',monospace; letter-spacing:.02em;">头图 · 待选</div>
</div>
</aside>
<!-- Center canvas (changes per step) -->
<main class="wb-canvas" id="canvas">
<!-- STEP 2 default view: 生成头图 -->
<div class="canvas-h">
<div>
<h2>选一张你最满意的头图</h2>
<p>AI 已基于原图生成 4 张候选,点击你想要的那张。可以重新生成。</p>
</div>
<span class="step-tag">// STEP 2 / 4 · HEAD IMAGES</span>
</div>
<div class="big-grid" id="head-grid">
<div class="big-card selected" data-id="h1">
<div class="placeholder"><span class="ph-frame">候选 A · 白底简约 · 适合电商详情</span></div>
<span class="corner-info">[ A · 1920×1920 · WHITE-BG ]</span>
</div>
<div class="big-card" data-id="h2">
<div class="placeholder"><span class="ph-frame">候选 B · 木纹背景 · 自然温暖</span></div>
<span class="corner-info">[ B · 1920×1920 · WOOD ]</span>
</div>
<div class="big-card" data-id="h3">
<div class="placeholder"><span class="ph-frame">候选 C · 浅米石材 · 高级质感</span></div>
<span class="corner-info">[ C · 1920×1920 · STONE ]</span>
</div>
<div class="big-card" data-id="h4">
<div class="placeholder"><span class="ph-frame">候选 D · 暖光氛围 · 生活场景</span></div>
<span class="corner-info">[ D · 1920×1920 · AMBIENT ]</span>
</div>
</div>
</main>
<!-- Right controls -->
<aside class="wb-controls">
<div class="ctrl-section">
<h3>// 当前选中</h3>
<div class="ctrl-card">
<strong>候选 A · 白底简约</strong>
<div style="margin-top:6px; color:var(--ink-3); font-family:'JetBrains Mono',monospace; font-size:11px; letter-spacing:.02em;">1920×1920 · 适合主图</div>
</div>
</div>
<div class="ctrl-section">
<h3>// 重新生成参数</h3>
<div class="field" style="margin-bottom:10px;">
<label class="field-label" style="font-size:11.5px;">背景风格</label>
<select class="select" style="height:32px; font-size:12px;">
<option>纯白底(电商主图)</option>
<option>木纹/石材</option>
<option>居家场景</option>
<option>自定义</option>
</select>
</div>
<div class="field" style="margin-bottom:10px;">
<label class="field-label" style="font-size:11.5px;">画面氛围</label>
<select class="select" style="height:32px; font-size:12px;">
<option>明亮简洁</option>
<option>暖光氛围</option>
<option>冷调高级</option>
</select>
</div>
<button class="btn" style="width:100%; margin-top:6px;">↻ 用以上参数重新生成</button>
</div>
<div class="ctrl-section">
<h3>// 提示</h3>
<div class="tip-mini">
<strong>不满意不扣费</strong>
重新生成 4 张约 ¥0.20。已生成共 4 张,确认后才扣费。
</div>
</div>
<div class="wb-bottom">
<div class="row1">
<span class="cost-info">// 累计 <span class="price">¥0.20</span></span>
<span class="cost-info">2 / 4</span>
</div>
<div class="hstack">
<button class="btn" style="flex:1;">← 上一步</button>
<button class="btn btn-primary" style="flex:1;" id="next-btn">下一步 →</button>
</div>
</div>
</aside>
</div>
</div>
<script>
// single select for head
document.querySelectorAll('#head-grid .big-card').forEach(c => {
c.onclick = () => {
document.querySelectorAll('#head-grid .big-card').forEach(x => x.classList.remove('selected'));
c.classList.add('selected');
};
});
// Step navigation
document.querySelectorAll('.step-item').forEach(item => {
item.onclick = () => {
const step = +item.dataset.step;
switchStep(step);
};
});
document.getElementById('next-btn').onclick = () => {
const cur = document.querySelector('.step-item.active');
const n = +cur.dataset.step;
if (n < 4) switchStep(n + 1);
else alert('✓ 商品已创建!');
};
function switchStep(n) {
document.querySelectorAll('.step-item').forEach((s, i) => {
s.classList.remove('active', 'done');
if (i + 1 < n) s.classList.add('done');
if (i + 1 === n) s.classList.add('active');
});
renderCanvas(n);
}
function renderCanvas(n) {
const c = document.getElementById('canvas');
if (n === 1) {
c.innerHTML = `
<div class="canvas-h">
<div><h2>填写商品信息 · 上传原图</h2><p>上传一张你拍的商品图(任何角度都行,AI 会优化),并填写商品基本信息。</p></div>
<span class="step-tag">// STEP 1 / 4 · INFO + UPLOAD</span>
</div>
<div style="display:grid; grid-template-columns: 280px 1fr; gap:24px; max-width: 920px;">
<div>
<div class="upload-c has-file"><div class="placeholder"><span class="ph-frame">补水面膜.jpg<br>1200×1500</span></div></div>
<div style="font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--ink-3); text-align:center; letter-spacing:.02em;">// 已上传 · 点击重选</div>
</div>
<div class="form-card">
<div class="field"><label class="field-label">商品名称<span class="req">*</span></label><input class="input" value="透真玻尿酸补水面膜"></div>
<div class="field-row">
<div class="field"><label class="field-label">品类</label><select class="select"><option>美妆个护</option></select></div>
<div class="field"><label class="field-label">参考价</label><input class="input" value="¥39.9"></div>
</div>
<div class="field"><label class="field-label">核心卖点<span class="req">*</span></label>
<ul class="bullet-list">
<li><span class="num">1</span> 透明质酸 + B5,敷完不黏不闷</li>
<li><span class="num">2</span> 30g 大容量精华液</li>
<li><span class="num">+</span> <input class="input" style="height:24px; border:0; padding:0 4px; background:transparent;" placeholder="添加新卖点"></li>
</ul>
</div>
<div class="field"><label class="field-label">目标人群</label><input class="input" value="22-32 岁女性、熬夜党、敏感肌"></div>
</div>
</div>
`;
} else if (n === 2) {
c.innerHTML = `
<div class="canvas-h">
<div><h2>选一张你最满意的头图</h2><p>AI 已基于原图生成 4 张候选,点击你想要的那张。可以重新生成。</p></div>
<span class="step-tag">// STEP 2 / 4 · HEAD IMAGES</span>
</div>
<div class="big-grid" id="head-grid">
<div class="big-card selected" data-id="h1"><div class="placeholder"><span class="ph-frame">候选 A · 白底简约</span></div><span class="corner-info">[ A · 1920×1920 ]</span></div>
<div class="big-card" data-id="h2"><div class="placeholder"><span class="ph-frame">候选 B · 木纹背景</span></div><span class="corner-info">[ B · 1920×1920 ]</span></div>
<div class="big-card" data-id="h3"><div class="placeholder"><span class="ph-frame">候选 C · 浅米石材</span></div><span class="corner-info">[ C · 1920×1920 ]</span></div>
<div class="big-card" data-id="h4"><div class="placeholder"><span class="ph-frame">候选 D · 暖光氛围</span></div><span class="corner-info">[ D · 1920×1920 ]</span></div>
</div>
`;
bindBig('#head-grid');
} else if (n === 3) {
c.innerHTML = `
<div class="canvas-h">
<div><h2>选模特,生成上身图</h2><p>选好模特后,AI 会让 ta 持你刚选的商品图生成 4 张参考图,可多选。</p></div>
<span class="step-tag">// STEP 3 / 4 · MODEL + WEAR</span>
</div>
<div class="big-grid" id="wear-grid" style="grid-template-columns: repeat(2,1fr);">
<div class="big-card selected" data-id="w1"><div class="placeholder"><span class="ph-frame">上身 A · 持物半身</span></div><span class="corner-info">[ A · 持物半身 ]</span></div>
<div class="big-card" data-id="w2"><div class="placeholder"><span class="ph-frame">上身 B · 敷面膜中</span></div><span class="corner-info">[ B · 敷面膜中 ]</span></div>
<div class="big-card selected" data-id="w3"><div class="placeholder"><span class="ph-frame">上身 C · 镜前自拍</span></div><span class="corner-info">[ C · 镜前自拍 ]</span></div>
<div class="big-card" data-id="w4"><div class="placeholder"><span class="ph-frame">上身 D · 床边特写</span></div><span class="corner-info">[ D · 床边特写 ]</span></div>
</div>
`;
bindMulti('#wear-grid');
} else if (n === 4) {
c.innerHTML = `
<div class="canvas-h">
<div><h2>预览 · 创建</h2><p>检查下面的素材和信息无误后,点击右下「创建商品」。</p></div>
<span class="step-tag">// STEP 4 / 4 · CONFIRM</span>
</div>
<div style="background:var(--card); border:1px solid var(--border); padding:32px; max-width: 760px;">
<h3 style="font-size:20px; font-weight:700;">透真玻尿酸补水面膜</h3>
<div style="font-family:'JetBrains Mono',monospace; font-size:11.5px; color:var(--ink-3); margin-top:4px; letter-spacing:.02em;">// 美妆个护 · ¥39.9 · AI 生成 · 模特: 林夕</div>
<div style="display:grid; grid-template-columns: repeat(4,1fr); gap:10px; margin-top:18px;">
<div class="placeholder" style="aspect-ratio:1;"><span class="ph-frame">原图</span></div>
<div class="placeholder" style="aspect-ratio:1;"><span class="ph-frame">头图 A</span></div>
<div class="placeholder" style="aspect-ratio:1;"><span class="ph-frame">上身 A</span></div>
<div class="placeholder" style="aspect-ratio:1;"><span class="ph-frame">上身 C</span></div>
</div>
</div>
`;
}
}
function bindBig(sel) {
document.querySelectorAll(sel + ' .big-card').forEach(c => {
c.onclick = () => {
document.querySelectorAll(sel + ' .big-card').forEach(x => x.classList.remove('selected'));
c.classList.add('selected');
};
});
}
function bindMulti(sel) {
document.querySelectorAll(sel + ' .big-card').forEach(c => {
c.onclick = () => c.classList.toggle('selected');
});
}
bindBig('#head-grid');
</script>
</body>
</html>

783
v1/pipeline.html Normal file
View File

@ -0,0 +1,783 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>补水面膜 · v3 · 流水线 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css">
<style>
/* ─── Project header ─── */
.proj-head { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 22px; align-items: flex-start; }
.proj-head h1 { font-size: 20px; font-weight: 700; letter-spacing: -.012em; }
/* ─── Stepper ─── */
.stepper { display: flex; align-items: center; gap: 0; margin-bottom: 28px; padding: 14px 18px; background: var(--card); border: 1px solid var(--border); position: relative; }
.stepper::before, .stepper::after { content: '+'; position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 13px; line-height: 1; }
.stepper::before { top: -8px; left: -8px; }
.stepper::after { bottom: -8px; right: -8px; }
.stepper .corner-tr, .stepper .corner-bl { position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 13px; }
.stepper .corner-tr { top: -8px; right: -8px; }
.stepper .corner-bl { bottom: -8px; left: -8px; }
.stage-step { display: flex; align-items: center; gap: 10px; padding: 6px 0; cursor: pointer; user-select: none; }
.stage-step .num { width: 26px; height: 26px; display: grid; place-items: center; font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 600; border: 1px solid var(--border); background: var(--card); color: var(--ink-3); flex-shrink: 0; }
.stage-step.done .num { background: var(--ink); border-color: var(--ink); color: #FFF; }
.stage-step.active .num { background: var(--orange); border-color: var(--orange); color: #FFF; }
.stage-step.locked { opacity: .5; cursor: not-allowed; }
.stage-step .lbl { font-size: 13px; font-weight: 500; color: var(--ink-2); }
.stage-step.active .lbl { color: var(--ink); font-weight: 600; }
.stage-step .st { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); margin-left: 4px; padding: 1px 6px; background: var(--bg-soft); border: 1px solid var(--border-soft); letter-spacing: .04em; }
.stage-step.active .st { background: var(--orange-tint); color: var(--orange); border-color: var(--orange-soft); }
.stage-line { flex: 1; height: 1px; background: var(--border); margin: 0 14px; min-width: 30px; }
.stage-line.done { background: var(--ink); }
/* ─── Stage panes ─── */
.stage { display: none; }
.stage.active { display: block; }
/* Common pane */
.pane { background: var(--card); border: 1px solid var(--border); }
.pane-h { display: flex; align-items: center; gap: 8px; padding: 14px 18px; border-bottom: 1px solid var(--border); }
.pane-h strong { font-size: 14px; font-weight: 600; }
/* Stage foot */
.stage-foot { display: flex; justify-content: space-between; align-items: center; padding: 18px 0 0; margin-top: 18px; border-top: 1px solid var(--border); }
.stage-foot .info { font-size: 12.5px; color: var(--ink-2); }
.stage-foot .info .mono { font-family: 'JetBrains Mono', monospace; color: var(--ink-3); font-size: 11.5px; letter-spacing: .02em; }
/* === STAGE 1 · 脚本 === */
.stage-script { display: grid; grid-template-columns: 1fr 1.2fr; gap: 16px; min-height: 560px; }
.chat-pane { display: flex; flex-direction: column; }
.chat-body { padding: 16px 18px; flex: 1; overflow-y: auto; max-height: 460px; display: flex; flex-direction: column; gap: 14px; }
.msg .bubble { max-width: 90%; padding: 10px 14px; font-size: 13px; line-height: 1.6; border: 1px solid var(--border); }
.msg.ai .bubble { background: var(--card); }
.msg.user { display: flex; flex-direction: column; align-items: flex-end; }
.msg.user .bubble { background: var(--orange-tint); color: var(--ink); border-color: var(--orange-soft); }
.msg .time { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); margin-top: 4px; letter-spacing: .02em; }
.msg .actions { display: flex; gap: 6px; margin-top: 6px; }
.ai-avatar { width: 26px; height: 26px; background: var(--orange); color: #FFF; display: grid; place-items: center; font-size: 11px; font-weight: 700; border: 1px solid var(--orange); }
.del { text-decoration: line-through; color: var(--ink-3); }
.ins { background: var(--green-bg); color: var(--green); padding: 0 3px; }
.chat-input { padding: 14px 18px; border-top: 1px solid var(--border); }
.shot-list { display: flex; flex-direction: column; }
.shots-body { padding: 12px 16px; flex: 1; overflow-y: auto; max-height: 540px; display: flex; flex-direction: column; gap: 10px; }
.shot-card { background: var(--bg); border: 1px solid var(--border); padding: 12px 14px; }
.shot-card.highlight { border-color: var(--orange); background: var(--orange-tint); }
.shot-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.shot-num { width: 22px; height: 22px; background: var(--ink); color: #FFF; display: grid; place-items: center; font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; }
.shot-time { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); padding: 2px 6px; background: var(--card); border: 1px solid var(--border); }
.shot-row { display: grid; grid-template-columns: 36px 1fr; gap: 8px; padding: 4px 0; }
.shot-k { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); padding-top: 2px; letter-spacing: .04em; }
.shot-v { font-size: 12.5px; color: var(--ink); line-height: 1.55; }
.icon-mini-btn { width: 24px; height: 24px; display: grid; place-items: center; color: var(--ink-3); background: var(--card); border: 1px solid var(--border); cursor: pointer; font-size: 14px; }
.icon-mini-btn:hover { color: var(--orange); border-color: var(--orange); }
/* === STAGE 2 · 基础资产 === */
.stage-assets { display: grid; grid-template-columns: 200px 1fr; gap: 24px; }
.asset-side .ttab { padding: 10px 12px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 8px; border: 1px solid transparent; }
.asset-side .ttab:hover { background: var(--bg-soft); }
.asset-side .ttab.active { background: var(--orange-tint); color: var(--orange); border-color: var(--orange-soft); font-weight: 600; }
.asset-side .ttab .num { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); margin-left: auto; }
.asset-side .ttab.active .num { color: var(--orange); }
.asset-side .info { font-size: 12px; color: var(--ink-3); padding: 14px 12px; line-height: 1.6; margin-top: 14px; border-top: 1px solid var(--border); }
.asset-side .info strong { color: var(--ink-2); display: block; }
.asset-side .info .mono { font-family: 'JetBrains Mono', monospace; }
.asset-grid-2 { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.asset-card-2 { background: var(--card); border: 1px solid var(--border); }
.asset-card-2 .thumb-2 { aspect-ratio: 1; }
.asset-card-2 .body-2 { padding: 12px 14px; }
.prompt-box { background: var(--bg); border: 1px solid var(--border); padding: 8px 10px; font-size: 12px; color: var(--ink-2); margin-top: 8px; line-height: 1.55; font-family: 'JetBrains Mono', monospace; letter-spacing: .01em; }
.fail-icon { width: 28px; height: 28px; background: var(--red); color: #FFF; display: grid; place-items: center; font-weight: 700; font-size: 16px; }
/* === STAGE 3 · 故事板 === */
.stage-storyboard { display: grid; grid-template-columns: 1.7fr 1fr; gap: 16px; align-items: start; }
.sb-canvas { background: var(--card); border: 1px solid var(--border); }
.sb-row { display: grid; grid-template-columns: 60px 1fr 1fr; gap: 0; border-bottom: 1px solid var(--border); }
.sb-row:last-child { border-bottom: 0; }
.sb-row.head { background: var(--bg-soft); font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .04em; text-transform: uppercase; }
.sb-row.head > div { padding: 10px 14px; }
.sb-num { padding: 14px; font-family: 'JetBrains Mono', monospace; font-size: 14px; font-weight: 700; color: var(--ink); border-right: 1px solid var(--border); }
.sb-num .t { display: block; font-size: 10.5px; color: var(--ink-3); font-weight: 400; margin-top: 4px; letter-spacing: .02em; }
.sb-img { padding: 10px; border-right: 1px solid var(--border); }
.sb-img .placeholder { aspect-ratio: 16/9; }
.sb-text { padding: 14px; display: flex; flex-direction: column; gap: 6px; }
.sb-text .meta { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .04em; }
.sb-text .dialog { font-size: 12.5px; color: var(--ink); line-height: 1.55; }
.sb-text .sfx { font-size: 11.5px; color: var(--ink-3); }
.sb-side .pane { padding: 18px; }
.prompt-edit { background: var(--bg); border: 1px solid var(--border); padding: 10px 12px; font-family: 'JetBrains Mono', monospace; font-size: 11.5px; line-height: 1.7; color: var(--ink-2); white-space: pre-wrap; min-height: 200px; outline: none; letter-spacing: .01em; }
.prompt-edit:focus { border-color: var(--orange); box-shadow: 0 0 0 3px rgba(229, 91, 38, 0.10); }
.asset-tag { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; background: var(--bg-soft); border: 1px solid var(--border); font-size: 11.5px; }
.asset-tag .dotc { width: 14px; height: 14px; background: var(--card); border: 1px solid var(--border); }
/* === STAGE 4 · 视频片段 === */
.queue-bar { display: flex; align-items: center; gap: 16px; padding: 14px 18px; background: var(--card); border: 1px solid var(--border); margin-bottom: 18px; }
.queue-bar .bar-wrap { flex: 1; height: 6px; background: var(--bg-soft); overflow: hidden; }
.queue-bar .bar-wrap > span { display: block; height: 100%; background: var(--orange); }
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
.video-card { background: var(--card); border: 1px solid var(--border); }
.video-thumb { aspect-ratio: 9/16; max-height: 320px; position: relative; }
.video-thumb .play { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(0,0,0,0.05); cursor: pointer; opacity: 0; transition: opacity .15s; }
.video-thumb:hover .play { opacity: 1; }
.video-thumb .btn-play { width: 36px; height: 36px; background: rgba(0,0,0,.7); color: #FFF; border-radius: 50%; display: grid; place-items: center; }
.video-card .body { padding: 10px 12px; }
/* === STAGE 5 · 编辑器 === */
.editor { display: grid; grid-template-columns: 1fr 280px; grid-template-rows: 1fr auto; gap: 0; height: 580px; background: var(--card); border: 1px solid var(--border); }
.editor-preview { padding: 16px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 12px; }
.editor-preview .canvas { flex: 1; aspect-ratio: 9/16; max-height: 380px; margin: 0 auto; background:
repeating-linear-gradient(135deg, rgba(0,0,0,0.03) 0 1px, transparent 1px 12px),
var(--bg-soft);
border: 1px solid var(--border);
display: grid; place-items: center;
color: var(--ink-3);
font-family: 'JetBrains Mono', monospace;
font-size: 12px; }
.editor-preview .controls { display: flex; align-items: center; gap: 8px; justify-content: center; }
.ctl-btn { width: 32px; height: 32px; border: 1px solid var(--border); background: var(--card); color: var(--ink-2); display: grid; place-items: center; cursor: pointer; }
.ctl-btn:hover { color: var(--orange); border-color: var(--orange); }
.editor-props { padding: 16px; border-bottom: 1px solid var(--border); overflow-y: auto; }
.props-tabs { display: flex; gap: 0; margin-bottom: 14px; border-bottom: 1px solid var(--border); }
.props-tabs > div { padding: 8px 12px; font-size: 12.5px; color: var(--ink-2); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.props-tabs > div.active { color: var(--orange); border-bottom-color: var(--orange); font-weight: 600; }
.style-swatch { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.swatch-card { padding: 10px; border: 1px solid var(--border); cursor: pointer; }
.swatch-card:hover { background: var(--bg-soft); }
.swatch-card.selected { border-color: var(--orange); background: var(--orange-tint); }
.swatch-card .demo { font-size: 12px; padding: 6px 8px; background: var(--card); border: 1px solid var(--border); margin-bottom: 4px; text-align: center; }
.swatch-card .demo.b { background: #000; color: #FFF; font-family: serif; }
.swatch-card .demo.c { color: var(--orange); -webkit-text-stroke: 0.5px var(--ink); }
.swatch-card .demo.d { background: #FFC700; color: var(--ink); font-weight: 700; }
.swatch-card .nm { font-size: 11px; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
.props-row { display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 12.5px; }
.props-row:last-child { border-bottom: 0; }
.props-row .k { color: var(--ink-3); flex: 1; font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: .02em; }
.input-mini { width: 90px; padding: 4px 8px; height: 26px; font-size: 12px; border-radius: 0; background: var(--card); border: 1px solid var(--border); }
.timeline { grid-column: 1 / -1; padding: 14px 16px; background: var(--bg); }
.tl-toolbar { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
.tl-ruler { display: grid; grid-template-columns: 80px 1fr; align-items: center; padding: 4px 0; font-size: 10.5px; }
.tl-ruler .l { font-family: 'JetBrains Mono', monospace; color: var(--ink-3); padding-left: 4px; }
.tl-ruler .ticks { display: flex; justify-content: space-between; font-family: 'JetBrains Mono', monospace; color: var(--ink-3); padding: 0 4px; letter-spacing: .04em; }
.tl-track { display: grid; grid-template-columns: 80px 1fr; align-items: center; gap: 0; padding: 6px 0; }
.tl-track .label { font-size: 11.5px; color: var(--ink-2); display: flex; align-items: center; gap: 6px; padding-left: 4px; }
.tl-track .label .dot { width: 8px; height: 8px; }
.tl-track .lane { display: flex; gap: 2px; height: 30px; position: relative; }
.clip { padding: 0 8px; font-size: 11px; display: flex; align-items: center; cursor: pointer; overflow: hidden; white-space: nowrap; user-select: none; }
.clip.video { background: rgba(229, 91, 38, 0.12); border: 1px solid rgba(229, 91, 38, 0.3); color: var(--orange); }
.clip.video.selected { background: var(--orange); color: #FFF; border-color: var(--orange-hover); }
.clip.subtitle { background: rgba(63, 107, 63, 0.10); border: 1px solid rgba(63, 107, 63, 0.3); color: var(--green); }
.clip.bgm { background: rgba(245, 165, 36, 0.12); border: 1px solid rgba(245, 165, 36, 0.3); color: #B8651A; }
.clip .num { font-family: 'JetBrains Mono', monospace; font-weight: 700; margin-right: 6px; opacity: .7; }
.playhead { position: absolute; top: -16px; bottom: -54px; width: 1px; background: var(--orange); pointer-events: none; }
.playhead::before { content: ''; position: absolute; top: -2px; left: -4px; width: 9px; height: 9px; background: var(--orange); transform: rotate(45deg); }
</style>
</head>
<body>
<div id="page">
<!-- Project header -->
<div class="proj-head">
<div style="display:flex; gap:14px; align-items:center;">
<div class="placeholder" style="width:42px;height:54px;"><span class="ph-frame">9:16</span></div>
<div>
<div style="display:flex; gap:8px; align-items:center;">
<h1>补水面膜 · 痛点种草 · v3</h1>
<span class="pill info"><span class="dot"></span>进行中</span>
</div>
<div class="muted-2 mono" style="font-size:11.5px; margin-top:4px; letter-spacing:.02em;">// 透真补水面膜 · AI 全生 · 6 镜 · 0-15s · 9:16</div>
</div>
</div>
<div class="hstack">
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('分享', '/projects/p3/share')">分享</button>
<button class="btn btn-sm" onclick="Shell.toast('已复制项目', '补水面膜 · v4')">复制项目</button>
<button class="btn btn-sm" onclick="Shell.toast('归档项目', '/projects/p3/archive')">归档</button>
</div>
</div>
<!-- Stage stepper -->
<div class="stepper">
<span class="corner-tr">+</span><span class="corner-bl">+</span>
<a class="stage-step done" data-stage="1" href="#stage-1"><div class="num">1</div><div class="lbl">脚本</div><div class="st">已确认</div></a>
<div class="stage-line done"></div>
<a class="stage-step done" data-stage="2" href="#stage-2"><div class="num">2</div><div class="lbl">基础资产</div><div class="st">已确认</div></a>
<div class="stage-line done"></div>
<a class="stage-step active" data-stage="3" href="#stage-3"><div class="num">3</div><div class="lbl">故事板</div><div class="st">待确认</div></a>
<div class="stage-line"></div>
<a class="stage-step" data-stage="4" href="#stage-4"><div class="num">4</div><div class="lbl">视频片段</div><div class="st">未开始</div></a>
<div class="stage-line"></div>
<a class="stage-step" data-stage="5" href="#stage-5"><div class="num">5</div><div class="lbl">拼接导出</div><div class="st">未开始</div></a>
</div>
<!-- ============= STAGE 1 · 脚本 ============= -->
<section class="stage" data-stage-pane="1">
<div class="stage-script">
<div class="pane chat-pane">
<div class="pane-h">
<div class="ai-avatar">AI</div>
<strong>脚本助手</strong>
<span class="muted-2 mono" style="font-size:11px;">· GPT-4o</span>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('已清空对话')">清空对话</button>
</div>
<div class="chat-body">
<div class="msg ai">
<div style="display:flex; gap:10px; align-items:flex-start;">
<div class="ai-avatar" style="margin-top:2px;">AI</div>
<div class="bubble">根据 <strong>透真补水面膜</strong> 和"痛点种草"风格,我先生成了一版 6 镜的脚本,主线是"加班党的深夜急救"。你可以直接编辑右侧的镜头,或者让我重写某一镜。</div>
</div>
<div class="time" style="margin-left:36px;">14:02</div>
</div>
<div class="msg user">
<div class="bubble">第 4 镜对白太硬了,能不能更口语化?</div>
<div class="time">14:05</div>
</div>
<div class="msg ai">
<div style="display:flex; gap:10px; align-items:flex-start;">
<div class="ai-avatar" style="margin-top:2px;">AI</div>
<div>
<div class="bubble">把 "<span class="del">补水力极强,锁水持久</span>" 改成 "<span class="ins">真的,敷完第二天起来脸是软的,不是绷着的</span>",更像真实分享。已替换右侧第 4 镜,确认要的话点 [接受]。</div>
<div class="actions">
<button class="btn btn-sm" onclick="Shell.toast('已接受改动', 'shot-4')">接受</button>
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('再来一版')">再来一版</button>
</div>
</div>
</div>
<div class="time" style="margin-left:36px;">14:05</div>
</div>
</div>
<div class="chat-input">
<textarea class="textarea" placeholder="对脚本的修改诉求 · 比如:让第 5 镜更夸张一点、整体加 1 镜结尾……" rows="2"></textarea>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm">↻ 整体重写</button>
<span class="spacer"></span>
<button class="btn btn-primary" onclick="Shell.toast('已发送', 'POST /chat')">发送 ⌘↵</button>
</div>
</div>
</div>
<div class="pane shot-list">
<div class="pane-h">
<strong>镜头脚本</strong>
<span class="muted-2 mono" style="font-size:11px;">· 6 镜 · 0-15s</span>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('已加一镜')">+ 加一镜</button>
</div>
<div class="shots-body">
<div class="shot-card">
<div class="shot-head"><div class="shot-num">1</div><div class="shot-time">0-2s</div><span class="spacer"></span><button class="icon-mini-btn" title="重写"></button></div>
<div class="shot-row"><span class="shot-k">画面</span><div class="shot-v">深夜的办公桌,电脑屏幕亮着,女主对着镜子叹气,皮肤干燥起皮特写。</div></div>
<div class="shot-row"><span class="shot-k">对白</span><div class="shot-v">(叹气)"加班三天,脸已经不能看了……"</div></div>
</div>
<div class="shot-card">
<div class="shot-head"><div class="shot-num">2</div><div class="shot-time">2-5s</div><span class="spacer"></span><button class="icon-mini-btn"></button></div>
<div class="shot-row"><span class="shot-k">画面</span><div class="shot-v">女主从抽屉拿出补水面膜,包装特写,光线柔和。</div></div>
<div class="shot-row"><span class="shot-k">对白</span><div class="shot-v">"还好我有这个 —— 透真玻尿酸面膜。"</div></div>
</div>
<div class="shot-card">
<div class="shot-head"><div class="shot-num">3</div><div class="shot-time">5-8s</div><span class="spacer"></span><button class="icon-mini-btn"></button></div>
<div class="shot-row"><span class="shot-k">画面</span><div class="shot-v">面膜布展开,30g 精华液从布上滴落特写,慢镜头。</div></div>
<div class="shot-row"><span class="shot-k">对白</span><div class="shot-v">"30g 精华液,一片顶三片的量。"</div></div>
</div>
<div class="shot-card highlight">
<div class="shot-head"><div class="shot-num">4</div><div class="shot-time">8-11s</div><span class="pill info" style="margin-left:6px;"><span class="dot"></span>刚改</span><span class="spacer"></span><button class="icon-mini-btn"></button></div>
<div class="shot-row"><span class="shot-k">画面</span><div class="shot-v">女主敷面膜,闭眼平躺,灯光暖。床头闹钟显示 23:41。</div></div>
<div class="shot-row"><span class="shot-k">对白</span><div class="shot-v">"真的,敷完第二天起来脸是软的,不是绷着的。"</div></div>
</div>
<div class="shot-card">
<div class="shot-head"><div class="shot-num">5</div><div class="shot-time">11-13s</div><span class="spacer"></span><button class="icon-mini-btn"></button></div>
<div class="shot-row"><span class="shot-k">画面</span><div class="shot-v">第二天早上,女主对镜化妆,皮肤透亮,状态饱满。</div></div>
<div class="shot-row"><span class="shot-k">对白</span><div class="shot-v">"早上化妆都能看出来,不假吹。"</div></div>
</div>
<div class="shot-card">
<div class="shot-head"><div class="shot-num">6</div><div class="shot-time">13-15s</div><span class="spacer"></span><button class="icon-mini-btn"></button></div>
<div class="shot-row"><span class="shot-k">画面</span><div class="shot-v">面膜产品大图,价格标签 "5 片装 ¥39.9",购物车浮动按钮。</div></div>
<div class="shot-row"><span class="shot-k">对白</span><div class="shot-v">"618 五片才 39.9,囤起来。"</div></div>
</div>
</div>
</div>
</div>
<div class="stage-foot">
<div class="info"><span class="mono">[ LLM 用量 ~2.4k tokens · ¥0.04 ]</span></div>
<div class="hstack">
<button class="btn" onclick="Shell.toast('重新生成', 'POST /script/regen')">↻ 重新生成全部</button>
<button class="btn btn-primary btn-lg" onclick="location.hash='#stage-2'">确认脚本,进入下一步 →</button>
</div>
</div>
</section>
<!-- ============= STAGE 2 · 基础资产 ============= -->
<section class="stage" data-stage-pane="2">
<div class="stage-assets">
<div class="asset-side">
<div class="ttab active"><span>人物</span><span class="num">2/2</span></div>
<div class="ttab"><span>场景</span><span class="num">3/3</span></div>
<div class="ttab"><span>商品</span><span class="num">3 张</span></div>
<div class="info">
基础资产是后续故事板的素材。生成后可以单独修改提示词重跑,或上传你已有的图替换。
<br><br>
<strong class="mono">// 人物 +¥0.20/张</strong>
<strong class="mono">// 场景 +¥0.15/张</strong>
<span style="color:var(--ink-3);">商品图无成本(直接复用商品库)</span>
</div>
</div>
<div>
<div class="hstack" style="margin-bottom:14px;">
<h3 style="font-size:15px; font-weight:600;">人物 · 2 个</h3>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm">从我的资产库选</button>
<button class="btn btn-sm" onclick="Shell.toast('+ 新增人物')">+ 新增人物</button>
</div>
<div class="asset-grid-2">
<div class="asset-card-2">
<div class="placeholder thumb-2"><span class="ph-frame">林夕 · 都市白领</span></div>
<div class="body-2">
<div class="hstack"><strong style="font-size:13.5px;">主角 · 林夕</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>已确认</span></div>
<div class="prompt-box">25-30 岁都市白领,长发,穿宽松米色家居服,温柔但带点疲倦感,肤色偏黄/略干。</div>
<div class="hstack" style="margin-top:10px;">
<button class="btn btn-ghost btn-sm">改提示词</button>
<button class="btn btn-ghost btn-sm">↻ 重跑</button>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm">⤓ 上传替换</button>
</div>
</div>
</div>
<div class="asset-card-2">
<div class="placeholder thumb-2">
<div style="display:flex; flex-direction:column; gap:8px; align-items:center;">
<div class="spinner"></div>
<span class="ph-frame">生成中 · 约 8s</span>
</div>
</div>
<div class="body-2">
<div class="hstack"><strong style="font-size:13.5px;">朋友/同事 · 阿楠</strong><span class="spacer"></span><span class="pill info"><span class="dot"></span>生成中</span></div>
<div class="prompt-box">25-30 岁同龄女性,短发,穿白色衬衫,妆容精致皮肤好,作为对比。</div>
<div class="hstack" style="margin-top:10px;">
<button class="btn btn-ghost btn-sm" disabled>改提示词</button>
<button class="btn btn-ghost btn-sm" disabled>↻ 重跑</button>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" disabled>⤓ 上传替换</button>
</div>
</div>
</div>
</div>
<div class="hstack" style="margin:28px 0 14px;">
<h3 style="font-size:15px; font-weight:600;">场景 · 3 个</h3>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm">从我的资产库选</button>
<button class="btn btn-sm">+ 新增场景</button>
</div>
<div class="asset-grid-2">
<div class="asset-card-2">
<div class="placeholder thumb-2"><span class="ph-frame">深夜办公桌</span></div>
<div class="body-2">
<div class="hstack"><strong style="font-size:13.5px;">深夜办公桌</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>已确认</span></div>
<div class="prompt-box">深夜居家办公环境,木质书桌,台灯暖光,电脑屏幕亮着,背景虚化。</div>
</div>
</div>
<div class="asset-card-2">
<div class="placeholder thumb-2"><span class="ph-frame">床头特写</span></div>
<div class="body-2">
<div class="hstack"><strong style="font-size:13.5px;">卧室床头</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>已确认</span></div>
<div class="prompt-box">米白色床品,木质床头柜,闹钟显示晚间时间,氛围温柔安静。</div>
</div>
</div>
<div class="asset-card-2">
<div class="placeholder thumb-2">
<div style="display:flex; flex-direction:column; gap:6px; align-items:center;">
<div class="fail-icon">!</div>
<span class="ph-frame">生成失败</span>
</div>
</div>
<div class="body-2">
<div class="hstack"><strong style="font-size:13.5px;">通勤地铁</strong><span class="spacer"></span><span class="pill err"><span class="dot"></span>失败</span></div>
<div class="prompt-box">早高峰地铁车厢,光线偏冷,年轻通勤族,氛围紧张。</div>
<div class="muted-2" style="font-size:12px; margin-top:6px;">⚠ 提示词被安全审核拦截,请调整后重试(不扣费)</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-sm">改提示词</button>
<button class="btn btn-ghost btn-sm">↻ 重跑</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="stage-foot">
<div class="info"><span class="mono">[ 已确认 ¥0.85 · 待生成 ¥0.20 · 失败 ¥0(不扣) ]</span></div>
<div class="hstack">
<button class="btn" onclick="location.hash='#stage-1'">← 返回脚本</button>
<button class="btn btn-primary btn-lg" disabled>1 个资产生成中 · 等待</button>
</div>
</div>
</section>
<!-- ============= STAGE 3 · 故事板 ============= -->
<section class="stage active" data-stage-pane="3">
<div class="stage-storyboard">
<div class="sb-canvas">
<div class="sb-row head">
<div>镜号</div><div>画面</div><div>机位 / 对白 / 音效</div>
</div>
<div class="sb-row">
<div class="sb-num">1<span class="t">0-2s</span></div>
<div class="sb-img"><div class="placeholder"><span class="ph-frame">深夜办公桌 · 女主对镜叹气 · 暖光</span></div></div>
<div class="sb-text">
<div class="meta">中景 / 固定机位</div>
<div class="dialog">"加班三天,脸已经不能看了……"</div>
<div class="sfx">SFX:键盘声 + 远处空调嗡鸣</div>
</div>
</div>
<div class="sb-row">
<div class="sb-num">2<span class="t">2-5s</span></div>
<div class="sb-img"><div class="placeholder"><span class="ph-frame">面膜包装特写 · 抽屉光线柔和</span></div></div>
<div class="sb-text">
<div class="meta">特写 / 缓推</div>
<div class="dialog">"还好我有这个 —— 透真玻尿酸面膜。"</div>
<div class="sfx">SFX:抽屉滑动声</div>
</div>
</div>
<div class="sb-row">
<div class="sb-num">3<span class="t">5-8s</span></div>
<div class="sb-img"><div class="placeholder"><span class="ph-frame">面膜布展开 · 30g 精华滴落 · 慢动作</span></div></div>
<div class="sb-text">
<div class="meta">微距 / 慢镜头</div>
<div class="dialog">"30g 精华液,一片顶三片的量。"</div>
<div class="sfx">SFX:水滴慢速回弹</div>
</div>
</div>
<div class="sb-row">
<div class="sb-num">4<span class="t">8-11s</span></div>
<div class="sb-img"><div class="placeholder"><span class="ph-frame">女主敷面膜平躺 · 闹钟 23:41</span></div></div>
<div class="sb-text">
<div class="meta">中近景 / 固定</div>
<div class="dialog">"敷完第二天起来脸是软的,不是绷着的。"</div>
<div class="sfx">SFX:呼吸声 + 窗外风声</div>
</div>
</div>
<div class="sb-row">
<div class="sb-num">5<span class="t">11-13s</span></div>
<div class="sb-img"><div class="placeholder"><span class="ph-frame">早晨化妆台 · 女主对镜上妆 · 透亮</span></div></div>
<div class="sb-text">
<div class="meta">中景 / 固定</div>
<div class="dialog">"早上化妆都能看出来,不假吹。"</div>
<div class="sfx">SFX:化妆刷轻扫声</div>
</div>
</div>
<div class="sb-row">
<div class="sb-num">6<span class="t">13-15s</span></div>
<div class="sb-img"><div class="placeholder"><span class="ph-frame">产品大图 · 价格 5片¥39.9 · 购物车</span></div></div>
<div class="sb-text">
<div class="meta">产品定格 / 静止</div>
<div class="dialog">"618 五片才 39.9,囤起来。"</div>
<div class="sfx">SFX:清脆叮咚音效</div>
</div>
</div>
</div>
<div class="sb-side">
<div class="pane">
<div class="hstack" style="margin-bottom:10px;">
<strong style="font-size:14px;">故事板</strong>
<span class="spacer"></span>
<span class="pill ok"><span class="dot"></span>已生成</span>
</div>
<div class="muted-2" style="font-size:12px; line-height:1.55; margin-bottom:14px;">
整张故事板由 image-2 一次性输出,包含画面 + 镜头说明。如需修改请编辑下方提示词整张重跑(不能局部改)。
</div>
<div class="muted mono" style="font-size:11px; font-weight:500; margin-bottom:6px; letter-spacing:.04em;">// 视觉提示词</div>
<div class="prompt-edit" contenteditable="true">风格:日系小清新短视频,暖色调,午夜→清晨光线变化。
镜头列表(6 镜,0-15s):
1. 中景 · 深夜办公桌女主对镜叹气
2. 特写 · 面膜包装从抽屉拿出
3. 微距 · 面膜布展开 + 精华液滴落慢镜
4. 中近景 · 女主敷面膜平躺,床头闹钟 23:41
5. 中景 · 早晨化妆台 + 女主透亮上妆
6. 产品定格 · 面膜盒 + 价格标签 ¥39.9
人物:林夕(参考 R1)
场景:深夜办公桌(S1) + 卧室床头(S2)
要求:表格化布局,每镜含画面 + 文字说明</div>
<div class="hstack" style="margin-top:12px;">
<button class="btn btn-sm" onclick="Shell.toast('整张重跑', 'POST /storyboard/regen ¥0.45')">↻ 整张重跑</button>
<span class="spacer"></span>
<span class="muted-2 mono" style="font-size:11px;">~¥0.45</span>
</div>
<div class="divider"></div>
<div class="muted mono" style="font-size:11px; font-weight:500; margin-bottom:8px; letter-spacing:.04em;">// 绑定的资产</div>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<span class="asset-tag"><span class="dotc"></span>林夕(人物)</span>
<span class="asset-tag"><span class="dotc"></span>深夜办公桌(场景)</span>
<span class="asset-tag"><span class="dotc"></span>卧室床头(场景)</span>
<span class="asset-tag"><span class="dotc"></span>面膜盒(商品)</span>
</div>
</div>
</div>
</div>
<div class="stage-foot">
<div class="info"><span class="mono">[ image-2 一次 ¥0.45 · 累计 ¥1.50 ]</span></div>
<div class="hstack">
<button class="btn" onclick="location.hash='#stage-2'">← 返回资产</button>
<button class="btn btn-primary btn-lg" onclick="location.hash='#stage-4'">确认故事板,开始生成视频片段 →</button>
</div>
</div>
</section>
<!-- ============= STAGE 4 · 视频片段 ============= -->
<section class="stage" data-stage-pane="4">
<div class="queue-bar">
<div>
<div style="font-size:14px; font-weight:600;">视频生成中 · 4 / 6 完成</div>
<div class="muted-2 mono" style="font-size:11px; margin-top:3px; letter-spacing:.02em;">// 每镜 Seedance 调用 ~30s · 预计还需 1 分钟</div>
</div>
<div class="bar-wrap"><span style="width:67%"></span></div>
<span class="muted mono" style="font-size:12px;">67%</span>
<button class="btn btn-sm" onclick="Shell.toast('全部重跑', 'POST /video/regen-all')">↻ 全部重跑</button>
</div>
<div class="video-grid">
<div class="video-card">
<div class="placeholder video-thumb">
<span class="ph-frame">镜 1 · 0-2s</span>
<div class="play"><div class="btn-play"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></div></div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">镜 1 · 深夜办公桌</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">2.0s · 1080×1920 · ¥0.18</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm">↻ 重跑</button>
<button class="btn btn-ghost btn-sm">⤓ 下载</button>
</div>
</div>
</div>
<div class="video-card">
<div class="placeholder video-thumb">
<span class="ph-frame">镜 2 · 2-5s</span>
<div class="play"><div class="btn-play"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></div></div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">镜 2 · 面膜包装</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">3.0s · 1080×1920 · ¥0.22</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm">↻ 重跑</button>
<button class="btn btn-ghost btn-sm">⤓ 下载</button>
</div>
</div>
</div>
<div class="video-card">
<div class="placeholder video-thumb">
<span class="ph-frame">镜 3 · 5-8s</span>
<div class="play"><div class="btn-play"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></div></div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">镜 3 · 精华液微距</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">3.0s · 1080×1920 · ¥0.22</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm">↻ 重跑</button>
<button class="btn btn-ghost btn-sm">⤓ 下载</button>
</div>
</div>
</div>
<div class="video-card">
<div class="placeholder video-thumb">
<span class="ph-frame">镜 4 · 8-11s</span>
<div class="play"><div class="btn-play"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></div></div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">镜 4 · 敷面膜平躺</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">3.0s · 1080×1920 · ¥0.22</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm">↻ 重跑</button>
<button class="btn btn-ghost btn-sm">⤓ 下载</button>
</div>
</div>
</div>
<div class="video-card">
<div class="placeholder video-thumb">
<div style="display:flex; flex-direction:column; gap:8px; align-items:center;">
<div class="spinner"></div>
<span class="ph-frame">镜 5 · 生成中 18s</span>
</div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">镜 5 · 化妆台</strong><span class="spacer"></span><span class="pill info"><span class="dot"></span>生成中</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">2.0s · 排队中 · ~¥0.18</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm" disabled>↻ 重跑</button>
<button class="btn btn-ghost btn-sm" disabled>⤓ 下载</button>
</div>
</div>
</div>
<div class="video-card">
<div class="placeholder video-thumb"><span class="ph-frame">镜 6 · 排队</span></div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">镜 6 · 产品定格</strong><span class="spacer"></span><span class="pill neutral"><span class="dot"></span>排队中</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">2.0s · 等待中 · ~¥0.18</div>
</div>
</div>
</div>
<div class="stage-foot">
<div class="info"><span class="mono">[ 已确认 ¥0.84 · 待生成 ¥0.36 · 累计 ¥2.34 ]</span></div>
<div class="hstack">
<button class="btn" onclick="location.hash='#stage-3'">← 返回故事板</button>
<button class="btn btn-primary btn-lg" disabled>2 镜生成中 · 等待</button>
</div>
</div>
</section>
<!-- ============= STAGE 5 · 拼接编辑器 ============= -->
<section class="stage" data-stage-pane="5">
<div class="editor">
<div class="editor-preview">
<div class="canvas">9:16 预览 · 1080×1920</div>
<div class="controls">
<button class="ctl-btn" title="上一帧"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3v10l4-5zM9 3v10l4-5z" fill="currentColor"/></svg></button>
<button class="ctl-btn" title="播放" onclick="Shell.toast('播放', '00:08.42 / 00:15.00')"><svg width="16" height="16" viewBox="0 0 16 16"><path d="M5 4l7 4-7 4z" fill="currentColor"/></svg></button>
<button class="ctl-btn" title="下一帧"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M13 3v10l-4-5zM7 3v10l-4-5z" fill="currentColor"/></svg></button>
<span class="muted mono" style="font-size:12px; margin-left:8px;">00:08.42 / 00:15.00</span>
</div>
</div>
<div class="editor-props">
<div class="props-tabs">
<div class="active">字幕</div>
<div>转场</div>
<div>BGM</div>
</div>
<div class="muted mono" style="font-size:11px; font-weight:500; margin-bottom:8px; letter-spacing:.04em;">// 字幕样式</div>
<div class="style-swatch">
<div class="swatch-card selected"><div class="demo">真实分享</div><div class="nm">朴素白底</div></div>
<div class="swatch-card"><div class="demo b">真实分享</div><div class="nm">影视黑底</div></div>
<div class="swatch-card"><div class="demo c">真实分享</div><div class="nm">手写描边</div></div>
<div class="swatch-card"><div class="demo d">真实分享</div><div class="nm">综艺暖黄</div></div>
</div>
<div class="divider"></div>
<div class="muted mono" style="font-size:11px; font-weight:500; margin-bottom:8px; letter-spacing:.04em;">// 当前选中(镜 4)</div>
<div class="props-row"><span class="k">起始</span><input class="input-mini" value="00:08.00"></div>
<div class="props-row"><span class="k">时长</span><input class="input-mini" value="3.00s"></div>
<div class="props-row"><span class="k">音量</span><input class="input-mini" value="100"></div>
<div class="props-row"><span class="k">速度</span><input class="input-mini" value="1.0x"></div>
<div class="props-row"><span class="k">入场</span><span class="mono" style="font-size:11.5px;">交叉淡化</span></div>
<div class="divider"></div>
<div class="muted mono" style="font-size:11px; font-weight:500; margin-bottom:8px; letter-spacing:.04em;">// BGM</div>
<div class="props-row" style="border-bottom:0;">
<span style="font-size:12px; flex:1;">温柔治愈钢琴 · 0:42</span>
<button class="btn btn-ghost btn-sm">替换</button>
</div>
</div>
<div class="timeline">
<div class="tl-toolbar">
<button class="btn btn-ghost btn-sm"></button>
<button class="btn btn-ghost btn-sm"></button>
<span class="muted-2" style="font-size:12px;">|</span>
<button class="btn btn-ghost btn-sm">分割</button>
<button class="btn btn-ghost btn-sm">复制</button>
<button class="btn btn-ghost btn-sm">删除</button>
<span class="spacer"></span>
<span class="muted mono" style="font-size:11px;">缩放</span>
<input type="range" min="50" max="200" value="100" style="width:120px;">
</div>
<div class="tl-ruler">
<div class="l">// time</div>
<div class="ticks">
<span>0s</span><span>2s</span><span>5s</span><span>8s</span><span>11s</span><span>13s</span><span>15s</span>
</div>
</div>
<div class="tl-track">
<div class="label"><span class="dot" style="background:var(--orange);"></span>视频</div>
<div class="lane">
<div class="clip video" style="flex:2;"><span class="num">1</span> 深夜办公桌</div>
<div class="clip video" style="flex:3;"><span class="num">2</span> 面膜包装</div>
<div class="clip video" style="flex:3;"><span class="num">3</span> 精华液微距</div>
<div class="clip video selected" style="flex:3;"><span class="num">4</span> 敷面膜平躺</div>
<div class="clip video" style="flex:2;"><span class="num">5</span> 化妆台</div>
<div class="clip video" style="flex:2;"><span class="num">6</span> 产品定格</div>
</div>
</div>
<div class="tl-track">
<div class="label"><span class="dot" style="background:var(--green);"></span>字幕</div>
<div class="lane" style="position:relative;">
<div class="clip subtitle" style="flex:2;">加班三天 脸已经不能看了…</div>
<div class="clip subtitle" style="flex:3;">还好我有这个 透真玻尿酸面膜</div>
<div class="clip subtitle" style="flex:3;">30g 精华 一片顶三片</div>
<div class="clip subtitle" style="flex:3;">敷完起来脸是软的</div>
<div class="clip subtitle" style="flex:2;">化妆都能看出来</div>
<div class="clip subtitle" style="flex:2;">5 片 ¥39.9 囤起来</div>
<div class="playhead" style="left:56%;"></div>
</div>
</div>
<div class="tl-track">
<div class="label"><span class="dot" style="background:#B8651A;"></span>BGM</div>
<div class="lane">
<div class="clip bgm" style="flex:15;">温柔治愈钢琴 · 0:42(循环 1 次,淡入淡出)</div>
</div>
</div>
</div>
</div>
<div class="stage-foot">
<div class="info"><span class="mono">[ 合成预估 ~30s · 不消耗 token ]</span></div>
<div class="hstack">
<button class="btn" onclick="location.hash='#stage-4'">← 返回片段</button>
<button class="btn" onclick="Shell.toast('已保存草稿', '/projects/p3/draft')">保存草稿</button>
<button class="btn btn-primary btn-lg" onclick="Shell.toast('开始导出', 'POST /export · 1080P 9:16')">导出 MP4 · 1080P 9:16 →</button>
</div>
</div>
</section>
</div>
<script src="assets/shell.js"></script>
<script>
Shell.render({
active: 'projects',
crumbs: [{ label: '工作台', href: 'index.html' }, { label: '视频项目', href: 'projects.html' }, { label: '补水面膜 · v3' }]
});
// hash routing
function activateStage(n) {
document.querySelectorAll('.stage').forEach(s => s.classList.remove('active'));
document.querySelector(`[data-stage-pane="${n}"]`)?.classList.add('active');
document.querySelectorAll('.stage-step').forEach(s => {
s.classList.remove('active');
if (+s.dataset.stage === +n) s.classList.add('active');
});
// update top breadcrumb fragment indicator with shell.toast
const stageNames = { 1:'脚本', 2:'基础资产', 3:'故事板', 4:'视频片段', 5:'拼接导出' };
Shell.toast('进入 Stage ' + n + ' · ' + stageNames[n], 'pipeline#stage-' + n);
}
function readHash() {
const m = location.hash.match(/stage-(\d)/);
if (m) activateStage(+m[1]);
}
window.addEventListener('hashchange', readHash);
readHash();
</script>
</body>
</html>

191
v1/products.html Normal file
View File

@ -0,0 +1,191 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>商品库 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css">
<style>
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
.product-card { background: var(--card); border: 1px solid var(--border); cursor: pointer; transition: background .15s; }
.product-card:hover { background: var(--bg-soft); border-color: var(--ink-3); }
.product-thumb { aspect-ratio: 1.4 / 1; }
.product-body { padding: 14px; }
.product-name { font-size: 14px; font-weight: 600; color: var(--ink); }
.product-meta { font-size: 11.5px; color: var(--ink-3); margin-top: 4px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
.product-tags { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
.tag-chip { font-size: 11px; padding: 2px 8px; background: var(--bg-soft); color: var(--ink-2); border: 1px solid var(--border); }
.product-card.add { border: 1px dashed var(--border); background: transparent; display: grid; place-items: center; min-height: 220px; color: var(--ink-2); gap: 8px; padding: 16px; }
.product-card.add:hover { border-color: var(--orange); color: var(--orange); background: var(--orange-tint); }
.product-card.add .plus-ic { width: 36px; height: 36px; border: 1px solid currentColor; display: grid; place-items: center; }
.upload-zone { border: 1px dashed var(--border); padding: 24px; text-align: center; background: var(--bg-soft); color: var(--ink-2); font-size: 13px; }
.upload-zone strong { color: var(--orange); font-weight: 600; }
.upload-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 10px; }
.upload-grid .placeholder { aspect-ratio: 1; }
.bullet-list { list-style: none; }
.bullet-list li { display: flex; gap: 8px; align-items: center; padding: 8px 10px; background: var(--bg-soft); border: 1px solid var(--border); margin-bottom: 6px; font-size: 13px; }
.bullet-list .num { width: 18px; height: 18px; background: var(--card); border: 1px solid var(--border); font-size: 11px; color: var(--ink-2); display: grid; place-items: center; flex-shrink: 0; font-family: 'JetBrains Mono', monospace; }
</style>
</head>
<body>
<div id="page">
<div class="page-head">
<div>
<h1>商品库</h1>
<div class="sub"><span class="mono">// 12 SKU</span> · 商品信息会作为脚本和资产生成的素材</div>
</div>
<div class="actions">
<button class="btn btn-primary" onclick="Shell.openDrawer('new-product')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M12 5v14M5 12h14"/></svg>
新建商品
</button>
</div>
</div>
<div class="toolbar">
<div class="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input class="input" placeholder="搜索商品名称、品牌">
</div>
<button class="chip active">全部 <span class="mono" style="opacity:.7">12</span></button>
<button class="chip">美妆个护</button>
<button class="chip">数码 3C</button>
<button class="chip">食品饮料</button>
<button class="chip">服饰</button>
<span class="spacer"></span>
<button class="chip">最近添加 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
</div>
<div class="product-grid">
<div class="product-card" onclick="Shell.toast('打开商品', '透真玻尿酸补水面膜')">
<div class="placeholder product-thumb"><span class="ph-frame">补水面膜 · 1200×800</span></div>
<div class="product-body">
<div class="product-name">透真玻尿酸补水面膜</div>
<div class="product-meta">美妆个护 · 3 张图</div>
<div class="product-tags"><span class="tag-chip">熬夜党</span><span class="tag-chip">敏感肌</span><span class="tag-chip">¥39.9</span></div>
</div>
</div>
<div class="product-card" onclick="Shell.toast('打开商品', '南卡 Lite Pro')">
<div class="placeholder product-thumb"><span class="ph-frame">蓝牙耳机 · 1200×800</span></div>
<div class="product-body">
<div class="product-name">南卡 Lite Pro 蓝牙耳机</div>
<div class="product-meta">数码 3C · 5 张图</div>
<div class="product-tags"><span class="tag-chip">通勤</span><span class="tag-chip">运动</span><span class="tag-chip">¥199</span></div>
</div>
</div>
<div class="product-card" onclick="Shell.toast('打开商品', '滋啦速食')">
<div class="placeholder product-thumb"><span class="ph-frame">速食牛肉面 · 1200×800</span></div>
<div class="product-body">
<div class="product-name">滋啦速食牛肉面 · 6 桶装</div>
<div class="product-meta">食品饮料 · 4 张图</div>
<div class="product-tags"><span class="tag-chip">加班</span><span class="tag-chip">独居</span><span class="tag-chip">¥49.9</span></div>
</div>
</div>
<div class="product-card" onclick="Shell.toast('打开商品', '透真清透防晒')">
<div class="placeholder product-thumb"><span class="ph-frame">防晒霜 · 1200×800</span></div>
<div class="product-body">
<div class="product-name">透真清透物理防晒霜</div>
<div class="product-meta">美妆个护 · 4 张图</div>
<div class="product-tags"><span class="tag-chip">SPF50</span><span class="tag-chip">通勤</span><span class="tag-chip">¥69</span></div>
</div>
</div>
<div class="product-card" onclick="Shell.toast('打开商品', '三顿半同款')">
<div class="placeholder product-thumb"><span class="ph-frame">咖啡冻干粉 · 1200×800</span></div>
<div class="product-body">
<div class="product-name">三顿半同款冻干咖啡粉</div>
<div class="product-meta">食品饮料 · 6 张图</div>
<div class="product-tags"><span class="tag-chip">提神</span><span class="tag-chip">早八</span><span class="tag-chip">¥89/24 颗</span></div>
</div>
</div>
<div class="product-card" onclick="Shell.toast('打开商品', '小熊 4L 空气炸锅')">
<div class="placeholder product-thumb"><span class="ph-frame">空气炸锅 · 1200×800</span></div>
<div class="product-body">
<div class="product-name">小熊 4L 可视空气炸锅</div>
<div class="product-meta">家电 · 5 张图</div>
<div class="product-tags"><span class="tag-chip">小户型</span><span class="tag-chip">健康</span><span class="tag-chip">¥159</span></div>
</div>
</div>
<div class="product-card" onclick="Shell.toast('打开商品', '露露同款瑜伽裤')">
<div class="placeholder product-thumb"><span class="ph-frame">瑜伽裤 · 1200×800</span></div>
<div class="product-body">
<div class="product-name">露露同款裸感瑜伽裤</div>
<div class="product-meta">服饰 · 8 张图</div>
<div class="product-tags"><span class="tag-chip">健身房</span><span class="tag-chip">通勤</span><span class="tag-chip">¥119</span></div>
</div>
</div>
<div class="product-card add" onclick="Shell.openDrawer('new-product')">
<div class="plus-ic"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 5v14M5 12h14"/></svg></div>
<div style="font-size:13px; font-weight:500;">新建商品</div>
</div>
</div>
</div>
<!-- Drawer: 新建商品 -->
<div class="drawer-bg" id="new-product-bg" onclick="Shell.closeDrawer('new-product')"></div>
<aside class="drawer" id="new-product">
<div class="drawer-h">
<h3>新建商品</h3>
<div class="x" onclick="Shell.closeDrawer('new-product')">
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
</div>
</div>
<div class="drawer-b">
<div class="field">
<label class="field-label">商品名称<span class="req">*</span></label>
<input class="input" value="透真玻尿酸补水面膜">
</div>
<div style="display:flex; gap:12px;">
<div class="field" style="flex:1;">
<label class="field-label">品类</label>
<select class="select"><option>美妆个护</option><option>数码 3C</option><option>食品饮料</option><option>服饰</option></select>
</div>
<div class="field" style="flex:1;">
<label class="field-label">参考价</label>
<input class="input" value="39.9" placeholder="¥39.9">
</div>
</div>
<div class="field">
<label class="field-label">商品图册<span class="req">*</span></label>
<div class="upload-zone">
将图片拖到此处,或 <strong>点击上传</strong><br>
<span style="color:var(--ink-3); font-size:11.5px;" class="mono">// 建议 3-6 张 · 1200×1200+ · JPG / PNG</span>
</div>
<div class="upload-grid">
<div class="placeholder"><span class="ph-frame">主图</span></div>
<div class="placeholder"><span class="ph-frame">细节</span></div>
<div class="placeholder"><span class="ph-frame">使用</span></div>
<div class="placeholder"><span class="ph-frame">+</span></div>
</div>
</div>
<div class="field">
<label class="field-label">核心卖点<span class="req">*</span></label>
<div class="field-hint">每条一行,3-5 条。脚本会优先围绕这些卖点展开。</div>
<ul class="bullet-list" style="margin-top:8px;">
<li><span class="num">1</span> 透明质酸 + B5,敷完不黏不闷</li>
<li><span class="num">2</span> 30g 大容量精华液,一片顶三片</li>
<li><span class="num">3</span> 0 香精 0 酒精,敏感肌可用</li>
<li><span class="num">+</span> <input class="input" style="height:28px; border:0; padding:0 4px; background:transparent;" placeholder="添加新卖点"></li>
</ul>
</div>
<div class="field">
<label class="field-label">目标人群</label>
<input class="input" value="22-32 岁女性、熬夜党、敏感肌、办公室通勤">
</div>
<div class="field">
<label class="field-label">补充说明</label>
<textarea class="textarea">日常 ¥69 ,618 活动 5 片装 ¥39.9 。建议睡前敷 15 分钟,可冷藏后使用,体感更好。</textarea>
</div>
</div>
<div class="drawer-f">
<button class="btn btn-ghost" onclick="Shell.closeDrawer('new-product')">取消</button>
<button class="btn" onclick="Shell.toast('已保存草稿', '/products/draft')">保存草稿</button>
<button class="btn btn-primary" onclick="Shell.closeDrawer('new-product'); Shell.toast('商品已创建', '透真补水面膜')">创建商品</button>
</div>
</aside>
<script src="assets/shell.js"></script>
<script>Shell.render({ active: 'products', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '商品库' }] });</script>
</body>
</html>

286
v1/projects-new.html Normal file
View File

@ -0,0 +1,286 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>新建项目 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css">
<style>
.wizard { display: grid; grid-template-columns: 200px minmax(0, 1fr) 300px; gap: 36px; align-items: start; max-width: 1400px; }
@media (max-width: 1180px) { .wizard { grid-template-columns: 200px minmax(0, 1fr); } .wiz-preview { display: none; } }
.steps { position: sticky; top: 24px; align-self: start; }
.step { display: flex; gap: 12px; padding: 12px 0; position: relative; }
.step:not(:last-child)::after { content: ''; position: absolute; left: 11px; top: 36px; width: 1px; height: calc(100% - 24px); background: var(--border); }
.step .num { width: 24px; height: 24px; border: 1px solid var(--border); background: var(--card); display: grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--ink-3); flex-shrink: 0; z-index: 1; font-family: 'JetBrains Mono', monospace; }
.step.done .num { background: var(--ink); border-color: var(--ink); color: #FFF; }
.step.active .num { background: var(--orange); border-color: var(--orange); color: #FFF; }
.step .label { font-size: 13.5px; font-weight: 500; color: var(--ink-2); padding-top: 2px; }
.step .desc { font-size: 11.5px; color: var(--ink-3); padding-top: 3px; line-height: 1.4; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
.step.active .label { color: var(--ink); font-weight: 600; }
.step.done .label { color: var(--ink-2); }
.step.done:not(:last-child)::after { background: var(--ink); }
.wiz-pane { background: var(--card); border: 1px solid var(--border); padding: 22px 24px; margin-bottom: 14px; }
.wiz-pane.active { padding: 26px 28px; position: relative; }
.wiz-pane.active::before, .wiz-pane.active::after { 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='%239C988C'%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; }
.wiz-pane.active::before { top: -7px; left: -7px; }
.wiz-pane.active::after { bottom: -7px; right: -7px; }
.wiz-pane.collapsed { padding: 16px 20px; }
.wiz-pane-h { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.wiz-pane-h h3 { font-size: 14px; font-weight: 600; }
.wiz-step-h h2 { font-size: 20px; font-weight: 600; letter-spacing: -.015em; }
.wiz-step-h p { font-size: 13px; color: var(--ink-2); margin-top: 6px; }
.opt-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.opt-row.cols-4 { grid-template-columns: repeat(4, 1fr); }
.opt-row.cols-6 { grid-template-columns: repeat(3, 1fr); }
@media (min-width: 1280px) { .opt-row.cols-6 { grid-template-columns: repeat(6, 1fr); } }
.opt-card { border: 1px solid var(--border); padding: 14px; background: var(--card); cursor: pointer; position: relative; display: flex; flex-direction: column; min-width: 0; }
.opt-card:hover { background: var(--bg-soft); }
.opt-card.selected { border-color: var(--orange); background: var(--orange-tint); }
.opt-card.selected::after { content: ''; position: absolute; top: 8px; right: 10px; width: 16px; height: 16px; background-color: var(--orange); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; }
.opt-card h4 { font-size: 13px; font-weight: 600; }
.opt-card .sub { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); margin-top: 3px; letter-spacing: .02em; }
.opt-card .note { font-size: 11.5px; color: var(--ink-2); margin-top: 6px; line-height: 1.5; }
.opt-card .metric { margin-top: auto; padding-top: 10px; font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .02em; }
.opt-card .metric .val { color: var(--ink); font-weight: 500; }
.opt-card.selected .metric .val { color: var(--orange); }
.opt-card .badge { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; padding: 1px 6px; background: var(--card); border: 1px solid var(--border); color: var(--ink-3); display: inline-block; margin-top: 8px; letter-spacing: .04em; align-self: flex-start; }
.opt-card.selected .badge { color: var(--orange); border-color: var(--orange-soft); }
.theme-pill { display: inline-flex; gap: 4px; height: 28px; align-items: center; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--card); font-size: 12.5px; cursor: pointer; color: var(--ink-2); }
.theme-pill:hover { background: var(--bg-soft); }
.theme-pill.active { background: var(--orange-tint); color: var(--orange); border-color: var(--orange-soft); font-weight: 600; }
.reco-bubble { position: relative; margin-top: 10px; padding: 10px 14px; background: var(--orange-tint); border: 1px solid var(--orange-soft); display: flex; align-items: center; gap: 12px; font-size: 12.5px; color: var(--ink); }
.reco-bubble::before { content: ''; position: absolute; top: -5px; left: 28px; width: 9px; height: 9px; background: var(--orange-tint); border-left: 1px solid var(--orange-soft); border-top: 1px solid var(--orange-soft); transform: rotate(45deg); }
.reco-bubble .ic { color: var(--orange); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; }
.reco-bubble .ic svg, .reco-bubble .dismiss svg { display: block; }
.reco-bubble .txt { flex: 1; line-height: 1.5; }
.reco-bubble .txt strong { color: var(--orange); font-weight: 600; }
.reco-bubble .txt .meta { display: block; font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); margin-top: 2px; letter-spacing: .02em; }
.reco-bubble .btn-apply { height: 26px; padding: 0 12px; background: var(--orange); color: #FFF; border: 1px solid var(--orange); font-size: 11.5px; font-weight: 600; cursor: pointer; flex-shrink: 0; }
.reco-bubble .btn-apply:hover { background: var(--orange-d); }
.reco-bubble .dismiss { background: transparent; color: var(--ink-3); border: 0; width: 24px; height: 24px; padding: 0; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.reco-bubble .dismiss:hover { color: var(--ink); }
.wiz-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
/* preview panel */
.wiz-preview { position: sticky; top: 24px; background: var(--card); border: 1px solid var(--border); padding: 18px; }
.wiz-preview::before, .wiz-preview::after { 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='%239C988C'%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; }
.wiz-preview::before { top: -7px; left: -7px; }
.wiz-preview::after { bottom: -7px; right: -7px; }
.pv-h { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .08em; margin-bottom: 12px; text-transform: uppercase; display: flex; justify-content: space-between; }
.pv-h .live { display: inline-flex; align-items: center; gap: 5px; color: var(--orange); }
.pv-h .live::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--orange); animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: .35 } }
.pv-title { font-size: 14px; font-weight: 600; line-height: 1.3; margin-bottom: 14px; }
.pv-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); border: 1px solid var(--border); margin-bottom: 14px; }
.pv-metric { padding: 10px 12px; background: var(--card); }
.pv-metric .l { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: var(--ink-3); letter-spacing: .04em; text-transform: uppercase; }
.pv-metric .v { font-size: 18px; font-weight: 600; margin-top: 3px; font-variant-numeric: tabular-nums; color: var(--ink); }
.pv-metric .v small { font-size: 11px; color: var(--ink-3); font-weight: 500; }
.pv-metric.accent .v { color: var(--orange); }
.pv-section { margin-top: 14px; }
.pv-section .lbl { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: var(--ink-3); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 8px; }
.pv-flow { display: flex; flex-wrap: wrap; gap: 4px 0; font-size: 11.5px; color: var(--ink-2); align-items: center; line-height: 1.7; }
.pv-flow .node { padding: 2px 7px; background: var(--bg-soft); border: 1px solid var(--border-soft); color: var(--ink); font-weight: 500; }
.pv-flow .arrow { color: var(--orange); margin: 0 5px; font-family: 'JetBrains Mono', monospace; }
.pv-list { list-style: none; padding: 0; margin: 0; }
.pv-list li { font-size: 11.5px; color: var(--ink-2); padding: 4px 0; display: flex; align-items: center; gap: 6px; }
.pv-list li::before { content: ''; width: 11px; height: 11px; flex-shrink: 0; background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23E55B26' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12l5 5L20 6'/%3E%3C/svg%3E") no-repeat center; background-size: contain; }
.pv-foot { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--border); font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); display: flex; justify-content: space-between; }
.pv-foot strong { color: var(--ink); font-weight: 500; }
</style>
</head>
<body>
<div id="page">
<div class="page-head">
<div>
<h1>新建项目</h1>
<div class="sub"><span class="mono">// 商品 → 脚本来源 → 配置 → 确认 · 4 步开始生成</span></div>
</div>
<div class="actions">
<a class="btn btn-ghost" href="projects.html">退出</a>
</div>
</div>
<div class="wizard">
<nav class="steps">
<div class="step done"><div class="num"></div><div><div class="label">选择商品</div><div class="desc">透真补水面膜</div></div></div>
<div class="step done"><div class="num"></div><div><div class="label">脚本来源</div><div class="desc">AI 全生 · 痛点种草</div></div></div>
<div class="step active"><div class="num">3</div><div><div class="label">项目配置</div><div class="desc">名称 · 时长 · 风格</div></div></div>
<div class="step"><div class="num">4</div><div><div class="label">确认与计费</div><div class="desc">预估 ¥3.20</div></div></div>
</nav>
<div>
<div class="wiz-pane collapsed">
<div class="wiz-pane-h">
<h3>第 1 步 · 选择商品</h3>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm">修改</button>
</div>
<div class="hstack" style="gap:12px;">
<div class="placeholder" style="width:44px;height:56px;"><span class="ph-frame">主图</span></div>
<div>
<div style="font-weight:600; font-size:13.5px;">透真玻尿酸补水面膜</div>
<div class="muted-2 mono" style="font-size:11.5px; margin-top:3px; letter-spacing:.02em;">美妆个护 · ¥39.9 · 3 张图 · 3 个卖点</div>
</div>
</div>
</div>
<div class="wiz-pane collapsed">
<div class="wiz-pane-h">
<h3>第 2 步 · 脚本来源</h3>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm">修改</button>
</div>
<div class="hstack">
<span class="pill info"><span class="dot"></span>AI 全生</span>
<span class="muted">主题:</span>
<span style="font-size:13px;">熬夜党的急救面膜 · 痛点种草向</span>
</div>
</div>
<div class="wiz-pane active">
<div class="wiz-step-h" style="margin-bottom:18px;">
<h2>第 3 步 · 项目配置</h2>
<p>这些设置会影响 LLM 生成脚本的方向,确认后会进入流水线第 1 步(脚本生成)。</p>
</div>
<div class="field">
<label class="field-label">项目名称<span class="req">*</span></label>
<input class="input" value="补水面膜 · 痛点种草 · v3">
</div>
<div class="field">
<label class="field-label">视频时长<span class="req">*</span></label>
<div class="opt-row cols-4">
<div class="opt-card"><h4>0-10 秒</h4><div class="sub">3-4 镜</div><div class="note">黄金完播</div><div class="metric">完播 <span class="val">52%</span></div></div>
<div class="opt-card selected"><h4>0-15 秒</h4><div class="sub">4-5 镜</div><div class="note">完播率最佳</div><div class="metric">完播 <span class="val">42%</span></div></div>
<div class="opt-card"><h4>0-30 秒</h4><div class="sub">6-8 镜</div><div class="note">卖点详解</div><div class="metric">完播 <span class="val">32%</span></div></div>
<div class="opt-card"><h4>0-60 秒</h4><div class="sub">10-12 镜</div><div class="note">故事化</div><div class="metric">完播 <span class="val">26%</span></div></div>
</div>
<div class="field-hint">数据来源:抖音同品类 TOP 视频均值 · 实际镜头数由 LLM 决定</div>
</div>
<div class="field">
<label class="field-label">脚本风格</label>
<div class="opt-row">
<div class="opt-card selected">
<h4>痛点种草</h4>
<div class="note">用户痛点切入,以「我懂你」的口吻引出产品。</div>
<span class="badge">最常用</span>
</div>
<div class="opt-card">
<h4>开箱测评</h4>
<div class="note">朋友式分享,从开箱到使用感受娓娓道来。</div>
</div>
<div class="opt-card">
<h4>对比展示</h4>
<div class="note">「用前 vs 用后 / 同类 vs 本品」直观呈现。</div>
</div>
</div>
</div>
<div class="field">
<label class="field-label">人物设定</label>
<div class="opt-row cols-6">
<div class="opt-card"><h4>都市白领女性</h4><div class="sub">25-30 岁</div><div class="metric"><span class="val">大盘消费力</span></div></div>
<div class="opt-card"><h4>闺蜜种草</h4><div class="sub">邻家女孩</div><div class="metric"><span class="val">复购最高</span></div></div>
<div class="opt-card"><h4>总裁亲选</h4><div class="sub">创始人 IP</div><div class="metric"><span class="val">30 万销额</span></div></div>
<div class="opt-card"><h4>专业测评师</h4><div class="sub">垂类达人</div><div class="metric"><span class="val">互动 +30%</span></div></div>
<div class="opt-card"><h4>实用宝妈</h4><div class="sub">家庭决策者</div><div class="metric"><span class="val">母婴/家清</span></div></div>
<div class="opt-card selected"><h4>学生党</h4><div class="sub">Z 世代 18-24</div><div class="metric"><span class="val">平价快消</span></div></div>
</div>
<div class="reco-bubble">
<span class="ic">
<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 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>
</span>
<div class="txt">
<span>抖音同人设 TOP 视频更常用 <strong>0-10 秒</strong> + <strong>对比展示</strong></span>
<span class="meta">当前 0-15 秒 · 痛点种草 → 推荐换为学生党最优组合</span>
</div>
<button class="btn-apply">一键套用</button>
<button class="dismiss" aria-label="忽略">
<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 5l14 14M19 5L5 19"/></svg>
</button>
</div>
</div>
<div class="field">
<label class="field-label">关键卖点(可勾选要重点突出的)</label>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<span class="theme-pill active">✓ 透明质酸 + B5</span>
<span class="theme-pill active">✓ 30g 大容量精华</span>
<span class="theme-pill">+ 0 香精 0 酒精</span>
</div>
</div>
</div>
<div class="wiz-foot">
<button class="btn">← 上一步</button>
<div class="hstack">
<span class="muted-2 mono" style="font-size:11.5px; letter-spacing:.02em;">// 下一步:确认与计费</span>
<a class="btn btn-primary btn-lg" href="pipeline.html#stage-1" onclick="Shell.toast('开始生成项目', 'pipeline#stage-1')">下一步 →</a>
</div>
</div>
</div>
<!-- ── Live preview ── -->
<aside class="wiz-preview">
<div class="pv-h">
<span>实时预估</span>
<span class="live">LIVE</span>
</div>
<div class="pv-title">补水面膜 · 痛点种草 · v3</div>
<div class="pv-metrics">
<div class="pv-metric"><div class="l">镜头</div><div class="v">4-5<small></small></div></div>
<div class="pv-metric accent"><div class="l">预估完播</div><div class="v">42<small>%</small></div></div>
<div class="pv-metric"><div class="l">预估转化</div><div class="v">1.8<small>%</small></div></div>
<div class="pv-metric"><div class="l">预估成本</div><div class="v">¥3.20</div></div>
</div>
<div class="pv-section">
<div class="lbl">人设 · 风格</div>
<ul class="pv-list">
<li>学生党 · Z 世代 18-24</li>
<li>痛点种草 · 完播率最佳</li>
</ul>
</div>
<div class="pv-section">
<div class="lbl">脚本走向</div>
<div class="pv-flow">
<span style="display:inline-flex; align-items:center;"><span class="node">痛点</span><span class="arrow"></span></span>
<span style="display:inline-flex; align-items:center;"><span class="node">共鸣</span><span class="arrow"></span></span>
<span style="display:inline-flex; align-items:center;"><span class="node">产品</span><span class="arrow"></span></span>
<span style="display:inline-flex; align-items:center;"><span class="node">效果</span><span class="arrow"></span></span>
<span class="node">引导</span>
</div>
</div>
<div class="pv-section">
<div class="lbl">突出卖点</div>
<ul class="pv-list">
<li>透明质酸 + B5</li>
<li>30g 大容量精华</li>
</ul>
</div>
<div class="pv-foot">
<span>v3 · Restraint</span>
<strong>就绪</strong>
</div>
</aside>
</div>
</div>
<script src="assets/shell.js"></script>
<script>Shell.render({ active: 'projects', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '视频项目', href: 'projects.html' }, { label: '新建项目' }] });</script>
</body>
</html>

521
v1/projects.html Normal file
View File

@ -0,0 +1,521 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>视频项目 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css">
<style>
/* ─── List view ─── */
.proj-name-cell { display: flex; align-items: center; gap: 12px; }
.proj-thumb { width: 40px; height: 52px; flex-shrink: 0; }
.proj-name { font-weight: 600; color: var(--ink); font-size: 13.5px; }
.proj-sub { font-size: 11.5px; color: var(--ink-3); margin-top: 3px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
.row-action { display: flex; gap: 4px; visibility: hidden; }
table.t tbody tr:hover .row-action { visibility: visible; }
.row-action a { width: 28px; height: 28px; display: grid; place-items: center; color: var(--ink-2); }
.row-action a:hover { background: var(--card); color: var(--orange); border: 1px solid var(--border); }
/* ─── View toggle ─── */
.view-toggle { display: inline-flex; border: 1px solid var(--border); }
.view-toggle button { padding: 6px 12px; background: var(--card); color: var(--ink-2); font-size: 12px; border-right: 1px solid var(--border); border-radius: 0; height: 32px; cursor: pointer; font-family: inherit; display: flex; align-items: center; gap: 5px; }
.view-toggle button:last-child { border-right: 0; }
.view-toggle button:hover { background: var(--bg-soft); color: var(--ink); }
.view-toggle button.active { background: var(--orange-tint); color: var(--orange); font-weight: 600; }
.view-toggle button svg { width: 13px; height: 13px; }
/* ─── Grid view ─── */
.proj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
.proj-card { background: var(--card); border: 1px solid var(--border); cursor: pointer; transition: background .15s; display: flex; flex-direction: column; }
.proj-card:hover { background: var(--bg-soft); border-color: var(--ink-3); }
.proj-card .card-thumb { aspect-ratio: 9/16; max-height: 280px; }
.proj-card .card-body { padding: 14px; display: flex; flex-direction: column; gap: 10px; flex: 1; }
.proj-card .card-name { font-size: 13.5px; font-weight: 600; color: var(--ink); line-height: 1.4; }
.proj-card .card-sub { font-size: 11.5px; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
.proj-card .card-foot { display: flex; align-items: center; justify-content: space-between; padding-top: 10px; border-top: 1px solid var(--border); margin-top: auto; }
.proj-card .card-time { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .02em; }
/* ─── Empty state ─── */
.empty-state {
background: var(--card);
border: 1px dashed var(--border);
padding: 80px 40px;
text-align: center;
color: var(--ink-2);
display: none;
}
.empty-state.show { display: block; }
.empty-state .ic-empty {
width: 48px; height: 48px;
margin: 0 auto 14px;
background: var(--bg-soft);
border: 1px solid var(--border);
display: grid; place-items: center;
color: var(--ink-3);
}
.empty-state h3 { font-size: 14px; font-weight: 600; color: var(--ink); margin-bottom: 6px; }
.empty-state p { font-size: 12.5px; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
/* ─── Result count ─── */
.result-meta {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--ink-3);
margin-bottom: 14px;
letter-spacing: .04em;
}
.result-meta .count { color: var(--orange); font-weight: 600; }
</style>
</head>
<body>
<div id="page">
<div class="page-head">
<div>
<h1>视频项目</h1>
<div class="sub"><span class="mono">// 12 个 · 3 进行中 · 8 完成 · 1 失败</span></div>
</div>
<div class="actions">
<a class="btn btn-primary btn-lg" href="projects-new.html">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M12 5v14M5 12h14"/></svg>
新建项目
</a>
</div>
</div>
<div class="tabs" id="status-tabs">
<div class="tab active" data-filter="all">全部 <span class="count">12</span></div>
<div class="tab" data-filter="wip">进行中 <span class="count">3</span></div>
<div class="tab" data-filter="done">已完成 <span class="count">8</span></div>
<div class="tab" data-filter="fail">失败 <span class="count">1</span></div>
</div>
<div class="toolbar">
<div class="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input class="input" id="search-input" placeholder="搜索项目名称、商品">
</div>
<button class="chip" onclick="Shell.toast('商品筛选', '/filter/product')">商品 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
<button class="chip" onclick="Shell.toast('脚本来源筛选', '/filter/source')">脚本来源 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
<button class="chip" onclick="Shell.toast('时间筛选', '/filter/date')">创建时间 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
<span class="spacer"></span>
<div class="view-toggle">
<button id="view-grid" data-view="grid">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><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 id="view-list" class="active" data-view="list">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M2 4h12M2 8h12M2 12h12"/></svg>
列表
</button>
</div>
</div>
<div class="result-meta" id="result-meta">// 显示 <span class="count">12</span> / 12 个项目</div>
<!-- ============= LIST VIEW ============= -->
<div id="list-view">
<table class="t">
<thead>
<tr>
<th style="width:32%">项目</th>
<th>商品</th>
<th>脚本来源</th>
<th style="width:200px">进度</th>
<th>状态</th>
<th style="width:120px">更新于</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody id="list-tbody">
<tr data-status="wip" data-name="补水面膜 痛点种草" onclick="location.href='pipeline.html#stage-3'">
<td>
<div class="proj-name-cell">
<div class="placeholder proj-thumb"><span class="ph-frame">9:16</span></div>
<div><div class="proj-name">补水面膜 · 痛点种草 · v3</div><div class="proj-sub">6 镜 · 0-15s</div></div>
</div>
</td>
<td>透真补水面膜</td>
<td><span class="muted">AI 全生</span></td>
<td>
<div class="hstack">
<div class="prog"><span class="done"></span><span class="done"></span><span class="cur"></span><span></span><span></span></div>
<span class="muted-2 mono" style="font-size:11px;">3/5</span>
</div>
</td>
<td><span class="pill info"><span class="dot"></span>故事板 待确认</span></td>
<td class="muted-2">12 分钟前</td>
<td>
<div class="row-action">
<a href="pipeline.html#stage-3" onclick="event.stopPropagation()" title="继续"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></a>
<a onclick="event.stopPropagation();Shell.toast('更多操作')"><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></a>
</div>
</td>
</tr>
<tr data-status="wip" data-name="速食牛肉面 加班治愈" onclick="location.href='pipeline.html#stage-2'">
<td>
<div class="proj-name-cell">
<div class="placeholder proj-thumb"><span class="ph-frame">9:16</span></div>
<div><div class="proj-name">速食牛肉面 · 加班治愈</div><div class="proj-sub">4 镜 · 0-12s</div></div>
</div>
</td>
<td>滋啦速食 · 6 桶装</td>
<td><span class="muted">一句话主题</span></td>
<td>
<div class="hstack">
<div class="prog"><span class="done"></span><span class="cur"></span><span></span><span></span><span></span></div>
<span class="muted-2 mono" style="font-size:11px;">2/5</span>
</div>
</td>
<td><span class="pill info"><span class="dot"></span>资产生成中</span></td>
<td class="muted-2">37 分钟前</td>
<td></td>
</tr>
<tr data-status="wip" data-name="透真防晒 通勤对比" onclick="location.href='pipeline.html#stage-4'">
<td>
<div class="proj-name-cell">
<div class="placeholder proj-thumb"><span class="ph-frame">9:16</span></div>
<div><div class="proj-name">透真防晒 · 通勤对比</div><div class="proj-sub">6 镜 · 0-18s</div></div>
</div>
</td>
<td>透真清透防晒霜</td>
<td><span class="muted">AI 全生</span></td>
<td>
<div class="hstack">
<div class="prog"><span class="done"></span><span class="done"></span><span class="done"></span><span class="cur"></span><span></span></div>
<span class="muted-2 mono" style="font-size:11px;">4/5</span>
</div>
</td>
<td><span class="pill info"><span class="dot"></span>视频生成 4/6</span></td>
<td class="muted-2">2 小时前</td>
<td></td>
</tr>
<tr data-status="fail" data-name="咖啡冻干 早八剧情" onclick="location.href='pipeline.html#stage-3'">
<td>
<div class="proj-name-cell">
<div class="placeholder proj-thumb"><span class="ph-frame">9:16</span></div>
<div><div class="proj-name">咖啡冻干 · 早八剧情</div><div class="proj-sub">5 镜 · 0-15s</div></div>
</div>
</td>
<td>三顿半同款冻干</td>
<td><span class="muted">一句话主题</span></td>
<td>
<div class="hstack">
<div class="prog"><span class="done"></span><span class="done"></span><span class="fail"></span><span></span><span></span></div>
<span class="muted-2 mono" style="font-size:11px;">3/5</span>
</div>
</td>
<td><span class="pill err"><span class="dot"></span>故事板生成失败</span></td>
<td class="muted-2">昨天 18:42</td>
<td></td>
</tr>
<tr data-status="done" data-name="蓝牙耳机 开箱测评" onclick="location.href='pipeline.html#stage-5'">
<td>
<div class="proj-name-cell">
<div class="placeholder proj-thumb"><span class="ph-frame">9:16</span></div>
<div><div class="proj-name">蓝牙耳机 · 开箱测评</div><div class="proj-sub">5 镜 · 0-15s</div></div>
</div>
</td>
<td>南卡 Lite Pro</td>
<td><span class="muted">自带脚本</span></td>
<td>
<div class="hstack">
<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="muted-2 mono" style="font-size:11px;">5/5</span>
</div>
</td>
<td><span class="pill ok"><span class="dot"></span>已完成</span></td>
<td class="muted-2">5 月 7 日</td>
<td></td>
</tr>
<tr data-status="done" data-name="瑜伽裤 通勤穿搭" onclick="location.href='pipeline.html#stage-5'">
<td>
<div class="proj-name-cell">
<div class="placeholder proj-thumb"><span class="ph-frame">9:16</span></div>
<div><div class="proj-name">瑜伽裤 · 通勤穿搭</div><div class="proj-sub">5 镜 · 0-15s</div></div>
</div>
</td>
<td>露露同款瑜伽裤</td>
<td><span class="muted">AI 全生</span></td>
<td>
<div class="hstack">
<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="muted-2 mono" style="font-size:11px;">5/5</span>
</div>
</td>
<td><span class="pill ok"><span class="dot"></span>已完成</span></td>
<td class="muted-2">5 月 6 日</td>
<td></td>
</tr>
<tr data-status="done" data-name="空气炸锅 小户型" onclick="location.href='pipeline.html#stage-5'">
<td>
<div class="proj-name-cell">
<div class="placeholder proj-thumb"><span class="ph-frame">9:16</span></div>
<div><div class="proj-name">空气炸锅 · 小户型</div><div class="proj-sub">4 镜 · 0-12s</div></div>
</div>
</td>
<td>小熊 4L 空气炸锅</td>
<td><span class="muted">一句话主题</span></td>
<td>
<div class="hstack">
<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="muted-2 mono" style="font-size:11px;">5/5</span>
</div>
</td>
<td><span class="pill ok"><span class="dot"></span>已完成</span></td>
<td class="muted-2">5 月 4 日</td>
<td></td>
</tr>
<tr data-status="archived" data-name="补水面膜 痛点种草 v1" onclick="location.href='pipeline.html#stage-5'">
<td>
<div class="proj-name-cell">
<div class="placeholder proj-thumb"><span class="ph-frame">9:16</span></div>
<div><div class="proj-name">补水面膜 · 痛点种草 · v1</div><div class="proj-sub">6 镜 · 0-15s</div></div>
</div>
</td>
<td>透真补水面膜</td>
<td><span class="muted">AI 全生</span></td>
<td>
<div class="hstack">
<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="muted-2 mono" style="font-size:11px;">5/5</span>
</div>
</td>
<td><span class="pill neutral"><span class="dot"></span>已归档</span></td>
<td class="muted-2">4 月 28 日</td>
<td></td>
</tr>
</tbody>
</table>
</div>
<!-- ============= GRID VIEW ============= -->
<div id="grid-view" style="display:none;">
<div class="proj-grid" id="grid-body">
<div class="proj-card" data-status="wip" data-name="补水面膜 痛点种草" onclick="location.href='pipeline.html#stage-3'">
<div class="placeholder card-thumb"><span class="ph-frame">9:16 · 镜 3/6</span></div>
<div class="card-body">
<div>
<div class="card-name">补水面膜 · 痛点种草 · v3</div>
<div class="card-sub" style="margin-top:4px;">透真补水面膜 · 6 镜</div>
</div>
<div class="hstack">
<div class="prog"><span class="done"></span><span class="done"></span><span class="cur"></span><span></span><span></span></div>
<span class="muted-2 mono" style="font-size:10.5px;">3/5</span>
</div>
<div class="card-foot">
<span class="pill info"><span class="dot"></span>故事板 待确认</span>
<span class="card-time">12 分钟前</span>
</div>
</div>
</div>
<div class="proj-card" data-status="wip" data-name="速食牛肉面 加班治愈" onclick="location.href='pipeline.html#stage-2'">
<div class="placeholder card-thumb"><span class="ph-frame">9:16 · 镜 2/4</span></div>
<div class="card-body">
<div>
<div class="card-name">速食牛肉面 · 加班治愈</div>
<div class="card-sub" style="margin-top:4px;">滋啦速食 · 4 镜</div>
</div>
<div class="hstack">
<div class="prog"><span class="done"></span><span class="cur"></span><span></span><span></span><span></span></div>
<span class="muted-2 mono" style="font-size:10.5px;">2/5</span>
</div>
<div class="card-foot">
<span class="pill info"><span class="dot"></span>资产生成中</span>
<span class="card-time">37 分钟前</span>
</div>
</div>
</div>
<div class="proj-card" data-status="wip" data-name="透真防晒 通勤对比" onclick="location.href='pipeline.html#stage-4'">
<div class="placeholder card-thumb"><span class="ph-frame">9:16 · 镜 4/6</span></div>
<div class="card-body">
<div>
<div class="card-name">透真防晒 · 通勤对比</div>
<div class="card-sub" style="margin-top:4px;">透真清透防晒霜 · 6 镜</div>
</div>
<div class="hstack">
<div class="prog"><span class="done"></span><span class="done"></span><span class="done"></span><span class="cur"></span><span></span></div>
<span class="muted-2 mono" style="font-size:10.5px;">4/5</span>
</div>
<div class="card-foot">
<span class="pill info"><span class="dot"></span>视频 4/6</span>
<span class="card-time">2 小时前</span>
</div>
</div>
</div>
<div class="proj-card" data-status="fail" data-name="咖啡冻干 早八剧情" onclick="location.href='pipeline.html#stage-3'">
<div class="placeholder card-thumb"><span class="ph-frame">9:16 · 镜 3/5</span></div>
<div class="card-body">
<div>
<div class="card-name">咖啡冻干 · 早八剧情</div>
<div class="card-sub" style="margin-top:4px;">三顿半同款 · 5 镜</div>
</div>
<div class="hstack">
<div class="prog"><span class="done"></span><span class="done"></span><span class="fail"></span><span></span><span></span></div>
<span class="muted-2 mono" style="font-size:10.5px;">3/5</span>
</div>
<div class="card-foot">
<span class="pill err"><span class="dot"></span>故事板失败</span>
<span class="card-time">昨天 18:42</span>
</div>
</div>
</div>
<div class="proj-card" data-status="done" data-name="蓝牙耳机 开箱测评" onclick="location.href='pipeline.html#stage-5'">
<div class="placeholder card-thumb"><span class="ph-frame">9:16 · 5/5 ✓</span></div>
<div class="card-body">
<div>
<div class="card-name">蓝牙耳机 · 开箱测评</div>
<div class="card-sub" style="margin-top:4px;">南卡 Lite Pro · 5 镜</div>
</div>
<div class="hstack">
<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="muted-2 mono" style="font-size:10.5px;">5/5</span>
</div>
<div class="card-foot">
<span class="pill ok"><span class="dot"></span>已完成</span>
<span class="card-time">5 月 7 日</span>
</div>
</div>
</div>
<div class="proj-card" data-status="done" data-name="瑜伽裤 通勤穿搭" onclick="location.href='pipeline.html#stage-5'">
<div class="placeholder card-thumb"><span class="ph-frame">9:16 · 5/5 ✓</span></div>
<div class="card-body">
<div>
<div class="card-name">瑜伽裤 · 通勤穿搭</div>
<div class="card-sub" style="margin-top:4px;">露露同款 · 5 镜</div>
</div>
<div class="hstack">
<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="muted-2 mono" style="font-size:10.5px;">5/5</span>
</div>
<div class="card-foot">
<span class="pill ok"><span class="dot"></span>已完成</span>
<span class="card-time">5 月 6 日</span>
</div>
</div>
</div>
<div class="proj-card" data-status="done" data-name="空气炸锅 小户型" onclick="location.href='pipeline.html#stage-5'">
<div class="placeholder card-thumb"><span class="ph-frame">9:16 · 5/5 ✓</span></div>
<div class="card-body">
<div>
<div class="card-name">空气炸锅 · 小户型</div>
<div class="card-sub" style="margin-top:4px;">小熊 4L · 4 镜</div>
</div>
<div class="hstack">
<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="muted-2 mono" style="font-size:10.5px;">5/5</span>
</div>
<div class="card-foot">
<span class="pill ok"><span class="dot"></span>已完成</span>
<span class="card-time">5 月 4 日</span>
</div>
</div>
</div>
<div class="proj-card" data-status="archived" data-name="补水面膜 痛点种草 v1" onclick="location.href='pipeline.html#stage-5'">
<div class="placeholder card-thumb"><span class="ph-frame">9:16 · 5/5 ✓</span></div>
<div class="card-body">
<div>
<div class="card-name">补水面膜 · 痛点种草 · v1</div>
<div class="card-sub" style="margin-top:4px;">透真补水面膜 · 6 镜</div>
</div>
<div class="hstack">
<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="muted-2 mono" style="font-size:10.5px;">5/5</span>
</div>
<div class="card-foot">
<span class="pill neutral"><span class="dot"></span>已归档</span>
<span class="card-time">4 月 28 日</span>
</div>
</div>
</div>
</div>
</div>
<!-- Empty state -->
<div class="empty-state" id="empty">
<div class="ic-empty">
<svg width="20" height="20" 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>
</div>
<h3>没有匹配的项目</h3>
<p>// 试试切换 tab 或修改搜索词</p>
</div>
</div>
<script src="assets/shell.js"></script>
<script>
Shell.render({ active: 'projects', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '视频项目' }] });
// ============== Tab + search filter + view toggle ==============
const state = { filter: 'all', view: 'list', search: '' };
const TOTAL = 8; // 实际渲染的样本数
function applyFilter() {
const isList = state.view === 'list';
document.getElementById('list-view').style.display = isList ? '' : 'none';
document.getElementById('grid-view').style.display = isList ? 'none' : '';
const items = document.querySelectorAll(isList ? '#list-tbody tr' : '.proj-card');
let visible = 0;
items.forEach(el => {
const status = el.dataset.status || '';
const name = (el.dataset.name || '').toLowerCase();
const matchFilter = state.filter === 'all' || status.split(' ').includes(state.filter);
const matchSearch = !state.search || name.includes(state.search.toLowerCase());
const show = matchFilter && matchSearch;
el.style.display = show ? '' : 'none';
if (show) visible++;
});
// empty state
const empty = document.getElementById('empty');
if (visible === 0) {
empty.classList.add('show');
document.getElementById('list-view').style.display = 'none';
document.getElementById('grid-view').style.display = 'none';
} else {
empty.classList.remove('show');
}
// result meta
document.getElementById('result-meta').innerHTML = `// 显示 <span class="count">${visible}</span> / ${TOTAL} 个项目`;
}
// Tab clicks
document.querySelectorAll('#status-tabs .tab').forEach(t => {
t.addEventListener('click', () => {
document.querySelectorAll('#status-tabs .tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');
state.filter = t.dataset.filter;
applyFilter();
Shell.toast('筛选: ' + t.textContent.trim().split(' ')[0], 'filter=' + state.filter);
});
});
// View toggle
document.querySelectorAll('.view-toggle button').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('.view-toggle button').forEach(x => x.classList.remove('active'));
b.classList.add('active');
state.view = b.dataset.view;
applyFilter();
Shell.toast('视图切换: ' + (state.view === 'list' ? '列表' : '网格'), 'view=' + state.view);
});
});
// Search
document.getElementById('search-input').addEventListener('input', e => {
state.search = e.target.value.trim();
applyFilter();
});
applyFilter();
</script>
</body>
</html>

View File

@ -0,0 +1,59 @@
# 待归档/清理的 HTML 文件
> 2026-05-21 · 设计稿对接前清理清单
下列文件是早期版本或独立实验页,**不在主流程上**,对接给团队前建议归档/删除。
保留它们不会影响主流程渲染(没有 sidebar / 内部 link 指向),但会让 repo 文件目录显得混乱。
## ✅ 可以直接删除(无任何入口)
| 文件 | 原用途 | 现状 |
|------|--------|------|
| `product-create.legacy.html` | 旧版「新建商品」全屏页(3883 行 drawer) | 已被 `product-create.html`(stub 重定向)+ drawer 模式替代,无任何链接 |
| `product-create-v2.html` | 备选「新建商品」实验版 | 仅 `product-studio.html` 单向关联,product-studio 本身已无入口 |
| `studio.html` | 早期工作室页占位 | 已被 `pipeline.html` 替代,sidebar 已切换,无 link |
| `studio-v2.html` | 早期工作室 V2 实验 | 同上 |
| `product-studio.html` | 商品 + 工作室合并实验 | 主流程已拆为 `product-detail.html` + `pipeline.html` |
## ⚠️ 保留但要清理 sidebar 入口判断
| 文件 | 决定 |
|------|------|
| `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` | **保留** · 设计系统参考,非用户路径 |
## 🚀 清理命令(对接前)
如果决定执行,可在 `电商AI平台/` 目录下:
```powershell
# Windows PowerShell
Remove-Item product-create.legacy.html, product-create-v2.html, studio.html, studio-v2.html, product-studio.html
```
清理后总文件数:**21 → 16**(含 design-system),核心 15 个。
## 📌 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(设计参考 · 非用户路径)

View File

@ -2,63 +2,186 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>账户 · 流·Studio</title> <title>消费 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=202605211643"> <link rel="stylesheet" href="assets/restraint.css?v=202605211643">
<style> <style>
.acc-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 24px; align-items: start; } /* ─── 顶部:左右布局(余额 banner + 快速充值)─── */
.top-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 16px; margin-bottom: 22px; align-items: stretch; }
@media (max-width: 980px) { .top-grid { grid-template-columns: 1fr; } }
.balance-banner { .balance-banner {
background: var(--accent-black); background: var(--accent-black);
color: var(--accent-white); color: var(--accent-white);
padding: 28px 32px; padding: 24px 28px;
margin-bottom: 24px;
position: relative; position: relative;
border: 1px solid var(--accent-black); border: 1px solid var(--accent-black);
border-radius: var(--r-md); border-radius: var(--r-md);
display: flex;
flex-direction: column;
gap: 22px;
min-width: 0;
}
.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, .balance-banner::after { 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::before { top: -7px; left: -7px; }
.balance-banner::after { bottom: -7px; right: -7px; } .balance-banner::after { bottom: -7px; right: -7px; }
.balance-banner .corner-tr, .balance-banner .corner-bl { position: absolute; color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 13px; } .balance-banner > .corner-tr { top: -7px; right: -7px; }
.balance-banner .corner-tr { top: -8px; right: -8px; } .balance-banner > .corner-bl { bottom: -7px; left: -7px; }
.balance-banner .corner-bl { bottom: -8px; left: -8px; }
.balance-banner .lbl { font-family: var(--font-mono); font-size: 12px; color: rgba(255,255,255,.55); letter-spacing: .04em; }
.balance-banner .v { font-size: 42px; font-weight: 700; letter-spacing: -.018em; margin-top: 8px; font-variant-numeric: tabular-nums; }
.balance-banner .meta { font-size: 12.5px; color: rgba(255,255,255,.5); margin-top: 8px; font-family: var(--font-mono); letter-spacing: .02em; }
.balance-banner .actions { display: flex; gap: 8px; margin-top: 18px; }
.balance-banner .btn { background: var(--accent-white); color: var(--accent-black); border-color: var(--accent-white); }
.balance-banner .btn:hover { background: var(--background-base); }
.balance-banner .btn-ghost { background: transparent; color: var(--accent-white); border: 1px solid rgba(255,255,255,.25); }
.balance-banner .btn-ghost:hover { background: rgba(255,255,255,.1); color: var(--accent-white); }
.recharge-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 12px; } /* 主余额 · 突出展示 */
.recharge-card { border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 18px; text-align: center; cursor: pointer; background: var(--surface); position: relative; } .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: 14px 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-actions { display: flex; gap: 8px; margin-top: auto; }
.balance-actions .btn { background: var(--accent-white); color: var(--accent-black); border-color: var(--accent-white); flex: 1; }
.balance-actions .btn:hover { background: var(--background-base); }
.balance-actions .btn-ghost { background: transparent; color: var(--accent-white); border: 1px solid rgba(255,255,255,.25); flex: 1; }
.balance-actions .btn-ghost:hover { background: rgba(255,255,255,.1); color: var(--accent-white); }
/* ─── 快速充值 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; }
.recharge-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.recharge-card { border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px 16px; text-align: center; cursor: pointer; background: var(--surface); position: relative; transition: background var(--t-base), border-color var(--t-base); }
.recharge-card:hover { background: var(--background-lighter); } .recharge-card:hover { background: var(--background-lighter); }
.recharge-card.selected { border-color: var(--heat); background: var(--heat-12); } .recharge-card.selected { border-color: var(--heat); background: var(--heat-12); }
.recharge-card.selected::before, .recharge-card.selected::after { 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='%23FA5D19'%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; } .recharge-card.selected::before, .recharge-card.selected::after { 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='%23FA5D19'%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; }
.recharge-card.selected::before { top: -7px; left: -7px; } .recharge-card.selected::before { top: -7px; left: -7px; }
.recharge-card.selected::after { bottom: -7px; right: -7px; } .recharge-card.selected::after { bottom: -7px; right: -7px; }
.recharge-card .amt { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; } .recharge-card .amt { font-size: 19px; font-weight: 700; font-variant-numeric: tabular-nums; }
.recharge-card .gift { font-size: 11px; color: var(--black-alpha-48); margin-top: 4px; font-family: var(--font-mono); } .recharge-card .gift { font-size: 10.5px; color: var(--black-alpha-48); margin-top: 2px; font-family: var(--font-mono); }
.recharge-card .gift.bonus { color: var(--accent-forest); font-weight: 600; } .recharge-card .gift.bonus { color: var(--accent-forest); font-weight: 600; }
.recharge-card .ribbon { position: absolute; top: -8px; right: 8px; font-family: var(--font-mono); font-size: 9.5px; padding: 1px 6px; background: var(--heat); color: var(--accent-white); letter-spacing: .04em; font-weight: 600; border-radius: var(--r-sm); } .recharge-card .ribbon { position: absolute; top: -8px; right: 8px; font-family: var(--font-mono); font-size: 9.5px; padding: 1px 6px; background: var(--heat); color: var(--accent-white); letter-spacing: .04em; font-weight: 600; border-radius: var(--r-sm); }
.pay-row { display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 14px; }
.pay-row .input { width: 100%; box-sizing: border-box; height: 38px; }
.pay-btn-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.pay-btn-row .btn { width: 100%; }
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 20px; margin-bottom: 16px; } /* ─── Tab strip ─── */
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 14px; } .billing-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border-faint); margin: 26px 0 18px; padding: 0 2px; }
.pane h3 + .desc { font-size: 12px; color: var(--black-alpha-48); margin-top: -10px; margin-bottom: 14px; font-family: var(--font-mono); letter-spacing: .02em; } .billing-tabs .tab { background: none; border: 0; padding: 10px 16px 11px; font-size: 13px; color: var(--black-alpha-56); font-family: inherit; cursor: pointer; border-bottom: 2px solid transparent; position: relative; top: 1px; letter-spacing: .01em; transition: color var(--t-base); }
.billing-tabs .tab:hover { color: var(--accent-black); }
.billing-tabs .tab.active { color: var(--accent-black); font-weight: 600; border-bottom-color: var(--heat); }
.billing-tabs .tab .mono { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-32); margin-left: 4px; letter-spacing: .02em; font-weight: 400; }
.billing-tabs .tab.active .mono { color: var(--heat); }
.bills .neg { color: var(--accent-black); font-variant-numeric: tabular-nums; font-weight: 500; } .tab-panel { display: none; }
.bills .pos { color: var(--accent-forest); font-variant-numeric: tabular-nums; font-weight: 500; } .tab-panel.active { display: block; }
.bills .ref { color: var(--black-alpha-48); font-size: 11px; font-family: var(--font-mono); }
.usage-line { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; } /* ─── 总览 · 趋势 + 阶段分布 ─── */
.usage-line .v { font-variant-numeric: tabular-nums; color: var(--accent-black); font-weight: 600; } .overview-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 16px; align-items: start; }
.usage-bar { height: 4px; background: var(--background-lighter); border-radius: 2px; margin: 6px 0 12px; overflow: hidden; }
.usage-bar > span { display: block; height: 100%; }
.rule-list { font-size: 12.5px; color: var(--black-alpha-56); line-height: 1.7; } .trend-pane { padding: 18px 20px 14px; }
.rule-list strong { color: var(--accent-black); font-weight: 600; } .trend-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 14px; }
.rule-list .mono { font-family: var(--font-mono); color: var(--heat); background: var(--heat-12); padding: 1px 5px; font-size: 11.5px; } .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; height: 170px; 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-faint); 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: 1px solid var(--border-faint); }
.billing-table thead th { background: var(--background-lighter); 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:last-child td { border-bottom: 0; }
.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> </style>
</head> </head>
<body> <body>
@ -66,105 +189,424 @@
<div class="page-head"> <div class="page-head">
<div> <div>
<h1>账户</h1> <h1>消费</h1>
<div class="sub"><span class="mono">// 余额 · 充值 · 消费明细</span></div> <div class="sub"><span class="mono">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div>
</div> </div>
</div> </div>
<div class="acc-grid"> <!-- 顶部:余额 banner(左)+ 快速充值(右)左右布局 -->
<div> <div class="top-grid">
<div class="balance-banner"> <!-- 左:余额 banner -->
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span> <div class="balance-banner">
<div class="lbl">[ CURRENT BALANCE ]</div> <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="v">¥327.40</div>
<div class="meta">// 本月已消费 ¥162.60 · 可使用约 32 个项目</div> <div class="meta">// 充值累加 · 不重置</div>
<div class="actions"> </div>
<button class="btn btn-lg" onclick="Shell.toast('充值', '/billing/topup')">充值</button> <div class="balance-sub">
<button class="btn btn-ghost btn-lg" onclick="Shell.toast('提取明细', '/billing/export')">提取消费明细</button> <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> </div>
<div class="balance-actions">
<div class="pane"> <button class="btn btn-lg" onclick="openTopup()">充值</button>
<h3>快速充值</h3> <button class="btn btn-ghost btn-lg" onclick="Shell.toast('提取明细', 'CSV · /billing/export')">导出账单</button>
<div class="desc">// 充值后立刻到账,可开发票</div>
<div class="recharge-row">
<div class="recharge-card" onclick="Shell.toast('选择 ¥100')"><div class="amt">¥100</div><div class="gift">无赠送</div></div>
<div class="recharge-card selected"><span class="ribbon">推荐</span><div class="amt">¥500</div><div class="gift bonus">+ ¥30 赠送</div></div>
<div class="recharge-card" onclick="Shell.toast('选择 ¥1000')"><div class="amt">¥1000</div><div class="gift bonus">+ ¥80 赠送</div></div>
<div class="recharge-card" onclick="Shell.toast('选择 ¥3000')"><div class="amt">¥3000</div><div class="gift bonus">+ ¥300 赠送</div></div>
</div>
<div style="display:flex; gap:10px; margin-top:14px;">
<input class="input" placeholder="自定义金额(最低 ¥50)" style="flex:1;">
<button class="btn btn-primary" onclick="Shell.toast('微信支付', '¥500 · WechatPay')">微信支付 ¥500</button>
<button class="btn" onclick="Shell.toast('支付宝', '¥500 · Alipay')">支付宝</button>
</div>
</div> </div>
<div style="display:flex; align-items:baseline; margin-bottom:12px;">
<h2 style="font-size:15px; font-weight:600;">消费明细</h2>
<span class="spacer"></span>
<div class="hstack">
<button class="chip" style="height:28px; font-size:12px;">近 30 天</button>
<button class="chip" style="height:28px; font-size:12px;">导出</button>
</div>
</div>
<table class="t bills">
<thead>
<tr><th>时间</th><th>项目 / 类型</th><th>详情</th><th style="text-align:right;">金额</th></tr>
</thead>
<tbody>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.09 14:08</td><td>补水面膜 · v3</td><td class="muted">故事板 image-2 · 1 次</td><td style="text-align:right;" class="neg">-¥0.45</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.09 14:02</td><td>补水面膜 · v3</td><td class="muted">脚本 LLM · 2.4k tokens</td><td style="text-align:right;" class="neg">-¥0.04</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.09 13:38</td><td>补水面膜 · v3</td><td class="muted">基础资产 · 5 张图</td><td style="text-align:right;" class="neg">-¥1.05</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.08 18:21</td><td>透真防晒 · 通勤对比</td><td class="muted">视频片段 · 6 镜</td><td style="text-align:right;" class="neg">-¥1.20</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.08 11:02</td><td>充值</td><td class="muted">微信支付 · <span class="ref">TX2024050811021Z</span></td><td style="text-align:right;" class="pos">+¥500.00</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.07 20:14</td><td>蓝牙耳机 · 开箱</td><td class="muted">视频片段 · 5 镜(1 镜重跑不扣)</td><td style="text-align:right;" class="neg">-¥0.94</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.07 15:48</td><td>咖啡冻干 · 早八</td><td class="muted">故事板生成失败 · <span style="color:var(--black-alpha-48);">不扣费</span></td><td style="text-align:right;" class="muted-2">¥0.00</td></tr>
<tr><td class="muted-2 mono" style="font-size:11.5px;">05.06 10:30</td><td>瑜伽裤 · 通勤穿搭</td><td class="muted">项目导出 · 1 次</td><td style="text-align:right;" class="neg">-¥3.20</td></tr>
</tbody>
</table>
</div> </div>
<div> <!-- 右:快速充值 -->
<div class="pane"> <div class="pane topup-pane">
<h3>本月消费分布</h3> <h3>快速充值</h3>
<div class="usage-line"><span>视频片段(Seedance)</span><span class="v">¥98.40</span></div> <div class="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
<div class="usage-bar"><span style="width:60%; background:var(--heat);"></span></div> <div class="recharge-row">
<div class="recharge-card" data-amt="100"><div class="amt">¥100</div><div class="gift">无赠送</div></div>
<div class="usage-line"><span>故事板(image-2)</span><span class="v">¥36.00</span></div> <div class="recharge-card selected" data-amt="500"><span class="ribbon">推荐</span><div class="amt">¥500</div><div class="gift bonus">+ ¥30 赠送</div></div>
<div class="usage-bar"><span style="width:22%; background:var(--accent-forest);"></span></div> <div class="recharge-card" data-amt="1000"><div class="amt">¥1000</div><div class="gift bonus">+ ¥80 赠送</div></div>
<div class="recharge-card" data-amt="3000"><div class="amt">¥3000</div><div class="gift bonus">+ ¥300 赠送</div></div>
<div class="usage-line"><span>基础资产</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>脚本 LLM</span><span class="v">¥7.20</span></div>
<div class="usage-bar"><span style="width:5%; background:var(--black-alpha-48);"></span></div>
<div class="divider"></div>
<div class="usage-line" style="font-weight:600;"><span>合计</span><span class="v">¥162.60</span></div>
</div> </div>
<div class="pay-row">
<input class="input" id="custom-amt" placeholder="自定义金额(最低 ¥50)">
<div class="pay-btn-row">
<button class="btn btn-primary" onclick="openTopup('wechat')">微信支付</button>
<button class="btn" onclick="openTopup('alipay')">支付宝</button>
</div>
</div>
</div>
</div>
<div class="pane"> <!-- Tab strip -->
<h3>扣费规则</h3> <div class="billing-tabs" role="tablist">
<div class="rule-list"> <button class="tab active" data-tab="overview" role="tab">总览 <span class="mono">// 当月趋势 + 阶段分布</span></button>
<strong>① 失败不扣</strong>:模型超时、内容审核拦截、生成异常一律不扣费。<br> <button class="tab" data-tab="by-project" role="tab">按项目 <span class="mono">// 8 个</span></button>
<strong>② 用户重跑不扣首次</strong>:第一次重跑保留原扣费,第二次起按次结算。<br> <button class="tab" data-tab="by-member" role="tab">按成员 <span class="mono">// 5 人</span></button>
<strong>③ 仅在你点击 <span class="mono">[ 确认通过 ]</span> 时入账</strong><br> <button class="tab" data-tab="bills" role="tab">账单流水 <span class="mono">// 30 天</span></button>
<strong>④ 导出不再扣费</strong>,所有 token 已在过程中结算。 </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">// 近 14 天 · 单位 ¥</span>
<span class="spacer"></span>
<button class="chip active"></button>
<button class="chip"></button>
<button class="chip"></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> </div>
<div class="pane"> <div class="pane stage-pane">
<h3>开发票</h3> <h3>本月按阶段分布</h3>
<div style="font-size:12.5px; color:var(--black-alpha-56); margin-bottom:10px;">本月可开发票额度:<strong style="color:var(--accent-black);">¥162.60</strong></div> <div class="desc">// PRD §5.3.5 扣费规则 · 仅确认后扣</div>
<button class="btn" style="width:100%;" onclick="Shell.toast('申请发票', '/billing/invoice')">申请发票</button> <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><option>全部状态</option><option>进行中</option><option>已完成</option><option>已归档</option></select>
<select><option>本月</option><option>近 30 天</option><option>近 90 天</option></select>
<span class="spacer"></span>
<span class="ct"><b style="color:var(--accent-black);">8</b> 个项目 · 当月消耗 ¥162.60</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>
<!-- ===== Tab 3: 按成员 ===== -->
<div class="tab-panel" id="panel-by-member">
<div class="filter-bar">
<select><option>全部角色</option><option>超管</option><option>团管</option><option>成员</option></select>
<select><option>本月</option><option>近 30 天</option><option>近 90 天</option></select>
<span class="spacer"></span>
<span class="ct"><b style="color:var(--accent-black);">5</b> 人 · 当月合计 ¥319.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>
<!-- ===== Tab 4: 账单流水 ===== -->
<div class="tab-panel" id="panel-bills">
<div class="filter-bar">
<select><option>全部阶段</option><option>视频片段</option><option>故事板</option><option>基础资产</option><option>脚本 LLM</option><option>充值</option><option>导出</option></select>
<select><option>全部成员</option><option>小李</option><option>张运营</option><option>王小姐</option><option>陈策划</option></select>
<select><option>近 30 天</option><option>近 7 天</option><option>本月</option><option>全部</option></select>
<span class="spacer"></span>
<span class="ct"><b style="color:var(--accent-black);">28</b> 条 · <button class="chip" style="height:24px;font-size:11px;margin-left:4px;" onclick="Shell.toast('导出 CSV', '/billing/export?range=30d')">导出 CSV</button></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>
<!-- 充值 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.7" 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>
</div> </div>
</div> </div>
<script src="assets/shell.js?v=202605211643"></script> <script src="assets/shell.js?v=202605211643"></script>
<script>Shell.render({ active: 'account', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '账户' }] });</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 },
];
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 bars = document.getElementById('trend-bars');
const xax = document.getElementById('trend-xaxis');
const max = Math.max(...TREND_DAYS.map(d => d.v));
bars.innerHTML = TREND_DAYS.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('');
xax.innerHTML = TREND_DAYS.map((d, i) => i % 2 === 0 ? `<span>${d.d.slice(3)}</span>` : '<span></span>').join('');
const sum = TREND_DAYS.reduce((s, d) => s + d.v, 0);
document.getElementById('trend-sum').textContent = fmtMoney(sum);
document.getElementById('trend-avg').textContent = fmtMoney(sum / TREND_DAYS.length);
document.getElementById('trend-peak').textContent = fmtMoney(max);
}
/* ─── 按项目 表格 ─── */
function renderProjects() {
const tb = document.getElementById('proj-body');
tb.innerHTML = PROJECTS_BILL.map(p => {
const r = ROLE_META[p.role];
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(-p.amount)}">${p.amount === 0 ? '¥0.00' : '-' + fmtMoney(p.amount)}</td>
</tr>
`;
}).join('');
}
/* ─── 按成员 表格 ─── */
function renderMembers() {
const tb = document.getElementById('member-body');
tb.innerHTML = MEMBERS_BILL.map(m => {
const r = ROLE_META[m.role];
const pct = m.monthly > 0 ? (m.used / m.monthly * 100) : 0;
return `
<tr style="cursor:pointer;" onclick="location.href='team.html'">
<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(m.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('');
}
/* ─── 账单流水 表格 ─── */
function renderBills() {
const tb = document.getElementById('bills-body');
tb.innerHTML = BILLS.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('');
}
/* ─── Tab 切换 ─── */
document.querySelectorAll('.billing-tabs .tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.billing-tabs .tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById('panel-' + tab.dataset.tab).classList.add('active');
});
});
/* ─── 快速充值卡选择 ─── */
document.querySelectorAll('.recharge-card').forEach(card => {
card.addEventListener('click', () => {
document.querySelectorAll('.recharge-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
});
});
/* ─── 充值 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>
</body> </body>
</html> </html>

View File

@ -1440,3 +1440,32 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
/* ─── 缺三视图 badge · 商品 / 人物 卡片 thumb 左上角提示 ─── */
.tri-missing-badge {
position: absolute;
top: 8px; left: 8px;
z-index: 2;
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 8px 3px 6px;
background: var(--heat);
color: var(--accent-white);
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 500;
letter-spacing: .03em;
border-radius: var(--r-sm);
box-shadow: 0 1px 2px rgba(0,0,0,.12);
pointer-events: none;
user-select: none;
}
.tri-missing-badge::before {
content: '!';
width: 12px; height: 12px;
border: 1px solid currentColor;
border-radius: 50%;
display: grid; place-items: center;
font-size: 9px; font-weight: 700;
line-height: 1;
}
.tri-missing-badge .lbl-mono { font-family: var(--font-mono); letter-spacing: .04em; }

View File

@ -82,51 +82,51 @@
<a class="more" href="projects.html">[ ALL · 8 ] <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><path d="M5 12h14M12 5l7 7-7 7"/></svg></a> <a class="more" href="projects.html">[ ALL · 8 ] <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><path d="M5 12h14M12 5l7 7-7 7"/></svg></a>
</div> </div>
<div class="card-hard"> <div class="card-hard">
<a class="recent-row" href="pipeline.html#stage-3"> <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="placeholder thumb"><span class="ph-frame">9:16</span></div>
<div class="recent-meta"> <div class="recent-meta">
<div class="name">补水面膜 · 痛点种草</div> <div class="name">补水面膜 · 痛点种草 · v3</div>
<div class="sub">补水面膜 / AI 全生 / 6</div> <div class="sub">透真补水面膜 / AI 全生 / 7</div>
</div> </div>
<div class="prog"><span class="done"></span><span class="done"></span><span class="cur"></span><span></span><span></span></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="pill info"><span class="dot"></span>故事板 待确认</span>
<span class="btn btn-sm">继续</span> <span class="btn btn-sm">继续</span>
</a> </a>
<a class="recent-row" href="pipeline.html#stage-5"> <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="placeholder thumb"><span class="ph-frame">9:16</span></div>
<div class="recent-meta"> <div class="recent-meta">
<div class="name">蓝牙耳机 · 开箱测评</div> <div class="name">透真防晒 · 通勤对比</div>
<div class="sub">南卡 Lite Pro / 自带脚本 / 5</div> <div class="sub">透真防晒 / AI 全生 / 6</div>
</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> <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="pill ok"><span class="dot"></span>已完成</span>
<span class="btn btn-sm">打开</span> <span class="btn btn-sm">打开</span>
</a> </a>
<a class="recent-row" href="pipeline.html#stage-2"> <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="placeholder thumb"><span class="ph-frame">9:16</span></div>
<div class="recent-meta"> <div class="recent-meta">
<div class="name">速食牛肉面 · 一句话主题</div> <div class="name">蓝牙耳机 · 开箱测评</div>
<div class="sub">滋啦速食 / 一句话 / 4 镜</div> <div class="sub">Pro 4 蓝牙耳机 / 自带脚本 / 6 镜</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#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">透真防晒 / AI 全生 / 6 镜</div>
</div> </div>
<div class="prog"><span class="done"></span><span class="done"></span><span class="done"></span><span class="cur"></span><span></span></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="pill info"><span class="dot"></span>视频生成 4/6</span>
<span class="btn btn-sm">继续</span> <span class="btn btn-sm">继续</span>
</a> </a>
<a class="recent-row" href="pipeline.html#stage-3"> <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="placeholder thumb"><span class="ph-frame">9:16</span></div>
<div class="recent-meta"> <div class="recent-meta">
<div class="name">咖啡冻干粉 · 剧情带货</div> <div class="name">春日新品 · 立体口红</div>
<div class="sub">三顿半同款 / 一句话 / 5 镜</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>
<div class="prog"><span class="done"></span><span class="done"></span><span class="fail"></span><span></span><span></span></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="pill err"><span class="dot"></span>故事板失败</span>

207
电商AI平台/login.html Normal file
View File

@ -0,0 +1,207 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>登录 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=202605211643">
<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; gap: 10px; font-size: 18px; font-weight: 700; letter-spacing: -.01em; }
.auth-brand .logo .ic { width: 28px; height: 28px; background: var(--heat); border-radius: 6px; display: grid; place-items: center; }
.auth-brand .logo .ic svg { width: 16px; height: 16px; color: var(--accent-white); }
.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">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L19 7 V17 L12 22 L5 17 V7 Z"/><path d="M12 2 V22"/><path d="M5 7 L19 17"/></svg></span>
<span>流·Studio</span>
</div>
<div class="tag">// SHORT-VIDEO COMMERCE PLATFORM</div>
<div class="hero">
<h1>AI 全流程<br><span class="h">短剧化</span>带货生成</h1>
<p>商品 → AI 脚本 → 故事板 → Seedance 视频片段 → 一键导出 9:16 ≤60s 成片。</p>
</div>
<div class="ascii">
<div class="ln"><span class="k">// step·1</span> &nbsp; 脚本生成 &nbsp; &nbsp; <span class="v">●●●●●</span></div>
<div class="ln"><span class="k">// step·2</span> &nbsp; 基础资产 &nbsp; &nbsp; <span class="v">●●●●○</span></div>
<div class="ln"><span class="k">// step·3</span> &nbsp; 故事板 &nbsp; &nbsp; &nbsp; <span class="v">●●●○○</span></div>
<div class="ln"><span class="k">// step·4</span> &nbsp; 视频片段 &nbsp; &nbsp; <span class="v">●●○○○</span></div>
<div class="ln"><span class="k">// step·5</span> &nbsp; 拼接导出 &nbsp; &nbsp; <span class="v">●○○○○</span></div>
</div>
<div class="foot">
<a href="#">关于</a>
<a href="#">定价</a>
<a href="#">联系</a>
<a href="#">隐私</a>
</div>
</aside>
<!-- 右:表单 -->
<main class="auth-form">
<div class="h-row">
<h2>登录</h2>
<span class="sub">// /auth/login</span>
</div>
<p class="lead">使用团队邀请邮箱登录,接受邀请后自动加入对应团队。</p>
<form id="login-form" autocomplete="off" onsubmit="event.preventDefault(); doLogin();">
<div class="field">
<label class="field-label" for="auth-email">邮箱 <span class="req">*</span></label>
<div class="field-input-wrap">
<svg class="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" 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.6" 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.6" 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();alert('演示稿 · 真实流程为重置密码邮件');">忘记密码?</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="alert('演示稿 · 微信扫码登录占位');">
<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="alert('演示稿 · 飞书 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);
}
</script>
</body>
</html>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>商品库 · 流·Studio</title> <title>商品库 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=202605211643"> <link rel="stylesheet" href="assets/restraint.css?v=202605211800">
<style> <style>
/* ─── 全局 viewport 高度链 (让右侧 toolbar/分页吸顶吸底) ─── */ /* ─── 全局 viewport 高度链 (让右侧 toolbar/分页吸顶吸底) ─── */
/* 整页滚动 · 头部 H1+actions sticky 固定 · 其他随页面滚 */ /* 整页滚动 · 头部 H1+actions sticky 固定 · 其他随页面滚 */
@ -1016,6 +1016,60 @@
<script> <script>
Shell.render({ active: 'products', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '商品库' }] }); Shell.render({ active: 'products', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '商品库' }] });
// ============== 注入用户新建的商品(来自工作台 / 商品库 drawer,写入 localStorage)==============
// 必须在 const cards / TOTAL / CAT_COUNT 之前执行,让后续逻辑把它们当普通商品处理
(function injectExtraProducts() {
let pending;
try {
pending = JSON.parse(localStorage.getItem('fs-extra-products') || '[]');
} catch (e) { return; }
if (!Array.isArray(pending) || !pending.length) return;
const grid = document.getElementById('product-grid');
if (!grid) return;
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// 按 createdAt 升序排,然后逐个 insertBefore firstChild → 最新的排最上
pending.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
pending.forEach(p => {
const card = document.createElement('div');
card.className = 'product-card';
card.dataset.cat = p.cat || '美妆个护';
card.dataset.name = p.name || '';
card.dataset.tags = p.tags || '';
card.dataset.added = '0';
card.dataset.assets = String(p.assets || 0);
card.dataset.videos = String(p.videos || 0);
card.dataset.date = p.date || new Date(p.createdAt || Date.now()).toISOString().slice(0, 10);
card.setAttribute('onclick', `location.href='product-detail.html?t='+Date.now()+'&product=${encodeURIComponent(p.name || '')}'`);
card.dataset.triview = '0';
card.innerHTML = `
<span class="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg></span>
<button class="card-del-btn" type="button" title="删除商品" onclick="event.stopPropagation();" data-action="delete-product"><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 product-thumb">
<span class="tri-missing-badge" title="该商品尚未生成商品三视图,进入详情可在工作流第一步补齐"><span class="lbl-mono">缺三视图</span></span>
<span class="ph-frame">${esc(p.name)} · 新建</span>
</div>
<div class="product-body">
<div class="product-name">${esc(p.name)}</div>
<div class="product-cat">${esc(p.cat || '美妆个护')}</div>
<div class="product-date">${esc(p.date || '')} 创建</div>
</div>
<div class="product-footer">
<span class="stat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M21 17l-5-5-9 9"/></svg>
素材 <b>${p.assets || 0}</b>
</span>
<span class="sep">·</span>
<span class="stat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="14" height="12" rx="2"/><path d="M16 10l6-3v10l-6-3z"/></svg>
视频 <b>${p.videos || 0}</b>
</span>
</div>
`;
grid.insertBefore(card, grid.firstChild);
});
})();
// ============== Products: 商品分类多选 + 搜索 + 创建时间筛选 ============== // ============== Products: 商品分类多选 + 搜索 + 创建时间筛选 ==============
// state.cats: Set<string>,空集 = 全部 // state.cats: Set<string>,空集 = 全部
const PAGE_SIZES = [12, 24, 48, 96]; const PAGE_SIZES = [12, 24, 48, 96];
@ -1675,9 +1729,8 @@ blInput.addEventListener('keydown', e => {
} }
}); });
// 创建商品 (真正插入 grid) // 创建商品 · 持久化到 localStorage + 立即跳详情(中间不闪商品库)
saveBtn.addEventListener('click', () => { saveBtn.addEventListener('click', () => {
// 防呆:每一步明确反馈,清晰看出在哪一步出问题
const name = document.getElementById('pf-name').value.trim(); const name = document.getElementById('pf-name').value.trim();
const cat = document.getElementById('pf-cat').value; const cat = document.getElementById('pf-cat').value;
if (!name) { if (!name) {
@ -1689,110 +1742,39 @@ saveBtn.addEventListener('click', () => {
Shell.toast('请上传商品主图', '至少 1 张 · 必填'); Shell.toast('请上传商品主图', '至少 1 张 · 必填');
return; return;
} }
// 收集核心卖点:存在 .bl-item .bl-text 里(已确认的)+ .bl-add input(用户打了字但未回车)
const confirmedBullets = [...document.querySelectorAll('#pf-bullets .bl-item .bl-text')] const confirmedBullets = [...document.querySelectorAll('#pf-bullets .bl-item .bl-text')]
.map(el => el.textContent.trim()).filter(Boolean); .map(el => el.textContent.trim()).filter(Boolean);
const pendingInput = document.querySelector('#pf-bullets .bl-add .bl-input'); const pendingInput = document.querySelector('#pf-bullets .bl-add .bl-input');
const pendingText = (pendingInput?.value || '').trim(); const pendingText = (pendingInput?.value || '').trim();
if (pendingText) confirmedBullets.push(pendingText); // 包容用户没按回车的情况 if (pendingText) confirmedBullets.push(pendingText);
if (confirmedBullets.length === 0) { if (confirmedBullets.length === 0) {
Shell.toast('请填写核心卖点', '至少 1 条 · 回车确认'); Shell.toast('请填写核心卖点', '至少 1 条 · 回车确认');
pendingInput?.focus(); pendingInput?.focus();
return; return;
} }
const bullets = confirmedBullets; const bullets = confirmedBullets;
// 创建 card 并插入 grid 最前 // 持久化到 localStorage('fs-extra-products'),让 products.html 下次加载时
const today = new Date().toISOString().slice(0, 10); // 自动从 storage 读出并 prepend 到 grid
const card = document.createElement('div'); try {
card.className = 'product-card'; const KEY = 'fs-extra-products';
card.dataset.cat = cat; const list = JSON.parse(localStorage.getItem(KEY) || '[]');
card.dataset.name = name; list.push({
card.dataset.tags = ''; id: 'pp-' + Date.now(),
card.dataset.added = '0'; name, cat,
card.dataset.assets = '0'; tags: '',
card.dataset.videos = '0'; assets: 0,
card.dataset.date = today; videos: 0,
card.setAttribute('onclick', "location.href='product-detail.html?t='+Date.now()"); bullets,
card.innerHTML = ` date: new Date().toISOString().slice(0, 10),
<span class="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg></span> createdAt: Date.now(),
<button class="card-del-btn" type="button" title="删除商品" onclick="event.stopPropagation();" data-action="delete-product"><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 product-thumb"><span class="ph-frame">${name} · 新建</span></div>
<div class="product-body">
<div class="product-name">${name}</div>
<div class="product-cat">${cat}</div>
<div class="product-date">${today} 创建</div>
</div>
<div class="product-footer">
<span class="stat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M21 17l-5-5-9 9"/></svg>
素材 <b>0</b>
</span>
<span class="sep">·</span>
<span class="stat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="14" height="12" rx="2"/><path d="M16 10l6-3v10l-6-3z"/></svg>
视频 <b>0</b>
</span>
</div>
`;
const grid = document.getElementById('product-grid');
grid.insertBefore(card, grid.firstChild);
// 给新卡片注入交互:stat hover/click + del-btn 删除确认
card.querySelectorAll('.product-footer .stat').forEach(stat => {
const text = stat.textContent.trim();
const m = text.match(/^(素材|视频)\s*(\d+)/);
if (!m) return;
const isAsset = m[1] === '素材';
const svg = stat.querySelector('svg');
stat.innerHTML = '';
if (svg) stat.appendChild(svg);
const dft = document.createElement('span');
dft.className = 'stat-default';
dft.innerHTML = `${m[1]} <b>${m[2]}</b>`;
const hv = document.createElement('span');
hv.className = 'stat-hover';
hv.textContent = isAsset ? '+ 去生成素材' : '+ 去生成视频';
stat.appendChild(dft); stat.appendChild(hv);
stat.dataset.type = isAsset ? 'asset' : 'video';
stat.title = isAsset ? '去生成 AI 素材' : '去生成视频项目';
stat.addEventListener('click', e => {
e.stopPropagation();
const n = card.dataset.name || '';
if (isAsset) openGenAssetChoice(n);
else location.href = 'projects-new.html?t=' + Date.now() + '&product=' + encodeURIComponent(n);
}); });
}); localStorage.setItem(KEY, JSON.stringify(list));
const delBtn = card.querySelector('.card-del-btn'); } catch (e) { /* storage 不可用降级到只跳转 */ }
if (delBtn) {
delBtn.addEventListener('click', e => {
e.stopPropagation();
openDelConfirm([card]);
});
}
// 同步 SKU 计数(标题 + 列表 meta + 侧栏 badge)
const remaining = document.querySelectorAll('.product-card').length;
const skuCount = document.getElementById('sku-count');
if (skuCount) skuCount.textContent = remaining;
const meta = document.querySelector('#result-meta .count');
if (meta) meta.textContent = remaining;
const sidebar = document.querySelector('aside.sidebar a[href="products.html"] .pill-mini');
if (sidebar) sidebar.textContent = remaining;
Shell.toast('商品已创建 · 即将跳转详情', `+ ${name} · ${bullets.length} 条卖点`); // 不 close drawer · 跳转期间 drawer 仍覆盖 host 页面 → 视觉上彻底消除"闪商品库"
closeNewProductDrawer(); // 浏览器导航开始后,整页会被新页面替换,drawer 自然消失
// 重置表单(为下次新建准备) Shell.toast('商品已创建 · 跳转详情', `+ ${name} · ${bullets.length} 条卖点`);
document.getElementById('pf-name').value = ''; location.href = 'product-detail.html?t=' + Date.now() + '&product=' + encodeURIComponent(name) + '&id=new';
document.getElementById('pf-cat').value = cat;
if (typeof pfFiles !== 'undefined') pfFiles.length = 0;
const pfGrid = document.getElementById('pf-grid');
if (pfGrid) pfGrid.innerHTML = '';
const blList = document.getElementById('pf-bullets');
if (blList) blList.querySelectorAll('.bl-item').forEach(li => li.remove());
const pendingI = document.querySelector('#pf-bullets .bl-add .bl-input');
if (pendingI) pendingI.value = '';
// 1200ms 后跳转(让 toast 看清),商品库新建商品后跳转到该商品的详情页
setTimeout(() => {
location.href = 'product-detail.html?t=' + Date.now() + '&product=' + encodeURIComponent(name);
}, 1200);
}); });
</script> </script>
</body> </body>

View File

@ -558,7 +558,9 @@ Shell.render({ active: 'projects', crumbs: [{ label: '工作台', href: 'index.h
// 按 createdAt 升序 → 倒序 insert,最新的排最上 // 按 createdAt 升序 → 倒序 insert,最新的排最上
pending.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0)); pending.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
pending.forEach(p => { pending.forEach(p => {
const href = `pipeline.html#stage-${p.stage || 1}`; // 链接带 ?product= 让 pipeline 显示正确商品名(贯穿 5 stage)
const productQ = p.product ? `?product=${encodeURIComponent(p.product)}` : '';
const href = `pipeline.html${productQ}#stage-${p.stage || 1}`;
const status = p.status || 'wip'; const status = p.status || 'wip';
const shots = p.shots || 5; const shots = p.shots || 5;
const durLabel = p.durationLabel || '0-15s'; const durLabel = p.durationLabel || '0-15s';

View File

@ -0,0 +1,235 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>注册团队 · 流·Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=202605211643">
<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; gap: 10px; font-size: 18px; font-weight: 700; letter-spacing: -.01em; }
.auth-brand .logo .ic { width: 28px; height: 28px; background: var(--heat); border-radius: 6px; display: grid; place-items: center; }
.auth-brand .logo .ic svg { width: 16px; height: 16px; color: var(--accent-white); }
.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">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L19 7 V17 L12 22 L5 17 V7 Z"/><path d="M12 2 V22"/><path d="M5 7 L19 17"/></svg></span>
<span>流·Studio</span>
</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.6" 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.6" 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.6" 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.6" 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.6" 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.6" 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.6" 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()">用户协议</a><a href="#" onclick="event.preventDefault()">隐私政策</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);
}
</script>
</body>
</html>

View File

@ -11,10 +11,16 @@
.settings-nav { position: sticky; top: 16px; } .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 .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 a { display: flex; align-items: center; gap: 10px; padding: 10px 12px; font-size: 13px; color: var(--accent-black); border-radius: var(--r-md); border: 1px solid transparent; cursor: pointer; text-decoration: none; transition: background var(--t-base), border-color var(--t-base); } .settings-nav a { display: flex; align-items: center; gap: 10px; padding: 10px 12px; font-size: 13px; color: var(--accent-black); border-radius: var(--r-md); border: 1px solid transparent; cursor: pointer; text-decoration: none; transition: background var(--t-base), border-color var(--t-base); position: relative; }
.settings-nav a:hover { background: var(--background-lighter); } .settings-nav a:hover { background: var(--background-lighter); }
.settings-nav a: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 a.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
.settings-nav a svg { width: 16px; height: 16px; stroke-width: 1.5; } .settings-nav a svg { width: 16px; height: 16px; stroke-width: 1.5; }
.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; }
/* ─── pane ─── */ /* ─── pane ─── */
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 24px; margin-bottom: 16px; } .pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 24px; margin-bottom: 16px; }
@ -73,6 +79,23 @@
.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 .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 .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; } .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; }
</style> </style>
</head> </head>
<body> <body>
@ -94,33 +117,40 @@
<div class="settings-grid"> <div class="settings-grid">
<!-- 左侧 nav --> <!-- 左侧 nav -->
<aside class="settings-nav"> <aside class="settings-nav" role="tablist" aria-label="设置分区">
<div class="nav-h">个人</div> <div class="nav-h">个人</div>
<a href="#sec-profile" class="active" data-jump="sec-profile"> <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"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
个人信息 <span>个人信息</span>
<span class="nav-dot" aria-hidden="true"></span>
</a> </a>
<a href="#sec-security" data-jump="sec-security"> <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"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
安全 <span>安全</span>
<span class="nav-badge" data-count-source="sec-security">3</span>
<span class="nav-dot" aria-hidden="true"></span>
</a> </a>
<a href="#sec-notify" data-jump="sec-notify"> <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"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21a2 2 0 0 0 4 0"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 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> </a>
<div class="nav-h" style="margin-top: 16px;">偏好</div> <div class="nav-h" style="margin-top: 16px;">偏好</div>
<a href="#sec-pref" data-jump="sec-pref"> <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"><path d="m3 7 9-4 9 4-9 4-9-4z"/><path d="m3 12 9 4 9-4"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" 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-4"/></svg>
创作默认 <span>创作默认</span>
<span class="nav-dot" aria-hidden="true"></span>
</a> </a>
<a href="#sec-display" data-jump="sec-display"> <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"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 10h20"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 10h20"/></svg>
显示 <span>显示</span>
<span class="nav-dot" aria-hidden="true"></span>
</a> </a>
<div class="nav-h" style="margin-top: 16px;">账号</div> <div class="nav-h" style="margin-top: 16px;">账号</div>
<a href="#sec-danger" data-jump="sec-danger" style="color: var(--accent-crimson);"> <a href="#sec-danger" data-jump="sec-danger" role="tab" aria-controls="sec-danger" style="color: var(--accent-crimson);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" 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> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
危险操作 <span>危险操作</span>
</a> </a>
</aside> </aside>
@ -135,10 +165,10 @@
<div class="lbl">头像</div> <div class="lbl">头像</div>
<div class="val"> <div class="val">
<div class="avatar-edit"> <div class="avatar-edit">
<div class="av-big"></div> <div class="av-big" id="prof-avatar-preview"></div>
<div class="av-actions"> <div class="av-actions">
<button class="btn btn-sm" onclick="Shell.toast('上传头像', '占位 · 选择本地图片')">上传新头像</button> <button class="btn btn-sm" type="button" onclick="Shell.openModal('avatar-up-bg')">上传新头像</button>
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('恢复默认头像', '已重置')">恢复默认</button> <button class="btn btn-ghost btn-sm" type="button" id="prof-avatar-reset">恢复默认</button>
</div> </div>
</div> </div>
</div> </div>
@ -416,6 +446,51 @@
</main> </main>
</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> </div>
<script src="assets/shell.js?v=202605211643"></script> <script src="assets/shell.js?v=202605211643"></script>
<script> <script>
@ -424,50 +499,60 @@ Shell.render({
crumbs: [{ label: '工作台', href: 'index.html' }, { label: '设置' }] crumbs: [{ label: '工作台', href: 'index.html' }, { label: '设置' }]
}); });
/* ─── 侧边 nav 高亮 + 滚动联动 ─── */ /* ─── 配置 ─── */
const sections = ['sec-profile', 'sec-security', 'sec-notify', 'sec-pref', 'sec-display', 'sec-danger']; const SECTIONS = ['sec-profile', 'sec-security', 'sec-notify', 'sec-pref', 'sec-display', 'sec-danger'];
const navLinks = document.querySelectorAll('.settings-nav a[data-jump]'); const navLinks = document.querySelectorAll('.settings-nav a[data-jump]');
/* ─── 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 => { navLinks.forEach(a => {
a.addEventListener('click', e => { a.addEventListener('click', e => {
e.preventDefault(); e.preventDefault();
const id = a.dataset.jump; showSection(a.dataset.jump);
const el = document.getElementById(id); a.focus();
if (el) { });
const contentEl = document.getElementById('page-content') || document.querySelector('.content'); a.addEventListener('keydown', e => {
const offset = 16; const idx = [...navLinks].indexOf(a);
if (contentEl) { if (e.key === 'ArrowDown') { e.preventDefault(); navLinks[(idx + 1) % navLinks.length].focus(); }
contentEl.scrollTo({ top: el.offsetTop - contentEl.offsetTop - offset, behavior: 'smooth' }); else if (e.key === 'ArrowUp') { e.preventDefault(); navLinks[(idx - 1 + navLinks.length) % navLinks.length].focus(); }
} else { else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); a.click(); }
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
}); });
}); });
const contentEl = document.getElementById('page-content') || document.querySelector('.content'); /* ─── 2. 初始加载:读 URL hash 显示对应 section(无 hash 时默认 sec-profile) ─── */
const observer = new IntersectionObserver(entries => { (function initSection() {
entries.forEach(en => { const hash = location.hash.slice(1);
if (en.isIntersecting) { showSection(SECTIONS.includes(hash) ? hash : 'sec-profile');
const id = en.target.id; })();
navLinks.forEach(a => a.classList.toggle('active', a.dataset.jump === id));
} /* hash 外部变化(如浏览器前进后退)也跟着切 */
}); window.addEventListener('hashchange', () => {
}, { root: contentEl || null, rootMargin: '-20% 0px -60% 0px', threshold: 0 }); const hash = location.hash.slice(1);
sections.forEach(id => { if (SECTIONS.includes(hash)) showSection(hash);
const el = document.getElementById(id);
if (el) observer.observe(el);
}); });
/* ─── 偏好 chip 选择 ─── */ /* ─── 4. 偏好 chip 选择 ─── */
function bindChoice(containerId, label) { function bindChoice(containerId) {
const ct = document.getElementById(containerId); const ct = document.getElementById(containerId);
if (!ct) return; if (!ct) return;
ct.querySelectorAll('.pref-choice').forEach(c => { ct.querySelectorAll('.pref-choice').forEach(c => {
c.addEventListener('click', () => { c.addEventListener('click', () => {
ct.querySelectorAll('.pref-choice').forEach(x => x.classList.remove('selected')); ct.querySelectorAll('.pref-choice').forEach(x => x.classList.remove('selected'));
c.classList.add('selected'); c.classList.add('selected');
markDirty(); markDirty(c);
}); });
}); });
} }
@ -478,46 +563,232 @@ document.querySelectorAll('#pref-duration .dur-chip').forEach(c => {
c.addEventListener('click', () => { c.addEventListener('click', () => {
document.querySelectorAll('#pref-duration .dur-chip').forEach(x => x.classList.remove('selected')); document.querySelectorAll('#pref-duration .dur-chip').forEach(x => x.classList.remove('selected'));
c.classList.add('selected'); c.classList.add('selected');
markDirty(); markDirty(c);
}); });
}); });
/* ─── dirty state ─── */ /* ─── 5. dirty state · 追踪改了哪几节 + save btn 显示变更条数 ─── */
let dirty = false; const dirtyFields = new Set(); // 改过的字段 id 集合
function markDirty() { const dirtySections = new Set(); // 涉及的 section id 集合
if (dirty) return; const saveBtn = document.getElementById('save-btn');
dirty = true; const cancelBtn = document.getElementById('save-cancel');
const saveBtn = document.getElementById('save-btn'); const saveBtnDefaultLabel = saveBtn.innerHTML;
const cancelBtn = document.getElementById('save-cancel');
[saveBtn, cancelBtn].forEach(b => { function sectionOf(el) {
b.disabled = false; const sec = el.closest('section[id]');
b.style.opacity = ''; return sec ? sec.id : null;
b.style.cursor = ''; }
});
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() { function clearDirty() {
dirty = false; dirtyFields.clear();
const saveBtn = document.getElementById('save-btn'); dirtySections.clear();
const cancelBtn = document.getElementById('save-cancel'); syncSaveBtn();
[saveBtn, cancelBtn].forEach(b => {
b.disabled = true;
b.style.opacity = '.5';
b.style.cursor = 'not-allowed';
});
} }
document.querySelectorAll('[data-track], input[type="checkbox"], select').forEach(el => { document.querySelectorAll('[data-track], input[type="checkbox"], select').forEach(el => {
el.addEventListener('change', markDirty); el.addEventListener('change', () => markDirty(el));
if (el.tagName === 'INPUT' && el.type !== 'checkbox') el.addEventListener('input', markDirty); if (el.tagName === 'INPUT' && el.type !== 'checkbox') el.addEventListener('input', () => markDirty(el));
}); });
document.getElementById('save-btn').addEventListener('click', () => { /* ─── 6. 表单校验:必填 + 邮箱格式 ─── */
if (!dirty) return; const VALIDATORS = {
Shell.toast('设置已保存', '所有变更已生效'); '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(); clearDirty();
}); });
document.getElementById('save-cancel').addEventListener('click', () => { cancelBtn.addEventListener('click', () => {
if (!dirty) return; if (dirtyFields.size === 0) return;
if (confirm('放弃未保存的变更?')) location.reload(); if (confirm(`放弃 ${dirtyFields.size} 项未保存的变更?`)) location.reload();
});
/* ─── 8. 离开页面前提醒(有未保存变更时)─── */
window.addEventListener('beforeunload', e => {
if (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>
</body> </body>

View File

@ -110,6 +110,125 @@
.members-table tr.pending td { opacity: .65; } .members-table tr.pending td { opacity: .65; }
.members-table tr.pending .nm::after { content: '· 待激活'; font-size: 11px; color: var(--black-alpha-48); margin-left: 6px; font-weight: 400; font-family: var(--font-mono); } .members-table tr.pending .nm::after { content: '· 待激活'; font-size: 11px; color: var(--black-alpha-48); margin-left: 6px; font-weight: 400; font-family: var(--font-mono); }
/* ─── 成员详情 modal ─── */
.member-detail-modal { width: min(760px, 92vw); max-width: min(760px, 92vw); }
/* 给 modal-h 留出右侧 X 的位置 */
.member-detail-modal .modal-h { padding-right: 56px; }
/* 模态右上角关闭 X */
.md-x { position: absolute; top: 14px; right: 16px; width: 28px; height: 28px; border-radius: var(--r-sm); border: 1px solid var(--border-faint); background: var(--surface); color: var(--black-alpha-56); display: grid; place-items: center; cursor: pointer; transition: all var(--t-base); z-index: 2; }
.md-x:hover { color: var(--accent-black); border-color: var(--black-alpha-32); }
.md-x svg { width: 13px; height: 13px; }
.md-header { display: flex; align-items: center; gap: 14px; padding: 4px 0 16px; border-bottom: 1px solid var(--border-faint); margin-bottom: 18px; }
.md-header .av-big { width: 52px; height: 52px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 20px; font-weight: 600; color: var(--accent-black); flex-shrink: 0; }
.md-header .who-main { min-width: 0; flex: 1; }
.md-header .nm-big { font-size: 16px; font-weight: 600; color: var(--accent-black); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.md-header .em-big { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 4px; letter-spacing: .02em; }
.md-header .role-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 11px; font-weight: 500; }
.md-header .role-pill .dot { width: 6px; height: 6px; border-radius: 50%; }
.md-header .role-pill.role-super { background: var(--heat-12); color: var(--heat); }
.md-header .role-pill.role-super .dot { background: var(--heat); }
.md-header .role-pill.role-admin { background: rgba(30,64,175,.1); color: #1E40AF; }
.md-header .role-pill.role-admin .dot { background: #1E40AF; }
.md-header .role-pill.role-member { background: var(--background-lighter); color: var(--black-alpha-56); }
.md-header .role-pill.role-member .dot { background: var(--black-alpha-56); }
.md-header .tag-pending { font-family: var(--font-mono); font-size: 10.5px; padding: 1px 6px; background: rgba(180,83,9,.12); color: #B45309; border-radius: var(--r-sm); letter-spacing: .04em; }
/* 创建者:mono 小 tag,不再用 "·" 字符串拼接 */
.md-header .tag-creator { font-family: var(--font-mono); font-size: 10.5px; padding: 1px 6px; background: var(--background-lighter); color: var(--black-alpha-56); border: 1px solid var(--border-faint); border-radius: var(--r-sm); letter-spacing: .04em; font-weight: 500; }
.md-section { margin-bottom: 18px; }
.md-section:last-child { margin-bottom: 0; }
.md-section-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 10px; }
.md-section-h .hint { margin-left: 6px; font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-32); font-weight: 400; letter-spacing: .02em; text-transform: none; }
/* 角色权限速览 */
.md-perm-list { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 14px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px 14px; }
.md-perm-list .pi { display: flex; align-items: baseline; gap: 8px; font-size: 12.5px; color: var(--accent-black); line-height: 1.4; }
.md-perm-list .pi .ck { flex: 0 0 12px; color: var(--accent-forest); font-family: var(--font-mono); font-size: 11px; font-weight: 600; }
.md-perm-list .pi.deny .ck { color: var(--black-alpha-32); }
.md-perm-list .pi.deny { color: var(--black-alpha-48); }
.md-perm-list .pi .lb { flex: 1; min-width: 0; }
.md-perm-list .pi .lb em { font-style: normal; color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 11px; margin-left: 4px; }
/* 额度日消耗子行 */
.md-daily-row { display: flex; gap: 16px; align-items: center; margin-top: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-56); letter-spacing: .02em; }
.md-daily-row .k { color: var(--black-alpha-48); }
.md-daily-row .v { color: var(--accent-black); font-weight: 600; font-variant-numeric: tabular-nums; }
.md-daily-row .v.warn { color: #B45309; }
.md-daily-row .sep { color: var(--black-alpha-32); }
.md-quota-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.md-quota-box { background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px 14px; }
.md-quota-box .lbl { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; }
.md-quota-box .v { font-size: 17px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--accent-black); margin-top: 4px; }
.md-quota-box .v.warn { color: #B45309; }
.md-progress { margin-top: 10px; }
.md-progress .label { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--black-alpha-56); font-family: var(--font-mono); margin-bottom: 4px; }
.md-progress .bar { height: 6px; background: var(--background-lighter); border-radius: 3px; overflow: hidden; border: 1px solid var(--border-faint); }
.md-progress .bar > span { display: block; height: 100%; background: var(--heat); }
.md-progress .bar > span.ok { background: var(--accent-forest); }
.md-progress .bar > span.warn { background: #B45309; }
.md-stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.md-stat { background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 10px 12px; text-align: center; }
.md-stat .v { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--accent-black); }
.md-stat .lbl { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }
.md-activity-list { display: flex; flex-direction: column; gap: 8px; max-height: 180px; overflow-y: auto; padding-right: 4px; }
.md-activity-list::-webkit-scrollbar { width: 4px; }
.md-activity-list::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 2px; }
.md-activity-item { display: grid; grid-template-columns: 70px 1fr; gap: 10px; align-items: baseline; font-size: 12.5px; line-height: 1.4; }
.md-activity-item .ts { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
.md-activity-item .act { color: var(--black-alpha-56); }
.md-activity-item .obj { color: var(--heat); }
/* ─── 消费明细 section ─── */
.md-section-h .right { margin-left: auto; font-family: var(--font-mono); font-size: 11.5px; color: var(--accent-black); font-weight: 600; letter-spacing: 0; text-transform: none; font-variant-numeric: tabular-nums; }
.md-section-h { display: flex; align-items: baseline; }
.md-stage-list { display: flex; flex-direction: column; gap: 10px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px 16px; }
.md-stage-row { display: grid; grid-template-columns: 12px 1fr; column-gap: 10px; row-gap: 5px; align-items: center; }
.md-stage-row .swatch { width: 8px; height: 8px; border-radius: 2px; align-self: center; }
.md-stage-row .swatch.s-video { background: var(--heat); }
.md-stage-row .swatch.s-storyboard { background: var(--accent-forest); }
.md-stage-row .swatch.s-asset { background: var(--black-alpha-56); }
.md-stage-row .swatch.s-script { background: var(--black-alpha-32); }
.md-stage-row .line { display: flex; align-items: baseline; font-size: 12.5px; min-width: 0; }
.md-stage-row .nm { color: var(--accent-black); }
.md-stage-row .pct { margin-left: 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); font-variant-numeric: tabular-nums; }
.md-stage-row .amt { margin-left: auto; font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-weight: 600; color: var(--accent-black); font-size: 12.5px; }
.md-stage-row .bar { grid-column: 2; height: 4px; background: var(--surface); border-radius: 2px; overflow: hidden; }
.md-stage-row .bar > span { display: block; height: 100%; transition: width .3s ease; }
.md-stage-row .bar > span.s-video { background: var(--heat); }
.md-stage-row .bar > span.s-storyboard { background: var(--accent-forest); }
.md-stage-row .bar > span.s-asset { background: var(--black-alpha-56); }
.md-stage-row .bar > span.s-script { background: var(--black-alpha-32); }
/* 流水明细切换:按钮化 */
.md-tx-bar { display: flex; align-items: center; margin-top: 14px; padding: 8px 10px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); }
.md-tx-bar .ct { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-56); letter-spacing: .02em; }
.md-tx-bar .ct b { color: var(--accent-black); font-weight: 600; }
.md-tx-toggle { display: inline-flex; align-items: center; gap: 5px; font-family: var(--font-mono); font-size: 11px; color: var(--heat); cursor: pointer; margin-left: auto; user-select: none; padding: 4px 10px; border-radius: var(--r-sm); transition: background var(--t-base); }
.md-tx-toggle:hover { background: var(--heat-12); }
.md-tx-toggle svg { width: 11px; height: 11px; transition: transform var(--t-base); }
.md-tx-toggle.expanded svg { transform: rotate(180deg); }
.md-tx-table { display: none; flex-direction: column; max-height: 220px; overflow-y: auto; margin-top: 10px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); }
.md-tx-table.show { display: flex; }
.md-tx-table::-webkit-scrollbar { width: 4px; }
.md-tx-table::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 2px; }
.md-tx-row { display: grid; grid-template-columns: 96px 1fr 110px 78px; gap: 10px; padding: 9px 12px; font-size: 12px; border-bottom: 1px solid var(--border-faint); align-items: center; }
.md-tx-row:last-child { border-bottom: 0; }
.md-tx-row.head { background: var(--black-alpha-3); font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; padding-top: 8px; padding-bottom: 8px; }
.md-tx-row .ts { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); }
.md-tx-row .proj { color: var(--accent-black); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.md-tx-row .type { color: var(--black-alpha-56); font-size: 11.5px; }
.md-tx-row .amt { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-weight: 600; color: var(--accent-black); }
.md-tx-row .amt.pos { color: var(--accent-forest); }
.md-tx-row .amt.zero { color: var(--black-alpha-32); font-weight: 500; }
.md-meta { font-size: 12px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; padding-top: 14px; margin-top: 14px; border-top: 1px solid var(--border-faint); }
/* ─── 角色权限矩阵 ─── */ /* ─── 角色权限矩阵 ─── */
.perm-table { width: 100%; border-collapse: collapse; font-size: 12.5px; } .perm-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.perm-table th, .perm-table td { padding: 8px 4px; border-bottom: 1px solid var(--border-faint); } .perm-table th, .perm-table td { padding: 8px 4px; border-bottom: 1px solid var(--border-faint); }
@ -320,26 +439,6 @@
</table> </table>
</div> </div>
<div class="pane">
<h3>额度预检规则</h3>
<div style="font-size: 12px; color: var(--black-alpha-48); margin-top: -10px; margin-bottom: 12px; font-family: var(--font-mono); letter-spacing: .02em;">// 任一不通过即拦截</div>
<div class="quota-rules">
<div class="step"><span class="num">1</span><span><span class="v">个人日剩余</span> ≥ 任务预估 × <span class="formula">1.2</span></span></div>
<div class="step"><span class="num">2</span><span><span class="v">个人月剩余</span> ≥ 同上</span></div>
<div class="step"><span class="num">3</span><span><span class="v">团队月剩余</span> ≥ 同上</span></div>
<div class="step"><span class="num">4</span><span><span class="v">团队总余额</span> ≥ 同上</span></div>
</div>
</div>
<div class="pane" style="background: var(--heat-12); border-color: var(--heat-20);">
<h3 style="color: var(--heat);">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>
失败不扣费
</h3>
<div style="font-size: 12.5px; color: var(--black-alpha-56); line-height: 1.7;">
所有生成任务<strong style="color: var(--accent-black);">仅在用户 <span class="mono" style="background: var(--surface); padding: 1px 5px; font-family: var(--font-mono); color: var(--heat); font-size: 11.5px;">[ 通过 ]</span> 时才扣费</strong>。失败 / 超时 / 重跑(旧版本作废)一律不扣。
</div>
</div>
</div> </div>
</div> </div>
@ -351,12 +450,12 @@
<div class="ic-m"> <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="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 8v6M22 11h-6"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><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 8v6M22 11h-6"/></svg>
</div> </div>
<div class="ti">邀请成员<span>// 短信邀请 · 接受后自动加入团队</span></div> <div class="ti">邀请成员<span>// 邮件邀请 · 接受后自动加入团队</span></div>
</div> </div>
<div class="modal-b"> <div class="modal-b">
<div class="field"> <div class="field">
<label class="field-label">手机号 <span class="req">*</span></label> <label class="field-label">邮箱 <span class="req">*</span></label>
<input class="input" id="inv-phone" placeholder="例: 138 0013 8000"> <input class="input" id="inv-email" type="email" autocomplete="off" placeholder="例: name@company.com">
</div> </div>
<div class="field"> <div class="field">
<label class="field-label">备注姓名(可选)</label> <label class="field-label">备注姓名(可选)</label>
@ -433,6 +532,107 @@
</div> </div>
</div> </div>
<!-- 成员详情 modal · 点击成员行打开 -->
<div class="modal-bg" id="member-detail-bg" onclick="if(event.target===this)Shell.closeModal('member-detail-bg')">
<div class="modal member-detail-modal">
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
<button class="md-x" type="button" aria-label="关闭" onclick="Shell.closeModal('member-detail-bg')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
<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 id="md-sub">// 角色权限 / 额度 / 消费明细 / 团队贡献 / 最近活动</span></div>
</div>
<div class="modal-b">
<div class="md-header">
<div class="av-big" id="md-av"></div>
<div class="who-main">
<div class="nm-big">
<span id="md-name"></span>
<span class="role-pill" id="md-role"><span class="dot"></span><span id="md-role-label"></span></span>
<span class="tag-creator" id="md-creator" style="display:none">创建者</span>
<span class="tag-pending" id="md-pending" style="display:none">待激活</span>
</div>
<div class="em-big" id="md-email"></div>
</div>
</div>
<div class="md-section">
<div class="md-section-h">// 角色权限 · <span id="md-perm-role"></span> 可以<span class="hint">PRD §10.2 权限矩阵</span></div>
<div class="md-perm-list" id="md-perm-list">
<!-- JS 注入 4 条 -->
</div>
</div>
<div class="md-section">
<div class="md-section-h">// 额度使用<span class="hint">PRD §7.2 四层额度</span></div>
<div class="md-quota-grid">
<div class="md-quota-box"><div class="lbl">每日额度</div><div class="v" id="md-daily"></div></div>
<div class="md-quota-box"><div class="lbl">月度额度</div><div class="v" id="md-monthly"></div></div>
<div class="md-quota-box"><div class="lbl">当月已用</div><div class="v" id="md-used"></div></div>
</div>
<div class="md-daily-row">
<span><span class="k">今日已用</span> <span class="v" id="md-used-today">¥0.00</span></span>
<span class="sep">·</span>
<span><span class="k">今日剩余</span> <span class="v" id="md-left-today">¥0.00</span></span>
</div>
<div class="md-progress">
<div class="label"><span>当月消耗</span><span id="md-pct">0%</span></div>
<div class="bar"><span id="md-bar" style="width: 0%"></span></div>
</div>
</div>
<div class="md-section" id="md-cost-section">
<div class="md-section-h">// 消费明细 · 当月按阶段拆分<span class="hint">仅超管 / 团管可见</span><span class="right" id="md-cost-total">¥0.00</span></div>
<div class="md-stage-list" id="md-stage-list">
<!-- JS 注入 -->
</div>
<div class="md-tx-bar">
<span class="ct">// 流水明细 · 共 <b id="md-tx-count">0</b></span>
<span class="md-tx-toggle" id="md-tx-toggle" role="button" tabindex="0">
<span class="md-tx-label">展开</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg>
</span>
</div>
<div class="md-tx-table" id="md-tx-table">
<div class="md-tx-row head">
<span>时间</span>
<span>项目</span>
<span>类型</span>
<span style="text-align:right">金额</span>
</div>
<!-- JS 注入 rows -->
</div>
</div>
<div class="md-section">
<div class="md-section-h">// 团队贡献</div>
<div class="md-stats-row">
<div class="md-stat"><div class="v" id="md-projects-active">0</div><div class="lbl">进行中项目</div></div>
<div class="md-stat"><div class="v" id="md-projects-done">0</div><div class="lbl">已完成项目</div></div>
<div class="md-stat"><div class="v" id="md-assets">0</div><div class="lbl">上传共享素材</div></div>
</div>
</div>
<div class="md-section">
<div class="md-section-h">// 最近活动<span class="hint" id="md-last-active">· 最近活跃 —</span></div>
<div class="md-activity-list" id="md-activity">
<!-- JS 注入 -->
</div>
</div>
<div class="md-meta" id="md-meta">// —</div>
</div>
<div class="modal-f">
<button class="btn" type="button" style="margin-right: auto; color: var(--accent-crimson); border-color: var(--accent-crimson);" id="md-remove" data-creator-hide>移出团队</button>
<button class="btn" type="button" onclick="Shell.closeModal('member-detail-bg')">关闭</button>
<button class="btn btn-primary" type="button" id="md-edit" data-creator-hide>编辑成员</button>
</div>
</div>
</div>
</div> </div>
<script src="assets/shell.js?v=202605211643"></script> <script src="assets/shell.js?v=202605211643"></script>
<script> <script>
@ -448,12 +648,94 @@ const ROLE_META = {
member: { cls: 'role-member', label: '成员' }, member: { cls: 'role-member', label: '成员' },
}; };
/* PRD §10.2 权限矩阵 · 4 条精简到弹窗速览(grant + deny 各 2 条) */
const ROLE_PERMS = {
super: [
{ ok: true, txt: '团队设置 · 月限额 / 名称' },
{ ok: true, txt: '充值 · 划拨团队总额度' },
{ ok: true, txt: '任命团管 · 调整任意成员额度' },
{ ok: true, txt: '查看团队全部消费明细 + 财务' },
],
admin: [
{ ok: true, txt: '邀请 / 移除成员 + 设置额度' },
{ ok: true, txt: '管理团队共享资产库' },
{ ok: true, txt: '查看团队全部消费明细' },
{ ok: false, txt: '团队设置 / 充值 · 仅超管' },
],
member: [
{ ok: true, txt: '创建项目 + 流水线操作' },
{ ok: true, txt: '上传 / 引用团队共享资产' },
{ ok: false, txt: '邀请 / 移除成员 · 仅超管 / 团管' },
{ ok: false, txt: '消费明细 · 仅本人可见' },
],
};
const MEMBERS = [ const MEMBERS = [
{ id: 'u1', av: '李', name: '小李', email: 'li@shop.com', role: 'super', daily: 500, monthly: 10000, used: 162.60, pending: false, creator: true }, { id: 'u1', av: '李', name: '小李', email: 'li@shop.com', role: 'super', daily: 500, monthly: 10000, used: 162.60, usedToday: 0.45, lastActive: '15 分钟前', pending: false, creator: true,
{ id: 'u2', av: '张', name: '张运营', email: 'zhang@shop.com', role: 'admin', daily: 300, monthly: 6000, used: 98.40, pending: false }, joinDate: '2026-04-12', inviter: '—', projectsActive: 2, projectsDone: 14, assetsUploaded: 32,
{ id: 'u3', av: '王', name: '王小姐', email: 'wang@shop.com', role: 'member', daily: 100, monthly: 2000, used: 45.20, pending: false }, activity: [
{ id: 'u4', av: '陈', name: '陈策划', email: 'chen@shop.com', role: 'member', daily: 100, monthly: 2000, used: 12.80, pending: false }, { ts: '昨天 11:02', act: '团队充值', obj: '+¥500.00' },
{ id: 'u5', av: '林', name: '林新人', email: '186****1102', role: 'member', daily: 100, monthly: 2000, used: 0, pending: true }, { ts: '2 天前', act: '邀请新成员', obj: '林新人' },
{ ts: '3 天前', act: '创建项目', obj: '春日新品 · 立体口红' },
],
byStage: { video: 98.40, storyboard: 36.00, asset: 21.00, script: 7.20 },
transactions: [
{ ts: '05.21 14:32', proj: '补水面膜 · v3', type: '视频片段 · 1 镜', amount: -0.45 },
{ ts: '05.20 18:21', proj: '透真防晒 · 通勤对比', type: '视频片段 · 6 镜', amount: -1.20 },
{ ts: '05.20 11:02', proj: '充值', type: '微信支付', amount: 500.00 },
{ ts: '05.19 16:08', proj: '补水面膜 · v3', type: '故事板 image-2', amount: -0.45 },
{ ts: '05.19 14:02', proj: '补水面膜 · v3', type: '脚本 LLM · 2.4k', amount: -0.04 },
{ ts: '05.19 13:38', proj: '补水面膜 · v3', type: '基础资产 · 5 张', amount: -1.05 },
{ ts: '05.18 15:42', proj: '咖啡冻干 · 早八', type: '故事板生成失败', amount: 0 },
{ ts: '05.17 10:30', proj: '瑜伽裤 · 通勤穿搭', type: '项目导出', amount: -3.20 },
] },
{ id: 'u2', av: '张', name: '张运营', email: 'zhang@shop.com', role: 'admin', daily: 300, monthly: 6000, used: 98.40, usedToday: 0.45, lastActive: '10 分钟前', pending: false,
joinDate: '2026-04-18', inviter: '小李', projectsActive: 3, projectsDone: 8, assetsUploaded: 18,
activity: [
{ ts: '10 分钟前', act: '完成视频', obj: '补水面膜 · v3' },
{ ts: '昨天 18:32', act: '采用故事板', obj: '场 3 · v2' },
{ ts: '2 天前', act: '创建项目', obj: '蓝牙耳机 · 开箱测评' },
],
byStage: { video: 60.00, storyboard: 22.00, asset: 12.00, script: 4.40 },
transactions: [
{ ts: '05.21 14:08', proj: '补水面膜 · v3', type: '视频片段 · 1 镜', amount: -0.45 },
{ ts: '05.20 21:42', proj: '蓝牙耳机 · 开箱测评', type: '视频片段 · 6 镜', amount: -1.20 },
{ ts: '05.20 16:00', proj: '蓝牙耳机 · 开箱测评', type: '故事板 image-2', amount: -0.45 },
{ ts: '05.19 11:18', proj: '补水面膜 · v3', type: '故事板 image-2', amount: -0.45 },
{ ts: '05.18 09:42', proj: '蓝牙耳机 · 开箱测评', type: '基础资产 · 4 张', amount: -0.84 },
{ ts: '05.17 14:38', proj: '蓝牙耳机 · 开箱测评', type: '脚本 LLM · 1.8k', amount: -0.03 },
] },
{ id: 'u3', av: '王', name: '王小姐', email: 'wang@shop.com', role: 'member', daily: 100, monthly: 2000, used: 45.20, usedToday: 0, lastActive: '28 分钟前', pending: false,
joinDate: '2026-04-22', inviter: '小李', projectsActive: 1, projectsDone: 4, assetsUploaded: 12,
activity: [
{ ts: '28 分钟前', act: '上传到资产库', obj: '林夕 · 主播图' },
{ ts: '2 天前', act: '删除资产', obj: '透真防晒 · 旧版主图' },
{ ts: '5 天前', act: '完成视频', obj: '透真防晒 · 通勤对比' },
],
byStage: { video: 28.00, storyboard: 10.00, asset: 5.00, script: 2.20 },
transactions: [
{ ts: '05.16 19:38', proj: '透真防晒 · 通勤对比', type: '视频片段 · 4 镜', amount: -0.80 },
{ ts: '05.16 11:42', proj: '透真防晒 · 通勤对比', type: '故事板 image-2', amount: -0.45 },
{ ts: '05.15 16:08', proj: '透真防晒 · 通勤对比', type: '基础资产 · 3 张', amount: -0.63 },
{ ts: '05.15 09:30', proj: '透真防晒 · 通勤对比', type: '脚本 LLM · 1.5k', amount: -0.02 },
] },
{ id: 'u4', av: '陈', name: '陈策划', email: 'chen@shop.com', role: 'member', daily: 100, monthly: 2000, used: 12.80, usedToday: 0, lastActive: '4 小时前', pending: false,
joinDate: '2026-05-02', inviter: '张运营', projectsActive: 1, projectsDone: 1, assetsUploaded: 5,
activity: [
{ ts: '4 小时前', act: '创建项目', obj: '蓝牙耳机 · 开箱测评' },
{ ts: '昨天', act: '完成视频', obj: '速食面 · 加班场景' },
],
byStage: { video: 8.00, storyboard: 3.00, asset: 1.20, script: 0.60 },
transactions: [
{ ts: '05.20 14:32', proj: '速食面 · 加班场景', type: '视频片段 · 3 镜', amount: -0.60 },
{ ts: '05.19 18:08', proj: '速食面 · 加班场景', type: '故事板 image-2', amount: -0.30 },
{ ts: '05.19 11:12', proj: '速食面 · 加班场景', type: '基础资产 · 2 张', amount: -0.42 },
] },
{ id: 'u5', av: '林', name: '林新人', email: '186****1102', role: 'member', daily: 100, monthly: 2000, used: 0, usedToday: 0, lastActive: '尚未激活', pending: true,
joinDate: '2026-05-19', inviter: '小李', projectsActive: 0, projectsDone: 0, assetsUploaded: 0,
activity: [],
byStage: { video: 0, storyboard: 0, asset: 0, script: 0 },
transactions: [] },
]; ];
function fmtMoney(n) { return '¥' + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } function fmtMoney(n) { return '¥' + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
@ -522,6 +804,11 @@ function renderMembers(filter = '') {
} }
}); });
}); });
// 行点击 → 成员详情 modal(按钮区已 stopPropagation,不会冲突)
tb.querySelectorAll('tr[data-id]').forEach(row => {
row.style.cursor = 'pointer';
row.addEventListener('click', () => openMemberDetail(row.dataset.id));
});
} }
renderMembers(); renderMembers();
@ -540,8 +827,11 @@ document.querySelectorAll('#invite-bg .role-choice').forEach(c => {
}); });
}); });
document.getElementById('inv-send').addEventListener('click', () => { document.getElementById('inv-send').addEventListener('click', () => {
const phone = document.getElementById('inv-phone').value.trim(); const email = document.getElementById('inv-email').value.trim();
if (!phone) { Shell.toast('请填手机号', '邀请失败'); return; } if (!email) { Shell.toast('请填邮箱', '邀请失败'); return; }
// 简单邮箱格式校验:必须含 @ 和域名后缀
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
if (!emailOk) { Shell.toast('邮箱格式不正确', '请检查后重试'); return; }
const role = document.querySelector('#invite-bg .role-choice.selected')?.dataset.role || 'member'; const role = document.querySelector('#invite-bg .role-choice.selected')?.dataset.role || 'member';
const name = document.getElementById('inv-name').value.trim() || '待激活成员'; const name = document.getElementById('inv-name').value.trim() || '待激活成员';
const daily = Number(document.getElementById('inv-daily').value) || 100; const daily = Number(document.getElementById('inv-daily').value) || 100;
@ -549,13 +839,13 @@ document.getElementById('inv-send').addEventListener('click', () => {
MEMBERS.push({ MEMBERS.push({
id: 'u' + Date.now(), id: 'u' + Date.now(),
av: name[0] || '新', av: name[0] || '新',
name, email: phone, role, name, email, role,
daily, monthly, used: 0, pending: true, daily, monthly, used: 0, pending: true,
}); });
renderMembers(); renderMembers();
Shell.closeModal('invite-bg'); Shell.closeModal('invite-bg');
Shell.toast('邀请已发送', phone); Shell.toast('邀请邮件已发送', email);
document.getElementById('inv-phone').value = ''; document.getElementById('inv-email').value = '';
document.getElementById('inv-name').value = ''; document.getElementById('inv-name').value = '';
}); });
@ -600,6 +890,198 @@ document.getElementById('edit-remove').addEventListener('click', () => {
renderMembers(document.getElementById('member-search').value); renderMembers(document.getElementById('member-search').value);
} }
}); });
/* ─── 成员详情 modal · 点击行打开 ─── */
let detailId = null;
function openMemberDetail(id) {
const m = MEMBERS.find(x => x.id === id);
if (!m) return;
detailId = id;
const r = ROLE_META[m.role];
const pct = m.monthly > 0 ? m.used / m.monthly : 0;
document.getElementById('md-av').textContent = m.av;
document.getElementById('md-name').textContent = m.name;
document.getElementById('md-email').textContent = m.email;
const rolePill = document.getElementById('md-role');
rolePill.className = 'role-pill ' + r.cls;
document.getElementById('md-role-label').textContent = r.label;
document.getElementById('md-creator').style.display = m.creator ? '' : 'none';
document.getElementById('md-pending').style.display = m.pending ? '' : 'none';
// 角色权限速览(PRD §10.2 权限矩阵)
document.getElementById('md-perm-role').textContent = r.label;
const permList = document.getElementById('md-perm-list');
const perms = ROLE_PERMS[m.role] || [];
permList.innerHTML = perms.map(p => `
<div class="pi${p.ok ? '' : ' deny'}">
<span class="ck">${p.ok ? '✓' : '—'}</span>
<span class="lb">${p.txt}</span>
</div>
`).join('');
document.getElementById('md-daily').textContent = fmtMoney(m.daily);
document.getElementById('md-monthly').textContent = fmtMoney(m.monthly);
const usedEl = document.getElementById('md-used');
usedEl.textContent = fmtMoney(m.used);
usedEl.classList.toggle('warn', pct >= 0.85);
document.getElementById('md-pct').textContent = (pct * 100).toFixed(1) + '%';
const bar = document.getElementById('md-bar');
bar.style.width = Math.min(100, pct * 100).toFixed(1) + '%';
bar.className = usedClass(pct);
// 今日已用 / 剩余
const usedToday = m.usedToday || 0;
const leftToday = Math.max(0, m.daily - usedToday);
document.getElementById('md-used-today').textContent = fmtMoney(usedToday);
const leftTodayEl = document.getElementById('md-left-today');
leftTodayEl.textContent = fmtMoney(leftToday);
leftTodayEl.classList.toggle('warn', m.daily > 0 && leftToday / m.daily < 0.2);
// 最近活跃
document.getElementById('md-last-active').textContent = '· 最近活跃 ' + (m.lastActive || '—');
document.getElementById('md-projects-active').textContent = m.projectsActive ?? 0;
document.getElementById('md-projects-done').textContent = m.projectsDone ?? 0;
document.getElementById('md-assets').textContent = m.assetsUploaded ?? 0;
// ─── 消费明细:按阶段柱状条 ───
const stageList = document.getElementById('md-stage-list');
const bs = m.byStage || { video: 0, storyboard: 0, asset: 0, script: 0 };
const totalCost = bs.video + bs.storyboard + bs.asset + bs.script;
const STAGES = [
{ key: 'video', label: '视频片段(Seedance)', cls: 's-video' },
{ key: 'storyboard', label: '故事板(image-2)', cls: 's-storyboard' },
{ key: 'asset', label: '基础资产', cls: 's-asset' },
{ key: 'script', label: '脚本 LLM', cls: 's-script' },
];
if (totalCost === 0) {
stageList.innerHTML = '<div style="font-size:12px;color:var(--black-alpha-48);font-family:var(--font-mono);text-align:center;padding:8px 0;">// 本月暂无消费</div>';
} else {
stageList.innerHTML = STAGES.map(s => {
const v = bs[s.key] || 0;
const pct = totalCost > 0 ? (v / totalCost * 100) : 0;
return `
<div class="md-stage-row">
<span class="swatch ${s.cls}"></span>
<div class="line">
<span class="nm">${s.label}</span>
<span class="pct">${pct.toFixed(0)}%</span>
<span class="amt">${fmtMoney(v)}</span>
</div>
<div class="bar"><span class="${s.cls}" style="width: ${pct.toFixed(1)}%"></span></div>
</div>
`;
}).join('');
}
document.getElementById('md-cost-total').textContent = fmtMoney(totalCost);
// ─── 流水明细表 ───
const txTable = document.getElementById('md-tx-table');
const txToggle = document.getElementById('md-tx-toggle');
const txs = m.transactions || [];
document.getElementById('md-tx-count').textContent = txs.length;
// 保留 head 行,清除旧的明细行后重新注入
txTable.querySelectorAll('.md-tx-row:not(.head)').forEach(el => el.remove());
if (txs.length === 0) {
const empty = document.createElement('div');
empty.className = 'md-tx-row';
empty.style.gridTemplateColumns = '1fr';
empty.style.color = 'var(--black-alpha-48)';
empty.style.fontFamily = 'var(--font-mono)';
empty.style.textAlign = 'center';
empty.style.padding = '14px';
empty.textContent = '// 暂无消费记录';
txTable.appendChild(empty);
} else {
txs.forEach(t => {
const row = document.createElement('div');
row.className = 'md-tx-row';
const amtCls = t.amount > 0 ? 'pos' : (t.amount === 0 ? 'zero' : '');
const amtStr = t.amount === 0 ? '¥0.00' : (t.amount > 0 ? '+' + fmtMoney(t.amount) : '-' + fmtMoney(Math.abs(t.amount)));
row.innerHTML = `
<span class="ts">${t.ts}</span>
<span class="proj" title="${t.proj}">${t.proj}</span>
<span class="type">${t.type}</span>
<span class="amt ${amtCls}">${amtStr}</span>
`;
txTable.appendChild(row);
});
}
// 重置展开状态
txTable.classList.remove('show');
txToggle.classList.remove('expanded');
const txLabel = txToggle.querySelector('.md-tx-label');
txLabel.textContent = '展开';
txToggle.onclick = () => {
const opening = !txTable.classList.contains('show');
txTable.classList.toggle('show', opening);
txToggle.classList.toggle('expanded', opening);
txLabel.textContent = opening ? '收起' : '展开';
};
txToggle.onkeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); txToggle.click(); }
};
const list = document.getElementById('md-activity');
if (!m.activity || m.activity.length === 0) {
list.innerHTML = '<div style="font-size:12px;color:var(--black-alpha-48);font-family:var(--font-mono);text-align:center;padding:16px 0;">// 暂无活动记录</div>';
} else {
list.innerHTML = m.activity.map(a =>
'<div class="md-activity-item"><span class="ts">' + a.ts + '</span><span><span class="act">' + a.act + '</span> <span class="obj">' + a.obj + '</span></span></div>'
).join('');
}
document.getElementById('md-meta').textContent =
'// 加入团队 ' + (m.joinDate || '—') +
(m.inviter && m.inviter !== '—' ? ' · 由 ' + m.inviter + ' 邀请' : ' · 团队创建者');
// footer 操作权限分支:
// - 创建者(超管):隐藏编辑/移除(协议保护,PRD 没有明确路径降级团队创建者)
// - 待激活:编辑成员 → 重发邀请,移出团队 → 取消邀请
// - 普通成员/团管:标准 编辑成员 + 移出团队
const editBtn = document.getElementById('md-edit');
const removeBtn = document.getElementById('md-remove');
if (m.creator) {
editBtn.style.display = 'none';
removeBtn.style.display = 'none';
} else if (m.pending) {
editBtn.style.display = '';
removeBtn.style.display = '';
editBtn.textContent = '重发邀请';
removeBtn.textContent = '取消邀请';
} else {
editBtn.style.display = '';
removeBtn.style.display = '';
editBtn.textContent = '编辑成员';
removeBtn.textContent = '移出团队';
}
Shell.openModal('member-detail-bg');
}
document.getElementById('md-edit').addEventListener('click', () => {
if (!detailId) return;
const m = MEMBERS.find(x => x.id === detailId);
if (!m) return;
if (m.pending) {
Shell.toast('邀请邮件已重发', m.email);
return;
}
Shell.closeModal('member-detail-bg');
openEdit(detailId);
});
document.getElementById('md-remove').addEventListener('click', () => {
const m = MEMBERS.find(x => x.id === detailId);
if (!m) return;
const verb = m.pending ? '取消「' + m.name + '」的邀请' : '将「' + m.name + '」移出团队';
if (confirm('确定' + verb + '?')) {
const i = MEMBERS.findIndex(x => x.id === m.id);
MEMBERS.splice(i, 1);
Shell.closeModal('member-detail-bg');
Shell.toast(m.pending ? '邀请已取消' : '已移除', m.name);
renderMembers(document.getElementById('member-search').value);
}
});
</script> </script>
</body> </body>
</html> </html>