AirShelf/_check.html
iye f420af2069
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6s
chore: 全量推送 · 累积页面改动 + Next.js 工程骨架 + v1/ 归档 + 文档
页面 (电商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>
2026-05-21 21:16:46 +08:00

984 lines
63 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>