feat(k8s): 新增 core 真应用(前端+Django API+Celery worker)构建与部署
- core/frontend: Vite 多阶段镜像 + nginx 同源反代 /api,/admin,/static(零 CORS) - core/backend: Django gunicorn 镜像 + entrypoint(自动 migrate/collectstatic)+ WhiteNoise - k8s/core: api/worker/web Deployment+Service + ingress(airshelf-web.airlabs.art) - workflow: 追加 core 前后端 build/push,从 core/backend/.env 套生产覆盖生成 env Secret 后部署 - .gitignore 放行 core/backend/.env;.env 白名单加入 airshelf-web 域名 - 含前端 WIP 还原改动 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,22 @@
|
||||
import { useState } from "react";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import type { BillingSummary, Ledger, Project, TeamMember } from "../types";
|
||||
import { money, stageMeta, statusPill } from "./stage-config";
|
||||
import { money } from "./stage-config";
|
||||
|
||||
type Tab = "overview" | "by-project" | "by-member" | "bills";
|
||||
|
||||
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; ribbon?: string }> = [
|
||||
{ amt: 100, gift: "无赠送", bonus: false },
|
||||
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, ribbon: "推荐" },
|
||||
{ amt: 1000, gift: "+ ¥80 赠送", bonus: true },
|
||||
{ amt: 3000, gift: "+ ¥300 赠送", bonus: true }
|
||||
];
|
||||
|
||||
const STAGES: Array<{ k: string; color: string }> = [
|
||||
{ k: "视频片段(Seedance)", color: "var(--heat)" },
|
||||
{ k: "故事板(image-2)", color: "var(--accent-forest)" },
|
||||
{ k: "基础资产", color: "var(--black-alpha-56)" },
|
||||
{ k: "脚本 LLM", color: "var(--black-alpha-32)" }
|
||||
];
|
||||
|
||||
export function AccountPage({ billing, ledgers, projects, teamMembers }: {
|
||||
billing: BillingSummary | null;
|
||||
@@ -9,21 +24,195 @@ export function AccountPage({ billing, ledgers, projects, teamMembers }: {
|
||||
projects: Project[];
|
||||
teamMembers: TeamMember[];
|
||||
}) {
|
||||
const [tab, setTab] = useState<"overview" | "projects" | "members" | "bills">("overview");
|
||||
const [amount, setAmount] = useState(500);
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
const [recharge, setRecharge] = useState(500);
|
||||
|
||||
const balance = Number(billing?.account.balance || 0);
|
||||
const used = Number(billing?.charged_total || 0);
|
||||
const memberLimit = teamMembers.reduce((sum, m) => sum + Math.max(0, Number(m.monthly_credit_limit || 0)), 0);
|
||||
const limit = memberLimit || balance;
|
||||
const left = Math.max(0, limit - used);
|
||||
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>消费</h1><div className="sub"><span className="mono">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div></div></div>
|
||||
<div className="top-grid"><div className="balance-banner"><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="balance-hero"><div className="lbl">团队余额</div><div className="v">{money(billing?.account.balance)}</div><div className="meta">// 冻结 {money(billing?.account.reserved_balance)} · 已消费 {money(billing?.charged_total)}</div></div></div><div className="pane topup-pane"><div className="topup-head"><div><h3>快速充值</h3><div className="desc">// 充值后立刻到账,可开发票</div></div><div className="topup-selected">已选 ¥{amount}</div></div><div className="recharge-row">{[100, 500, 1000, 3000].map((item) => <button className={`recharge-card ${amount === item ? "selected" : ""}`} type="button" key={item} onClick={() => setAmount(item)}><div className="amt">¥{item}</div><div className="gift">+ 赠送</div></button>)}</div><button className="btn pay-method-btn" type="button"><CreditCard size={13} />微信支付</button></div></div>
|
||||
<div className="billing-tabs">{[["overview", "总览"], ["projects", "项目"], ["members", "成员"], ["bills", "账单流水"]].map(([key, label]) => <button className={`tab ${tab === key ? "active" : ""}`} type="button" key={key} onClick={() => setTab(key as typeof tab)}>{label}</button>)}</div>
|
||||
{tab === "overview" && <div className="overview-grid"><div className="pane trend-pane"><h3>消费趋势</h3><div className="bars">{Array.from({ length: 14 }).map((_, index) => <span key={index} style={{ height: `${18 + (index % 6) * 10}%` }} />)}</div></div><div className="pane stage-pane"><h3>本月按阶段分布</h3><UsageLine label="视频片段" value="¥98.40" width="60%" color="var(--orange)" /><UsageLine label="故事板" value="¥36.00" width="22%" color="var(--green)" /></div></div>}
|
||||
{tab === "projects" && <table className="billing-table"><thead><tr><th>项目</th><th>当前阶段</th><th>状态</th><th>消耗</th></tr></thead><tbody>{projects.map((project) => <tr key={project.id}><td>{project.name}</td><td>{stageMeta[project.current_stage]?.label || project.current_stage}</td><td><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{project.status}</span></td><td>¥0.00</td></tr>)}</tbody></table>}
|
||||
{tab === "members" && <table className="billing-table"><thead><tr><th>成员</th><th>角色</th><th>额度</th><th>状态</th></tr></thead><tbody>{teamMembers.map((member) => <tr key={member.id}><td>{member.user.username}</td><td>{member.role}</td><td>{money(member.monthly_credit_limit)}</td><td><span className={`pill ${statusPill(member.status)}`}><span className="dot" />{member.status}</span></td></tr>)}</tbody></table>}
|
||||
{tab === "bills" && <table className="billing-table bills"><thead><tr><th>时间</th><th>类型</th><th>详情</th><th>金额</th></tr></thead><tbody>{ledgers.map((item) => <tr key={item.id}><td>{new Date(item.created_at).toLocaleString("zh-CN")}</td><td>{item.ledger_type}</td><td>{item.reason}</td><td>{item.amount}</td></tr>)}</tbody></table>}
|
||||
</>
|
||||
<section className="account-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>消费</h1>
|
||||
<div className="sub"><span className="mono">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="top-grid">
|
||||
<div className="balance-banner">
|
||||
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
|
||||
<div className="balance-hero">
|
||||
<div className="lbl">团队余额</div>
|
||||
<div className="v">{money(balance)}</div>
|
||||
<div className="meta">// 充值累加 · 不重置</div>
|
||||
</div>
|
||||
<div className="balance-sub">
|
||||
<div className="col">
|
||||
<div className="lbl">本月限额</div>
|
||||
<div className="v">{money(limit)}</div>
|
||||
<div className="meta">// 按自然月重置</div>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="lbl">当月已用</div>
|
||||
<div className="v">{money(used)}</div>
|
||||
<div className="meta">// 占比 {pct.toFixed(1)}% · {pct >= 80 ? "注意" : "健康"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="balance-foot">
|
||||
<div className="balance-meter" aria-label={`本月额度使用率 ${pct.toFixed(1)}%`}><span style={{ width: `${pct}%` }} /></div>
|
||||
<div className="balance-foot-meta">
|
||||
<span>团队月剩余 {money(left)}</span>
|
||||
<span>使用率 {pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pane topup-pane">
|
||||
<div className="topup-head">
|
||||
<div>
|
||||
<h3>快速充值</h3>
|
||||
<div className="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
|
||||
</div>
|
||||
<div className="topup-selected">已选 ¥{recharge}</div>
|
||||
</div>
|
||||
<div className="recharge-row">
|
||||
{RECHARGE.map((item) => (
|
||||
<div
|
||||
key={item.amt}
|
||||
className={`recharge-card ${recharge === item.amt ? "selected" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={recharge === item.amt}
|
||||
onClick={() => setRecharge(item.amt)}
|
||||
>
|
||||
{item.ribbon && <span className="ribbon">{item.ribbon}</span>}
|
||||
<div className="amt">¥{item.amt}</div>
|
||||
<div className={`gift ${item.bonus ? "bonus" : ""}`}>{item.gift}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pay-row">
|
||||
<div className="pay-title">自定义金额</div>
|
||||
<input className="input" placeholder="最低 ¥50,可输入任意金额" />
|
||||
<div className="pay-btn-row">
|
||||
<button className="btn pay-method-btn pay-wechat" type="button" aria-label="微信支付">
|
||||
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-wechat.png" alt="" /></span>
|
||||
微信支付
|
||||
</button>
|
||||
<button className="btn pay-method-btn pay-alipay" type="button" aria-label="支付宝">
|
||||
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-alipay.png" alt="" /></span>
|
||||
支付宝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="billing-tabs" role="tablist">
|
||||
<button className={`tab ${tab === "overview" ? "active" : ""}`} type="button" onClick={() => setTab("overview")}>总览</button>
|
||||
<button className={`tab ${tab === "by-project" ? "active" : ""}`} type="button" onClick={() => setTab("by-project")}>项目 <span className="count">{projects.length}</span></button>
|
||||
<button className={`tab ${tab === "by-member" ? "active" : ""}`} type="button" onClick={() => setTab("by-member")}>成员 <span className="count">{teamMembers.length}</span></button>
|
||||
<button className={`tab ${tab === "bills" ? "active" : ""}`} type="button" onClick={() => setTab("bills")}>账单流水 <span className="count">{ledgers.length}</span></button>
|
||||
</div>
|
||||
|
||||
<div className={`tab-panel ${tab === "overview" ? "active" : ""}`}>
|
||||
<div className="overview-grid">
|
||||
<div className="pane trend-pane">
|
||||
<div className="trend-head">
|
||||
<h3>消费趋势</h3>
|
||||
<span className="sub">// 近 14 天 · 单位 ¥</span>
|
||||
<span className="spacer"></span>
|
||||
<button className="chip active" type="button">日</button>
|
||||
<button className="chip" type="button">周</button>
|
||||
<button className="chip" type="button">月</button>
|
||||
</div>
|
||||
<div className="trend-chart">
|
||||
<div className="bars"></div>
|
||||
<div className="x-axis"></div>
|
||||
</div>
|
||||
<div className="trend-foot">
|
||||
<div className="item"><span className="k">14 天合计</span><span className="v">{money(used)}</span></div>
|
||||
<div className="item"><span className="k">日均</span><span className="v">{money(used / 14)}</span></div>
|
||||
<div className="item"><span className="k">峰值</span><span className="v warn">{money(used)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pane stage-pane">
|
||||
<h3>本月按阶段分布</h3>
|
||||
<div className="desc">// PRD §5.3.5 扣费规则 · 仅确认后扣</div>
|
||||
{STAGES.map((s) => (
|
||||
<div key={s.k}>
|
||||
<div className="usage-line"><span className="k">{s.k}</span><span className="v">{money(0)}</span></div>
|
||||
<div className="usage-bar"><span style={{ width: "0%", background: s.color }} /></div>
|
||||
</div>
|
||||
))}
|
||||
<div className="total"><span>合计</span><span className="v">{money(used)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pane rule-pane" style={{ marginTop: 16 }}>
|
||||
<h3>扣费 + 四层额度预检规则</h3>
|
||||
<div className="desc">// PRD §5.3.5 + §10.3 · 对接团队请以此页为准</div>
|
||||
<div className="rule-list">
|
||||
<strong>① 失败不扣</strong>:模型超时 / 内容审核拦截 / 生成异常一律不扣费。<br />
|
||||
<strong>② 用户重跑不扣首次</strong>:第一次重跑保留原扣费,第二次起按次结算。<br />
|
||||
<strong>③ 仅在你点击 <span className="mono-acc">[ 确认通过 ]</span> 时入账</strong>。<br />
|
||||
<strong>④ 导出不再扣费</strong>,所有 token 已在过程中结算。
|
||||
</div>
|
||||
<div className="quota-rules">
|
||||
<div className="qr-head">// 任务确认前 · 四层额度预检(任一不通过即拦截)</div>
|
||||
<div className="step"><span className="num">1</span><span><strong>个人日剩余</strong> ≥ 任务预估 × <span className="formula">1.2</span></span></div>
|
||||
<div className="step"><span className="num">2</span><span><strong>个人月剩余</strong> ≥ 同上</span></div>
|
||||
<div className="step"><span className="num">3</span><span><strong>团队月剩余</strong> ≥ 同上</span></div>
|
||||
<div className="step"><span className="num">4</span><span><strong>团队总余额</strong> ≥ 同上</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
|
||||
<table className="billing-table">
|
||||
<thead><tr><th>时间</th><th>项目 / 类型</th><th>详情</th><th>成员</th><th>状态</th><th style={{ textAlign: "right" }}>金额</th></tr></thead>
|
||||
<tbody>
|
||||
{ledgers.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
|
||||
<td>{l.ledger_type}</td>
|
||||
<td className="muted">{l.reason}</td>
|
||||
<td></td>
|
||||
<td><span className="status-tag ok">OK</span></td>
|
||||
<td className="neg">{l.amount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
|
||||
<table className="billing-table">
|
||||
<thead><tr><th>项目</th><th>当前阶段</th><th>状态</th><th style={{ textAlign: "right" }}>消耗</th></tr></thead>
|
||||
<tbody>
|
||||
{projects.map((p) => (
|
||||
<tr key={p.id}><td>{p.name}</td><td>{p.current_stage}</td><td>{p.status}</td><td className="zero">{money(0)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className={`tab-panel ${tab === "by-member" ? "active" : ""}`}>
|
||||
<table className="billing-table">
|
||||
<thead><tr><th>成员</th><th>角色</th><th>已用 / 月度额度</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
{teamMembers.map((m) => (
|
||||
<tr key={m.id}><td className="who"><span className="av">{m.user.username.slice(0, 1).toUpperCase()}</span>{m.user.username}</td><td>{m.role}</td><td className="zero">{money(m.monthly_credit_limit)}</td><td>{m.status}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsageLine({ label, value, width, color }: { label: string; value: string; width: string; color: string }) {
|
||||
return <><div className="usage-line"><span>{label}</span><span className="v">{value}</span></div><div className="usage-bar"><span style={{ width, background: color }} /></div></>;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,22 @@ import { api } from "../api";
|
||||
import type { Team, User } from "../types";
|
||||
import type { AuthMode } from "./route-config";
|
||||
|
||||
const CHECK_SVG = (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
);
|
||||
const MAIL_SVG = (
|
||||
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" /><path d="m22 6-10 7L2 6" /></svg>
|
||||
);
|
||||
const LOCK_SVG = (
|
||||
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||
);
|
||||
const EYE_SVG = (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z" /><circle cx="12" cy="12" r="3" /></svg>
|
||||
);
|
||||
const ARROW_SVG = (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
|
||||
);
|
||||
|
||||
export function AuthScreen({
|
||||
initialMode,
|
||||
onModeChange,
|
||||
@@ -20,8 +36,11 @@ export function AuthScreen({
|
||||
const [email, setEmail] = useState("");
|
||||
const [registerPassword, setRegisterPassword] = useState("");
|
||||
const [registerPassword2, setRegisterPassword2] = useState("");
|
||||
const [invite, setInvite] = useState("");
|
||||
const [agreed, setAgreed] = useState(true);
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [showRegPwd, setShowRegPwd] = useState(false);
|
||||
const [showRegPwd2, setShowRegPwd2] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
|
||||
@@ -82,71 +101,117 @@ export function AuthScreen({
|
||||
<div className="hero">
|
||||
{mode === "login" ? <><h1>AI 全流程<br /><span className="h">短剧化</span>带货生成</h1><p>商品 → AI 脚本 → 故事板 → Seedance 视频片段 → 一键导出 9:16 ≤60s 成片。</p></> : <><h1>开通团队,<br />开始 <span className="h">AI 带货</span> 第一条短剧</h1><p>个人 / 企业团队 1-2 小时出成片,失败不扣费,确认后扣。</p></>}
|
||||
</div>
|
||||
<div className="ascii">
|
||||
<div className="ln"><span className="k">// step·1</span> 脚本生成 <span className="v">●●●●●</span></div>
|
||||
<div className="ln"><span className="k">// step·2</span> 基础资产 <span className="v">●●●●○</span></div>
|
||||
<div className="ln"><span className="k">// step·3</span> 故事板 <span className="v">●●●○○</span></div>
|
||||
<div className="ln"><span className="k">// step·4</span> 视频片段 <span className="v">●●○○○</span></div>
|
||||
<div className="ln"><span className="k">// step·5</span> 拼接导出 <span className="v">●○○○○</span></div>
|
||||
</div>
|
||||
{mode === "login" ? (
|
||||
<div className="ascii">
|
||||
<div className="ln"><span className="k">// step·1</span> 脚本生成 <span className="v">●●●●●</span></div>
|
||||
<div className="ln"><span className="k">// step·2</span> 基础资产 <span className="v">●●●●○</span></div>
|
||||
<div className="ln"><span className="k">// step·3</span> 故事板 <span className="v">●●●○○</span></div>
|
||||
<div className="ln"><span className="k">// step·4</span> 视频片段 <span className="v">●●○○○</span></div>
|
||||
<div className="ln"><span className="k">// step·5</span> 拼接导出 <span className="v">●○○○○</span></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="val-list">
|
||||
<div className="val-item"><span className="ic-v">{CHECK_SVG}</span><span className="txt-v"><b>5 阶段流水线</b> · 脚本 → 资产 → 故事板 → 视频片段 → 拼接导出</span></div>
|
||||
<div className="val-item"><span className="ic-v">{CHECK_SVG}</span><span className="txt-v"><b>失败不扣费</b> · 任务仅在用户通过时扣费,失败 / 超时 / 重跑一律不扣</span></div>
|
||||
<div className="val-item"><span className="ic-v">{CHECK_SVG}</span><span className="txt-v"><b>团队 + 角色 + 四层额度</b> · 超管 / 团管 / 成员,日 / 月 + 团队 / 总四层防超支</span></div>
|
||||
<div className="val-item"><span className="ic-v">{CHECK_SVG}</span><span className="txt-v"><b>跨项目资产库</b> · 主播 / 场景 / 商品图沉淀复用,不重复生成</span></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="foot"><a href="#">关于</a><a href="#">定价</a><a href="#">联系</a><a href="#">隐私</a></div>
|
||||
</aside>
|
||||
<main className="auth-form">
|
||||
<div className="h-row"><h2>{mode === "login" ? "登录" : "注册团队"}</h2><span className="sub">{mode === "login" ? "// /auth/login" : "// /auth/register"}</span></div>
|
||||
<p className="lead">{mode === "login" ? "使用团队邀请邮箱登录,接受邀请后自动加入对应团队。" : "填写团队信息开通账户,默认成为团队超管。"}</p>
|
||||
<form id={mode === "login" ? "login-form" : "register-form"} autoComplete="off" onSubmit={submit}>
|
||||
{mode === "login" ? (
|
||||
<>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="auth-email">邮箱 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" /><path d="m22 6-10 7L2 6" /></svg>
|
||||
<input type="email" id="auth-email" placeholder="name@company.com" value={username} onChange={(event) => setUsername(event.target.value)} required />
|
||||
</div>
|
||||
{mode === "login" ? (
|
||||
<form id="login-form" autoComplete="off" onSubmit={submit}>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="auth-email">邮箱 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
{MAIL_SVG}
|
||||
<input type="email" id="auth-email" placeholder="name@company.com" value={username} onChange={(event) => setUsername(event.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="auth-pwd">密码 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||
<input type={showPwd ? "text" : "password"} id="auth-pwd" placeholder="••••••••" value={password} onChange={(event) => setPassword(event.target.value)} required />
|
||||
<button type="button" className="toggle-pwd" aria-label="切换密码可见" onClick={() => setShowPwd((value) => !value)}>
|
||||
<svg id="pwd-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z" /><circle cx="12" cy="12" r="3" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="auth-pwd">密码 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
{LOCK_SVG}
|
||||
<input type={showPwd ? "text" : "password"} id="auth-pwd" placeholder="••••••••" value={password} onChange={(event) => setPassword(event.target.value)} required />
|
||||
<button type="button" className="toggle-pwd" aria-label="切换密码可见" onClick={() => setShowPwd((value) => !value)}>{EYE_SVG}</button>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<label><input type="checkbox" defaultChecked /> 记住我 7 天</label>
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("已发送重置邮件", "请到 li@shop.com 收件箱查看 · 链接 30 分钟有效"); }}>忘记密码?</a>
|
||||
</div>
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<button className="btn-cta" type="submit" disabled={busy}>
|
||||
{busy ? <span className="busy-copy">// 验证中...</span> : <>登录<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></>}
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<label><input type="checkbox" defaultChecked /> 记住我 7 天</label>
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("已发送重置邮件", "请到 li@shop.com 收件箱查看 · 链接 30 分钟有效"); }}>忘记密码?</a>
|
||||
</div>
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<button className="btn-cta" type="submit" disabled={busy}>
|
||||
{busy ? <span className="busy-copy">// 验证中...</span> : <>登录{ARROW_SVG}</>}
|
||||
</button>
|
||||
<div className="divider"><span className="line"></span><span className="txt">OR</span><span className="line"></span></div>
|
||||
<div className="sso-row">
|
||||
<button type="button" className="sso-btn" onClick={() => showToast("微信扫码", "请在 60s 内用微信扫一扫完成授权 · 内测中,以邮箱登录为准")}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 4C5 4 2 6.5 2 9.5c0 1.7 1 3.2 2.5 4.2L4 16l2.2-1.1c.7.2 1.5.3 2.3.3-.3-.5-.5-1.1-.5-1.7 0-2.8 2.9-5 6.5-5h.5C14.6 6 12 4 9 4zm-2 3.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zM15 9c-3.3 0-6 2.2-6 5s2.7 5 6 5c.7 0 1.4-.1 2-.3L19 20l-.4-1.7c1.4-.9 2.4-2.3 2.4-3.8 0-2.8-2.7-5-6-5zm-2 2.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1z" /></svg>
|
||||
微信扫码
|
||||
</button>
|
||||
<div className="divider"><span className="line"></span><span className="txt">OR</span><span className="line"></span></div>
|
||||
<div className="sso-row">
|
||||
<button type="button" className="sso-btn" onClick={() => showToast("微信扫码", "请在 60s 内用微信扫一扫完成授权 · 内测中,以邮箱登录为准")}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 4C5 4 2 6.5 2 9.5c0 1.7 1 3.2 2.5 4.2L4 16l2.2-1.1c.7.2 1.5.3 2.3.3-.3-.5-.5-1.1-.5-1.7 0-2.8 2.9-5 6.5-5h.5C14.6 6 12 4 9 4zm-2 3.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zM15 9c-3.3 0-6 2.2-6 5s2.7 5 6 5c.7 0 1.4-.1 2-.3L19 20l-.4-1.7c1.4-.9 2.4-2.3 2.4-3.8 0-2.8-2.7-5-6-5zm-2 2.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1z" /></svg>
|
||||
微信扫码
|
||||
</button>
|
||||
<button type="button" className="sso-btn" onClick={() => showToast("飞书 SSO", "即将打开企业飞书登录授权页 · 内测中,以邮箱登录为准")}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="18" height="18" rx="3" /></svg>
|
||||
飞书 SSO
|
||||
</button>
|
||||
<button type="button" className="sso-btn" onClick={() => showToast("飞书 SSO", "即将打开企业飞书登录授权页 · 内测中,以邮箱登录为准")}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="18" height="18" rx="3" /></svg>
|
||||
飞书 SSO
|
||||
</button>
|
||||
</div>
|
||||
<div className="switch-row">还没账号? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}>注册团队 →</a></div>
|
||||
</form>
|
||||
) : (
|
||||
<form id="register-form" autoComplete="off" onSubmit={submit}>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="reg-team">团队名 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="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 文化传媒" value={teamName} onChange={(event) => setTeamName(event.target.value)} required />
|
||||
</div>
|
||||
<div className="switch-row">还没账号? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}>注册团队 →</a></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="field"><label className="field-label" htmlFor="reg-team">团队名 <span className="req">*</span></label><div className="field-input-wrap"><input id="reg-team" value={teamName} onChange={(event) => setTeamName(event.target.value)} required /></div></div>
|
||||
<div className="field"><label className="field-label" htmlFor="reg-email">超管邮箱 <span className="req">*</span></label><div className="field-input-wrap"><input type="email" id="reg-email" value={email} onChange={(event) => setEmail(event.target.value)} required /></div></div>
|
||||
<div className="field-row"><div className="field"><label className="field-label" htmlFor="reg-pwd">密码 <span className="req">*</span></label><div className="field-input-wrap"><input type="password" id="reg-pwd" value={registerPassword} onChange={(event) => setRegisterPassword(event.target.value)} required /></div></div><div className="field"><label className="field-label" htmlFor="reg-pwd2">确认密码 <span className="req">*</span></label><div className="field-input-wrap"><input type="password" id="reg-pwd2" value={registerPassword2} onChange={(event) => setRegisterPassword2(event.target.value)} required /></div></div></div>
|
||||
<label className="agree"><input type="checkbox" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} /><span>我已阅读并同意 用户协议 与 隐私政策。</span></label>
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<button className="btn-cta" type="submit" disabled={busy}>{busy ? <span className="busy-copy">// 验证中...</span> : "创建团队 · 开始使用"}</button>
|
||||
<div className="switch-row">已有账号? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}>登录 →</a></div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="reg-email">超管邮箱 <span className="req">*</span><span className="hint">用于成员邀请 + 找回密码</span></label>
|
||||
<div className="field-input-wrap">
|
||||
{MAIL_SVG}
|
||||
<input type="email" id="reg-email" placeholder="name@company.com" value={email} onChange={(event) => setEmail(event.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="reg-pwd">密码 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
{LOCK_SVG}
|
||||
<input type={showRegPwd ? "text" : "password"} id="reg-pwd" placeholder="至少 8 位" value={registerPassword} onChange={(event) => setRegisterPassword(event.target.value)} required />
|
||||
<button type="button" className="toggle-pwd" onClick={() => setShowRegPwd((value) => !value)}>{EYE_SVG}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="reg-pwd2">确认密码 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
{LOCK_SVG}
|
||||
<input type={showRegPwd2 ? "text" : "password"} id="reg-pwd2" placeholder="再输一次" value={registerPassword2} onChange={(event) => setRegisterPassword2(event.target.value)} required />
|
||||
<button type="button" className="toggle-pwd" onClick={() => setShowRegPwd2((value) => !value)}>{EYE_SVG}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="reg-invite">邀请码 <span className="hint">可选 · 团队邀请才需要</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="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" value={invite} onChange={(event) => setInvite(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="agree">
|
||||
<input type="checkbox" id="reg-agree" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} />
|
||||
<span>我已阅读并同意 <a href="#" onClick={(event) => { event.preventDefault(); showToast("用户协议", "含数据处理 / 内容生成版权 / 计费规则三章 · 完整文本将在正式版接入"); }}>用户协议</a> 与 <a href="#" onClick={(event) => { event.preventDefault(); showToast("隐私政策", "遵循《个人信息保护法》· 团队数据存中国境内 · 默认不用于模型训练"); }}>隐私政策</a>,知悉「失败不扣费 · 确认后扣」的扣费规则。</span>
|
||||
</label>
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<button className="btn-cta" type="submit" id="reg-submit" disabled={busy}>
|
||||
{busy ? <span className="busy-copy">// 创建团队中...</span> : <>创建团队 · 开始使用{ARROW_SVG}</>}
|
||||
</button>
|
||||
<div className="switch-row">已有账号? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}>登录 →</a></div>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
{toast && <div className="login-toast"><div>{toast.title}</div><div style={{ fontSize: "11.5px", color: "rgba(0,0,0,.56)", fontWeight: 400, letterSpacing: ".02em" }}>// {toast.sub}</div></div>}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Film, Home, Library, Package, Plus, Wallet } from "lucide-react";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { Asset, BillingSummary, Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { money, stageMeta, statusPill } from "./stage-config";
|
||||
import { Progress } from "../components/pipeline-stage";
|
||||
import { IconKitSvg } from "../components/IconKitSvg";
|
||||
|
||||
export function Dashboard({ products, projects, assets, billing, userName, navigate }: {
|
||||
products: Product[];
|
||||
@@ -17,9 +18,9 @@ export function Dashboard({ products, projects, assets, billing, userName, navig
|
||||
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>欢迎回来{userName ? `,${userName}` : ""}</h1><div className="sub"><span className="mono">// {new Date().toLocaleDateString("zh-CN")}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span></div></div><div className="actions"><button className="btn" type="button" onClick={() => navigate("products")}><Plus size={13} />新建商品</button><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate("projectWizard")}><Plus size={13} />新建项目</button></div></div>
|
||||
<div className="page-head"><div><h1>欢迎回来{userName ? `,${userName.split("@")[0]}` : ""}</h1><div className="sub"><span className="mono">// {new Date().toLocaleDateString("zh-CN")}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span></div></div><div className="actions"><button className="btn" type="button" onClick={() => navigate("products")}><Plus size={13} />新建商品</button><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate("projectWizard")}><Plus size={13} />新建项目</button></div></div>
|
||||
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projects.length} delta={`↑ 本月 +${Math.max(projects.length, 0)}`} /><KpiStat label="进行中" badge="WIP" value={running} delta="待处理" /><KpiStat label="成片" badge="DONE" value={completed} delta="导出完成" /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl">余额 <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: "38%" }} /></div><div className="sub">已冻结 {money(billing?.account.reserved_balance)}</div></button></div>
|
||||
<div className="dash-grid"><div><div className="section-h"><h2>最近项目</h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projects.length} ] →</button></div><div className="card-hard">{projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI 全生 / 4 镜</div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm">继续</span></button>)}</div></div><div className="dash-side"><div className="section-h"><h2>快捷入口</h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut icon={Package} title="商品库" desc={`${products.length} SKU`} onClick={() => navigate("products")} /><Shortcut icon={Library} title="资产库" desc={`${assets.length} assets`} onClick={() => navigate("library")} /><Shortcut icon={Wallet} title="充值" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut icon={Film} title="所有项目" desc={`${projects.length} 个`} onClick={() => navigate("projects")} /></div></div></div>
|
||||
<div className="dash-grid"><div><div className="section-h"><h2>最近项目</h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projects.length} ] →</button></div><div className="card-hard">{projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI 全生 / 4 镜</div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm">继续</span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2>快捷入口</h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="商品库" desc={`${products.length} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="资产库" desc={`${assets.length} 资产`} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="充值" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="所有项目" desc={`${projects.length} 个`} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2>提示</h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong>扣费规则</strong>生成失败、超时、用户重跑 — 均不扣费。仅在你点 <span className="mono">[ 确认通过 ]</span> 时按 token 实际结算。</div></div></div></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +29,6 @@ export function KpiStat({ label, badge, value, delta }: { label: string; badge:
|
||||
return <div className="stat"><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div><div className="delta up">{delta}</div></div>;
|
||||
}
|
||||
|
||||
export function Shortcut({ icon: Icon, title, desc, onClick }: { icon: typeof Home; title: string; desc: string; onClick: () => void }) {
|
||||
return <button className="shortcut" type="button" onClick={onClick}><div className="ic"><Icon size={15} /></div><div><div className="t">{title}</div><div className="d">{desc}</div></div></button>;
|
||||
export function Shortcut({ name, title, desc, onClick }: { name: string; title: string; desc: string; onClick: () => void }) {
|
||||
return <a className="shortcut" onClick={onClick}><div className="ic"><IconKitSvg name={name} size={16} /></div><div><div className="t">{title}</div><div className="d">{desc}</div></div></a>;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,50 @@
|
||||
import { useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Search, Upload } from "lucide-react";
|
||||
import type { Asset } from "../types";
|
||||
import { Drawer } from "../components/overlays";
|
||||
|
||||
type LibTab = "people" | "scenes" | "products" | "finals" | "uploads" | "unclassified";
|
||||
|
||||
const LIB_TABS: Array<{ key: LibTab; label: string }> = [
|
||||
{ key: "people", label: "人物" }, { key: "scenes", label: "场景" }, { key: "products", label: "商品图" },
|
||||
{ key: "finals", label: "成片" }, { key: "uploads", label: "我的上传" }, { key: "unclassified", label: "未分类" }
|
||||
];
|
||||
|
||||
// 对齐 api-bridge:工具栏 chip 按 tab 显隐
|
||||
const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
|
||||
{ key: "gender", label: "性别", tabs: ["people"] },
|
||||
{ key: "age", label: "年龄段", tabs: ["people"] },
|
||||
{ key: "role", label: "角色标签", tabs: ["people"] },
|
||||
{ key: "sceneType", label: "场景类型", tabs: ["scenes"] },
|
||||
{ key: "product", label: "关联商品", tabs: ["products"] },
|
||||
{ key: "project", label: "关联项目", tabs: ["finals"] },
|
||||
{ key: "duration", label: "时长", tabs: ["finals"] },
|
||||
{ key: "kind", label: "资产类型", tabs: ["uploads"] },
|
||||
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "uploads"] }
|
||||
];
|
||||
|
||||
function assetTab(asset: Asset): LibTab {
|
||||
switch (asset.category) {
|
||||
case "person": return "people";
|
||||
case "scene": return "scenes";
|
||||
case "product_image": return "products";
|
||||
case "final_video": return "finals";
|
||||
case "upload": return "uploads";
|
||||
default: return asset.asset_type === "video" ? "finals" : "unclassified";
|
||||
}
|
||||
}
|
||||
|
||||
export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (formData: FormData) => Promise<unknown> | void }) {
|
||||
const [tab, setTab] = useState<LibTab>("people");
|
||||
const [query, setQuery] = useState("");
|
||||
const [drawer, setDrawer] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const counts = LIB_TABS.reduce((acc, t) => { acc[t.key] = assets.filter((a) => assetTab(a) === t.key).length; return acc; }, {} as Record<LibTab, number>);
|
||||
const inTab = assets.filter((a) => assetTab(a) === tab);
|
||||
const filtered = inTab.filter((a) => `${a.name} ${a.category}`.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!file) return;
|
||||
@@ -22,12 +58,62 @@ export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>资产库</h1><div className="sub"><span className="mono">// 跨项目复用 · {assets.length} assets</span></div></div><div className="actions"><button className="btn" type="button" onClick={() => setDrawer(true)}><Upload size={13} />上传资产</button></div></div>
|
||||
<div className="tabs"><button className="tab active">全部 <span className="count">{assets.length}</span></button><button className="tab">人物</button><button className="tab">场景</button><button className="tab">商品图</button><button className="tab">成片</button></div>
|
||||
<div className="toolbar"><div className="search-inline"><Search size={14} /><input className="input" placeholder="搜索资产" /></div><button className="chip active">全部</button><button className="chip">最近使用</button></div>
|
||||
<div className="asset-grid">{assets.map((asset) => <article className={`asset-card ${asset.asset_type}`} key={asset.id}><div className="placeholder asset-thumb"><span className="ph-frame">{asset.asset_type}</span></div><div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{asset.category} · {asset.source}</div></div></article>)}</div>
|
||||
<section className="library-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>资产库</h1>
|
||||
<div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> 人 · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-finals">{counts.finals}</span> 片</span></div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" id="lib-manage-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
|
||||
<span className="lib-manage-label">管理资产</span>
|
||||
</button>
|
||||
<button className="btn btn-primary" type="button" id="open-upload-btn" onClick={() => setDrawer(true)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
|
||||
上传资产
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs" id="asset-tabs">
|
||||
{LIB_TABS.map((t) => (
|
||||
<div className={`tab${tab === t.key ? " active" : ""}`} key={t.key} data-tab={t.key} onClick={() => setTab(t.key)}>{t.label} <span className="count">{counts[t.key]}</span></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<div className="search-inline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
<input className="input" id="search-input" placeholder="搜索资产名称、标签" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
{LIB_CHIPS.filter((chip) => chip.tabs.includes(tab)).map((chip) => (
|
||||
<div className="chip-wrap" data-key={chip.key} key={chip.key}>
|
||||
<button className="chip" type="button"><span className="chip-label">{chip.label}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
|
||||
</div>
|
||||
))}
|
||||
<span className="spacer"></span>
|
||||
<div className="chip-wrap" data-key="sort">
|
||||
<button className="chip" type="button"><span className="chip-label">最近使用</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="result-meta" id="result-meta">// 显示 <span className="count">{filtered.length}</span> / {inTab.length} 个资产</div>
|
||||
|
||||
{filtered.length ? (
|
||||
<div className="asset-grid" id="asset-grid">
|
||||
{filtered.map((asset) => (
|
||||
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
|
||||
<div className="placeholder asset-thumb"><span className="ph-frame">{asset.asset_type}</span></div>
|
||||
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{asset.category} · {asset.source}</div></div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-filter">// 当前分类暂无真实资产</div>
|
||||
)}
|
||||
|
||||
<Drawer title="上传资产" open={drawer} close={() => setDrawer(false)}><form onSubmit={submit}><div className="field"><label className="field-label">文件</label><input className="input file-input" type="file" onChange={(event) => setFile(event.target.files?.[0] || null)} /></div><div className="field"><label className="field-label">资产名称</label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="drawer-actions"><button className="btn btn-ghost" type="button" onClick={() => setDrawer(false)}>取消</button><button className="btn btn-primary" type="submit" disabled={!file}>上传资产</button></div></form></Drawer>
|
||||
</>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,71 @@
|
||||
import { useState } from "react";
|
||||
import { Copy, Play } from "lucide-react";
|
||||
import type { Project } from "../types";
|
||||
import { FragmentStageStep } from "../components/pipeline-stage";
|
||||
import { stageOrder, statusPill } from "./stage-config";
|
||||
import { Fragment, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
import type { BillingSummary, Product, Project, Team, User } from "../types";
|
||||
import type { Notice, Page } from "./route-config";
|
||||
import { money, stageOrder, statusPill } from "./stage-config";
|
||||
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
|
||||
import { IconKitSvg } from "../components/IconKitSvg";
|
||||
|
||||
export function PipelinePage({
|
||||
project,
|
||||
loading,
|
||||
onRefresh,
|
||||
onGenerateScript,
|
||||
onAdoptScript,
|
||||
onGenerateBaseAsset,
|
||||
onGenerateStoryboard,
|
||||
onSkipStoryboard,
|
||||
onSubmitVideo,
|
||||
onPollVideo,
|
||||
onSubmitAllVideos,
|
||||
onPollAllVideos,
|
||||
onSubmitExport
|
||||
}: {
|
||||
// 镜像 shell.js→mock-media.js 给 .placeholder 注入 mock 图;React 手动复刻对应映射
|
||||
const mock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
|
||||
|
||||
// Stage 3 故事板 · 镜像 JS 注入的 3 场(全 mock)
|
||||
const SB_SCENES = [
|
||||
{ sid: "sc1", nm: "场 1", sub: "0-15s", frame: "深夜办公桌", img: "scene-office.png" },
|
||||
{ sid: "sc2", nm: "场 2", sub: "15-30s", frame: "面膜包装/特写", img: "product-mask.png" },
|
||||
{ sid: "sc3", nm: "场 3", sub: "30-45s", frame: "化妆台/产品定格", img: "cover-mask-final.png" }
|
||||
];
|
||||
const SB_PROMPT = "中景 / 固定机位\n光线:台灯暖光 + 屏幕冷光\n演员:林夕(疲倦状态)\n关键道具:面膜盒(从抽屉露半角)\n氛围:午夜、安静、些许焦虑";
|
||||
|
||||
// Stage 4 视频 · 镜像 3 场(全 mock,video-thumb 经 mock-media 映射封面图)
|
||||
const VIDEO_CARDS = [
|
||||
{ vid: "v1", frame: "场 1 · 0-15s", title: "场 1 · 深夜办公桌", meta: "15s · 1080×1920 · ¥0.45", img: "cover-mask-v3.png" },
|
||||
{ vid: "v2", frame: "场 2 · 15-27s", title: "场 2 · 面膜包装/特写", meta: "12s · 1080×1920 · ¥0.45", img: "product-mask.png" },
|
||||
{ vid: "v3", frame: "场 3 · 27-40s", title: "场 3 · 化妆台/产品定格", meta: "13s · 1080×1920 · ¥0.45", img: "cover-mask-final.png" }
|
||||
];
|
||||
|
||||
// Stage 5 拼接编辑器 · 全 mock(镜像时间轴引擎按 data-dur 累计定位,React 直接算 left/width)
|
||||
const ED_VIDEO_CLIPS = [
|
||||
{ n: 1, lbl: "深夜办公桌", dur: 2 }, { n: 2, lbl: "面膜包装", dur: 3 }, { n: 3, lbl: "精华液微距", dur: 3 },
|
||||
{ n: 4, lbl: "敷面膜平躺", dur: 3 }, { n: 5, lbl: "化妆台", dur: 2 }, { n: 6, lbl: "产品定格", dur: 2 }
|
||||
];
|
||||
const ED_SUB_CLIPS = [
|
||||
{ lbl: "加班三天 脸已经不能看了…", dur: 2 }, { lbl: "还好我有这个 透真玻尿酸面膜", dur: 3 }, { lbl: "30g 精华 一片顶三片", dur: 3 },
|
||||
{ lbl: "敷完起来脸是软的", dur: 3 }, { lbl: "化妆都能看出来", dur: 2 }, { lbl: "5 片 ¥39.9 囤起来", dur: 2 }
|
||||
];
|
||||
const ED_RULER: Array<{ left: string; major: boolean; t?: string }> = [
|
||||
{ left: "0%", major: true, t: "0s" }, { left: "6.67%", major: false }, { left: "13.33%", major: true, t: "2s" }, { left: "20%", major: false },
|
||||
{ left: "26.67%", major: true, t: "4s" }, { left: "33.33%", major: false }, { left: "40%", major: true, t: "6s" }, { left: "46.67%", major: false },
|
||||
{ left: "53.33%", major: true, t: "8s" }, { left: "60%", major: false }, { left: "66.67%", major: true, t: "10s" }, { left: "73.33%", major: false },
|
||||
{ left: "80%", major: true, t: "12s" }, { left: "86.67%", major: false }, { left: "93.33%", major: true, t: "14s" }, { left: "100%", major: true, t: "15s" }
|
||||
];
|
||||
const ED_WAVE: Array<[number, number]> = [[8,4],[6,8],[3,14],[7,6],[4,12],[2,16],[6,8],[8,4],[5,10],[3,14],[7,6],[4,12],[6,8],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8]];
|
||||
function edLayout<T extends { dur: number }>(clips: T[]): Array<T & { leftPct: number; widthPct: number }> {
|
||||
let acc = 0;
|
||||
return clips.map((c) => { const left = acc; acc += c.dur; return { ...c, leftPct: (left / 15) * 100, widthPct: (c.dur / 15) * 100 }; });
|
||||
}
|
||||
|
||||
const STAGE_STEPS: Array<{ n: number; label: string }> = [
|
||||
{ n: 1, label: "脚本" },
|
||||
{ n: 2, label: "基础资产" },
|
||||
{ n: 3, label: "故事板" },
|
||||
{ n: 4, label: "视频" },
|
||||
{ n: 5, label: "拼接导出" }
|
||||
];
|
||||
|
||||
export function PipelinePage(props: {
|
||||
project: Project;
|
||||
loading: boolean;
|
||||
navigate: (page: Page, options?: { projectId?: string; productId?: string }) => void;
|
||||
user: User;
|
||||
team: Team;
|
||||
products: Product[];
|
||||
projects: Project[];
|
||||
billing: BillingSummary | null;
|
||||
notice: Notice | null;
|
||||
avatarChar: string;
|
||||
logout: () => void;
|
||||
onRefresh: () => void;
|
||||
onGenerateScript: (prompt: string) => void;
|
||||
onAdoptScript: (scriptId: string) => void;
|
||||
@@ -33,21 +78,510 @@ export function PipelinePage({
|
||||
onPollAllVideos: () => void;
|
||||
onSubmitExport: () => void;
|
||||
}) {
|
||||
const [activeStage, setActiveStage] = useState<string>(project.current_stage || "script");
|
||||
const [prompt, setPrompt] = useState("突出商品卖点,节奏紧凑,适合短视频投放");
|
||||
const [storyboardPrompt, setStoryboardPrompt] = useState("统一商品、人物、场景风格,生成可直接指导视频的分镜图");
|
||||
const [videoPrompt, setVideoPrompt] = useState("竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感");
|
||||
const {
|
||||
project, loading, navigate, user, team, products, projects, billing, notice, avatarChar, logout,
|
||||
onGenerateScript, onGenerateBaseAsset, onGenerateStoryboard, onSkipStoryboard,
|
||||
onSubmitVideo, onPollVideo, onSubmitAllVideos, onPollAllVideos, onSubmitExport
|
||||
} = props;
|
||||
|
||||
// 步进器:对齐镜像 activateStage 逻辑。默认(无 hash)pane=脚本(1) 但步进器 active=项目真实阶段;
|
||||
// 一旦导航(hash 或点击),active 跟随所看阶段,completed=max(项目阶段-1, 所看阶段-1)。
|
||||
const projectStage = project.status === "completed" ? 5 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1);
|
||||
const initHash = typeof location !== "undefined" ? location.hash.match(/#stage-(\d)/) : null;
|
||||
const [viewStage, setViewStage] = useState(initHash ? Number(initHash[1]) : 1);
|
||||
const [navigated, setNavigated] = useState(Boolean(initHash));
|
||||
const activeDot = navigated ? viewStage : projectStage;
|
||||
const completed = Math.max(projectStage - 1, activeDot - 1);
|
||||
const [chatText, setChatText] = useState("");
|
||||
const [storyboardPrompt, setStoryboardPrompt] = useState("统一商品、人物、场景风格,生成可直接指导视频的分镜图");
|
||||
const [videoPrompt, setVideoPrompt] = useState("竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感");
|
||||
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
|
||||
// 真实商品名(api-bridge 仅 hydrate 商品名,其余人物/场景沿用设计稿 mock)
|
||||
const productName = products.find((item) => item.id === project.product)?.title || "透真补水面膜";
|
||||
|
||||
function goStage(n: number) {
|
||||
setViewStage(n);
|
||||
setNavigated(true);
|
||||
if (typeof location !== "undefined") location.hash = `stage-${n}`;
|
||||
}
|
||||
function dotCls(n: number) {
|
||||
if (n === activeDot) return "sp-dot active";
|
||||
if (n <= completed) return "sp-dot done";
|
||||
return "sp-dot";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="proj-head"><div className="hstack" style={{ gap: 14 }}><div className="placeholder" style={{ width: 42, height: 54 }}><span className="ph-frame">9:16</span></div><div><div className="hstack"><h1>{project.name}</h1><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{project.status}</span></div><div className="muted-2 mono" style={{ fontSize: 11.5, marginTop: 4 }}>// AI 全生 · 4 段 · 60s · 9:16</div></div></div><div className="hstack"><button className="btn btn-sm" type="button" onClick={onRefresh}>刷新</button><button className="btn btn-ghost btn-sm" type="button"><Copy size={12} />复制项目</button></div></div>
|
||||
<div className="stepper"><span className="corner-tr">+</span><span className="corner-bl">+</span>{stageOrder.map((stage, index) => { const projectIndex = stageOrder.indexOf(project.current_stage as never); return <FragmentStageStep key={stage} stage={stage} done={index < projectIndex} active={activeStage === stage} lineDone={index < projectIndex} isLast={index === stageOrder.length - 1} onClick={() => setActiveStage(stage)} />; })}</div>
|
||||
{activeStage === "script" && <section className="stage active"><div className="stage-script"><div className="pane chat-pane"><div className="pane-h"><div className="ai-avatar">AI</div><strong>脚本助手</strong><span className="muted-2 mono">· Doubao</span></div><div className="chat-input"><textarea className="textarea" value={prompt} onChange={(event) => setPrompt(event.target.value)} /><div className="hstack" style={{ marginTop: 8 }}><span className="spacer" /><button className="btn btn-primary" type="button" disabled={loading} onClick={() => onGenerateScript(prompt)}>生成脚本 ⌘↵</button></div></div></div><div className="pane shot-list"><div className="pane-h"><strong>脚本 / 读秒分镜</strong><span className="spacer" /><span className="muted-2 mono">{project.script_versions.length} versions</span></div>{project.script_versions.map((script) => <article className={`shot-card ${script.is_adopted ? "highlight" : ""}`} key={script.id}><div className="shot-row"><div className="shot-k">TXT</div><div className="shot-v">{script.content}</div></div><button className="btn btn-sm" type="button" onClick={() => onAdoptScript(script.id)}>采用</button></article>)}</div></div></section>}
|
||||
{activeStage === "base_assets" && <section className="stage active"><div className="asset-grid-2">{(["product", "person", "scene"] as const).map((kind) => <article className="asset-card-2" key={kind}><div className="placeholder thumb-2"><span className="ph-frame">{kind}</span></div><div className="body-2"><strong>{kind}</strong><div className="prompt-box">{project.name} {kind} 16:9 基础资产</div><button className="btn btn-sm" type="button" disabled={loading} onClick={() => onGenerateBaseAsset(kind, `${project.name} ${kind} 16:9 基础资产`)}>生成 {kind}</button></div></article>)}</div></section>}
|
||||
{activeStage === "storyboard" && <section className="stage active"><div className="stage-storyboard"><div className="sb-canvas">{[0, 1, 2, 3].map((index) => <div className="sb-row" key={index}><div className="sb-num">{String(index + 1).padStart(2, "0")}<span className="t">{index * 15}-{(index + 1) * 15}s</span></div><div className="sb-img"><div className="placeholder"><span className="ph-frame">16:9</span></div></div><div className="sb-text"><div className="meta">PROMPT</div><div className="dialog">等待脚本段落</div></div></div>)}</div><div className="sb-side"><div className="pane"><textarea className="textarea" value={storyboardPrompt} onChange={(event) => setStoryboardPrompt(event.target.value)} /><div className="hstack"><button className="btn btn-primary" type="button" disabled={loading} onClick={() => onGenerateStoryboard(storyboardPrompt)}>生成故事板</button><button className="btn" type="button" disabled={loading} onClick={onSkipStoryboard}>跳过故事板</button></div></div></div></div></section>}
|
||||
{activeStage === "video" && <section className="stage active"><div className="queue-bar"><span className="mono">[ VIDEO QUEUE ]</span><div className="bar-wrap"><span style={{ width: `${(project.video_segments.filter((s) => s.status === "succeeded").length / 4) * 100}%` }} /></div><span className="mono">{project.video_segments.filter((s) => s.status === "succeeded").length}/4</span></div><div className="field"><textarea className="textarea" value={videoPrompt} onChange={(event) => setVideoPrompt(event.target.value)} /></div><div className="hstack" style={{ marginBottom: 14 }}><button className="btn btn-primary" type="button" disabled={loading} onClick={() => onSubmitAllVideos(videoPrompt)}>生成完整 60s</button><button className="btn" type="button" disabled={loading} onClick={onPollAllVideos}>刷新全部</button></div><div className="video-grid">{project.video_segments.map((segment) => <article className="video-card" key={segment.id}><div className="placeholder video-thumb"><span className="ph-frame">SEG {segment.sort_order + 1}</span><div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div></div><div className="body"><div className="hstack"><strong>{segment.target_duration_seconds}s</strong><span className="spacer" /><span className={`pill ${statusPill(segment.status)}`}><span className="dot" />{segment.status}</span></div><div className="hstack" style={{ marginTop: 8 }}><button className="btn btn-sm" type="button" disabled={loading || segment.status === "running"} onClick={() => onSubmitVideo(segment.id, `${videoPrompt} 第 ${segment.sort_order + 1} 段`)}>生成</button><button className="btn btn-sm" type="button" onClick={() => onPollVideo(segment.id)}>刷新</button></div></div></article>)}</div></section>}
|
||||
{activeStage === "export" && <section className="stage active"><div className="editor"><div className="editor-preview"><div className="canvas">9:16 / 1080×1920</div></div><div className="editor-props"><button className="btn btn-primary" type="button" disabled={loading || !canExport} onClick={onSubmitExport}>提交导出</button></div></div></section>}
|
||||
</>
|
||||
<div className="app pipeline-page">
|
||||
<Sidebar page="pipeline" navigate={navigate} user={user} team={team} products={products} projects={projects} />
|
||||
<main>
|
||||
<Decorations />
|
||||
<header className="topbar">
|
||||
<div className="pipeline-topbar-left">
|
||||
<a className="btn btn-ghost pipeline-back" href="/projects" onClick={(event) => { event.preventDefault(); navigate("projects"); }} aria-label="返回视频项目">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M19 12H5" /><path d="M12 19l-7-7 7-7" /></svg>
|
||||
返回视频项目
|
||||
</a>
|
||||
<div className="pipeline-topbar-title" title={project.name}>
|
||||
{project.name}<span className="mono">// PIPELINE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="right">
|
||||
<span className="balance-chip" onClick={() => navigate("account")}>
|
||||
<IconKitSvg name="creditCard" />
|
||||
余额 <strong>{money(billing?.account.balance)}</strong>
|
||||
</span>
|
||||
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
|
||||
<IconKitSvg name="bell" />
|
||||
<span className="count-noti">12</span>
|
||||
</button>
|
||||
<div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
|
||||
<span>{avatarChar}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stage-pill" id="stage-pill">
|
||||
{STAGE_STEPS.map((step, index) => (
|
||||
<Fragment key={step.n}>
|
||||
<a className={dotCls(step.n)} data-stage={step.n} href={`#stage-${step.n}`} onClick={(event) => { event.preventDefault(); goStage(step.n); }}>
|
||||
<span className="d"></span><span className="l">{step.label}</span>
|
||||
</a>
|
||||
{index < STAGE_STEPS.length - 1 && <span className={`sp-line${step.n <= completed ? " done" : ""}`}></span>}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="content content--fh content--fh-flat" id="page-content">
|
||||
<CornerMarks />
|
||||
{notice && <ToastLike notice={notice} />}
|
||||
|
||||
{/* ============= STAGE 1 · 脚本 ============= */}
|
||||
<section className={`stage${viewStage === 1 ? " active" : ""}`} data-stage-pane="1">
|
||||
<div className="stage-script">
|
||||
<div className="pane shot-list">
|
||||
<div className="pane-h">
|
||||
<div className="shot-headline">
|
||||
<strong>镜头脚本</strong>
|
||||
<span className="muted-2 mono" id="shots-meta" style={{ fontSize: "11px" }}>· 空 · 待生成</span>
|
||||
</div>
|
||||
<div className="script-brief-summary" aria-label="当前创作方向">
|
||||
<span className="pill neutral script-brief-pill"><span className="k">来源</span><span className="v" id="brief-source">未选择</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">风格</span><span className="v" id="brief-style">待确认</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">人物</span><span className="v" id="brief-persona">待确认</span></span>
|
||||
</div>
|
||||
<div className="script-tags" id="script-tags">
|
||||
<div className="tag-group" data-kind="char">
|
||||
<span className="tg-lbl">// 人物</span>
|
||||
<button className="tag-add" type="button" aria-label="添加人物">+</button>
|
||||
</div>
|
||||
<div className="tag-group" data-kind="scene">
|
||||
<span className="tg-lbl">// 场景</span>
|
||||
<button className="tag-add" type="button" aria-label="添加场景">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-ghost btn-sm" type="button" id="chat-regen-btn">↻ 整体重写</button>
|
||||
</div>
|
||||
<div className="shots-body" id="shots-body">
|
||||
<div className="shots-empty">
|
||||
<div className="empty-ico"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3 10h18M9 5v14" /></svg></div>
|
||||
<div className="empty-title">还没有镜头脚本</div>
|
||||
<div className="empty-hint">// 跟右侧脚本助手对话<br />选择一种方式生成你的第一稿</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-script-gutter" id="stage-script-gutter" role="separator" aria-orientation="vertical" aria-label="拖动调整脚本助手宽度"></div>
|
||||
|
||||
<div className="pane chat-pane">
|
||||
<div className="pane-h">
|
||||
<div className="ai-avatar">AI</div>
|
||||
<strong>脚本助手</strong>
|
||||
<span className="muted-2 mono" style={{ fontSize: "11px" }}>· GPT-4o</span>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn">清空对话</button>
|
||||
</div>
|
||||
<div className="chat-body" id="chat-body">
|
||||
<div className="chat-empty">
|
||||
<div className="ce-title">选择一种生成方式开始</div>
|
||||
<div className="ce-hint">// 三种,由「最省事」到「最保真原意」</div>
|
||||
<div className="chat-modes">
|
||||
<button className="chat-mode primary" type="button" data-mode="ai" disabled={loading} onClick={() => onGenerateScript("AI 全生 · 突出商品卖点,节奏紧凑,适合短视频投放")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>AI 全生</button>
|
||||
<button className="chat-mode" type="button" data-mode="theme"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6" /><path d="M10 22h4" /><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5" /></svg>一句话主题</button>
|
||||
<button className="chat-mode" type="button" data-mode="manual"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="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 2v6h6" /></svg>自带脚本</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-input">
|
||||
<div className="chat-input-card">
|
||||
<div className="chat-attach-row" id="chat-attach-row" hidden></div>
|
||||
<textarea className="chat-input-area" id="chat-textarea" placeholder="直接说怎么改,如:更像小红书种草 / 换成熬夜党" rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}></textarea>
|
||||
<div className="chat-input-foot">
|
||||
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本附件" aria-label="上传脚本附件">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</button>
|
||||
<span className="spacer"></span>
|
||||
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || !chatText.trim()} onClick={() => { onGenerateScript(chatText.trim()); setChatText(""); }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ LLM 用量 ~2.4k tokens · ¥0.04 · 失败不扣 · 通过后扣 ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" disabled={loading} onClick={() => onGenerateScript("整体重新生成 · 突出商品卖点,节奏紧凑")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><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" /></svg> 重新生成全部</button>
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(2)}>确认脚本,进入下一步 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stage 2-5 · 暂沿用既有功能性结构(默认隐藏),后续逐阶段做像素还原 */}
|
||||
{viewStage === 2 && (
|
||||
<section className="stage active" data-stage-pane="2">
|
||||
<div className="stage-assets">
|
||||
<div className="asset-side">
|
||||
<div className="ttab active" data-jump="asset-sec-products"><span>商品</span><span className="num">3 张</span></div>
|
||||
<div className="ttab" data-jump="asset-sec-characters"><span>人物</span><span className="num">2/2</span></div>
|
||||
<div className="ttab" data-jump="asset-sec-scenes"><span>场景</span><span className="num">3/3</span></div>
|
||||
<div className="info">
|
||||
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。
|
||||
<br /><br />
|
||||
<strong className="mono">// 人物 +¥0.20/张</strong>
|
||||
<strong className="mono">// 场景 +¥0.15/张</strong>
|
||||
<span style={{ color: "var(--black-alpha-48)" }}>商品图无成本(直接复用商品库)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="asset-main">
|
||||
<section className="asset-sec" id="asset-sec-products">
|
||||
<div className="sec-h"><h3>商品 · <span id="asset-prod-name">{productName}</span></h3><span className="spacer"></span></div>
|
||||
<div className="prod-row">
|
||||
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id="prod-main" id="asset-prod-card">
|
||||
<div className="placeholder prod-thumb has-mock-media" style={mock("product-mask.png")}>
|
||||
<span className="tri-missing-badge" id="asset-prod-tri-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明">
|
||||
<span className="ico" aria-hidden="true"></span>
|
||||
<span className="lbl-mono">缺三视图</span>
|
||||
<span className="tri-missing-pop" role="tooltip">
|
||||
<span className="pop-h">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 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" /></svg>
|
||||
MISSING TRI-VIEW
|
||||
</span>
|
||||
<span className="pop-body">该商品还未生成 <b>正 / 侧 / 背</b> 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。</span>
|
||||
<span className="pop-tip">建议:点右下 <b>AI 生成三视图</b> 先补齐三视图,再发起后续生成。</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="ph-frame" id="asset-prod-thumb-label">{productName} · 主图</span>
|
||||
</div>
|
||||
<div className="prod-body">
|
||||
<div className="prod-name" id="asset-prod-card-name">{productName}</div>
|
||||
<div className="prod-cat">美妆个护</div>
|
||||
<div className="prod-date">2026-05-15 创建</div>
|
||||
</div>
|
||||
<div className="prod-action" id="asset-prod-action">
|
||||
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={loading} onClick={() => onGenerateBaseAsset("product", `${productName} 三视图`)}>
|
||||
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
|
||||
AI 生成三视图
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prod-preview" id="asset-prod-preview">
|
||||
<div className="prod-preview-h">// 三视图预览 · <span id="prod-preview-status">生成中</span></div>
|
||||
<div className="placeholder prod-preview-img" id="prod-preview-img"></div>
|
||||
<div className="prod-preview-foot" id="prod-preview-foot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="asset-sec" id="asset-sec-characters">
|
||||
<div className="sec-h"><h3>人物 · 2 个</h3><span className="spacer"></span></div>
|
||||
<div className="asset-grid-2">
|
||||
<div className="asset-card-2" data-asset-kind="character" data-asset-id="ch-linxi">
|
||||
<div className="placeholder thumb-2 has-mock-media" style={mock("person-linxi.png")}><span className="ph-frame">林夕 · 都市白领</span></div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>主角 · 林夕</strong><span className="spacer"></span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>25-30 岁都市白领,长发,穿宽松米色家居服,温柔但带点疲倦感。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-card-2" data-asset-kind="character" data-asset-id="ch-anan">
|
||||
<div className="placeholder thumb-2">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", alignItems: "center" }}>
|
||||
<div className="spinner"></div>
|
||||
<span className="ph-frame">生成中 · 约 8s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>朋友/同事 · 阿楠</strong><span className="spacer"></span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>25-30 岁同龄女性,短发,穿白色衬衫,妆容精致皮肤好,作为对比。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop disabled>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop disabled>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="asset-sec" id="asset-sec-scenes">
|
||||
<div className="sec-h"><h3>场景 · 3 个</h3><span className="spacer"></span></div>
|
||||
<div className="asset-grid-2">
|
||||
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-desk">
|
||||
<div className="placeholder thumb-2 has-mock-media" style={mock("scene-office.png")}><span className="ph-frame">深夜办公桌</span></div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>深夜办公桌</strong><span className="spacer"></span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>深夜居家办公环境,木质书桌,台灯暖光,电脑屏幕亮着。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-bed">
|
||||
<div className="placeholder thumb-2 has-mock-media" style={mock("scene-bedroom.png")}><span className="ph-frame">床头特写</span></div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>卧室床头</strong><span className="spacer"></span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>米白色床品,木质床头柜,闹钟显示晚间时间。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-subway">
|
||||
<div className="placeholder thumb-2">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px", alignItems: "center" }}>
|
||||
<div className="fail-icon">!</div>
|
||||
<span className="ph-frame">生成失败</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>通勤地铁</strong><span className="spacer"></span><span className="pill err"><span className="dot"></span>失败</span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>早高峰地铁车厢,光线偏冷,年轻通勤族,氛围紧张。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ 已确认 ¥0.85 · 待生成 ¥0.20 · 失败 ¥0(不扣) ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(1)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回脚本</button>
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(3)}>确认资产,进入故事板 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{viewStage === 3 && (
|
||||
<section className="stage active" data-stage-pane="3">
|
||||
<div className="stage-storyboard">
|
||||
<div className="sb-canvas">
|
||||
<div className="sb-scenes-col" id="sb-scenes-row">
|
||||
{SB_SCENES.map((scene) => (
|
||||
<div className={`sb-scene-thumb${scene.sid === "sc1" ? " selected" : ""}`} key={scene.sid} data-sid={scene.sid}>
|
||||
<div className="placeholder has-mock-media" style={mock(scene.img)}><span className="ph-frame">{scene.frame}</span></div>
|
||||
<div className="nm">{scene.nm}</div>
|
||||
<div className="sub">{scene.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="placeholder sb-main-img has-mock-media" id="sb-main-img" style={mock("cover-mask-v3.png")}><span className="ph-frame">场 1 · 深夜办公桌 · v1</span></div>
|
||||
</div>
|
||||
|
||||
<div className="sb-side">
|
||||
<div className="pane" style={{ padding: "18px" }}>
|
||||
<div className="hstack" style={{ marginBottom: "10px" }}>
|
||||
<strong style={{ fontSize: "14px" }}>故事板 · <span id="sb-side-scene">场 1</span></strong>
|
||||
<span className="spacer"></span>
|
||||
<span className="pill ok"><span className="dot"></span>已生成</span>
|
||||
</div>
|
||||
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}>整张故事板由 image-2 一次性输出,包含画面 + 镜头说明。</div>
|
||||
<div className="sb-rerun-note">
|
||||
<span className="warn-ic" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4M12 17h.01" /></svg>
|
||||
</span>
|
||||
<div className="note-copy"><strong>仅支持整张重跑</strong> · 不能局部改某一镜。如需调单镜,先在 <a href="#stage-1" onClick={(event) => { event.preventDefault(); goStage(1); }}>Stage 1 脚本</a> 改镜头描述,再回此处整张重跑。</div>
|
||||
</div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>// 本场提示词</div>
|
||||
<div className="prompt-edit" contentEditable suppressContentEditableWarning id="sb-prompt-edit">{SB_PROMPT}</div>
|
||||
<div className="sb-stage-actions">
|
||||
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => onGenerateStoryboard(storyboardPrompt)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><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" /></svg>
|
||||
整张重跑
|
||||
</button>
|
||||
<span className="spacer"></span>
|
||||
<span className="muted-2 mono" style={{ fontSize: "11px", alignSelf: "center" }}>~¥0.45/场</span>
|
||||
</div>
|
||||
<div className="sb-history">
|
||||
<div className="sb-history-h">// 历史版本(<span id="sb-history-ct">1</span>)</div>
|
||||
<div className="sb-history-row" id="sb-history-row">
|
||||
<div className="sb-history-thumb current" data-vi="0">
|
||||
<div className="placeholder has-mock-media" style={mock("cover-mask-final.png")}><span className="ph-frame">v1</span></div>
|
||||
<div className="ts">14:02</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider" style={{ marginTop: "16px" }}></div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 绑定的资产</div>
|
||||
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }} id="sb-bound-assets">
|
||||
<span className="asset-tag"><span className="dotc"></span>林夕(人物)</span>
|
||||
<span className="asset-tag"><span className="dotc"></span>深夜办公桌(场景)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ image-2 单场 ¥0.45 · 累计 ¥1.35 · 整张重跑,失败不扣 ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(2)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回资产</button>
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(4)}>确认故事板,开始生成视频 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{viewStage === 4 && (
|
||||
<section className="stage active" data-stage-pane="4">
|
||||
<div className="queue-bar">
|
||||
<div>
|
||||
<div style={{ fontSize: "14px", fontWeight: 600 }}>视频生成 · 3 / 3 完成</div>
|
||||
<div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 约 <span id="seedance-avg">15</span> 秒 · 已完成所有场次</div>
|
||||
</div>
|
||||
<div className="bar-wrap"><span style={{ width: "100%" }}></span></div>
|
||||
<span className="muted mono" style={{ fontSize: "12px" }}>100%</span>
|
||||
<button className="btn btn-sm" type="button" disabled={loading} onClick={() => onSubmitAllVideos(videoPrompt)}>↻ 全部重跑</button>
|
||||
<button className="btn btn-sm" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
|
||||
上传视频
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="video-grid" id="video-grid">
|
||||
{VIDEO_CARDS.map((card) => (
|
||||
<div className="video-card" key={card.vid} data-video-id={card.vid}>
|
||||
<div className="placeholder video-thumb has-mock-media" style={mock(card.img)}>
|
||||
<span className="ph-frame">{card.frame}</span>
|
||||
<div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>
|
||||
</div>
|
||||
<div className="body">
|
||||
<div className="video-card-head"><strong className="video-card-title">{card.title}</strong><span className="pill ok"><span className="dot"></span>完成</span></div>
|
||||
<div className="video-meta">{card.meta}</div>
|
||||
<div className="video-actions">
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-vstop>重跑</button>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-vstop>下载</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ 已完成 3 场 · 累计 ¥1.35 · 总时长 <span id="seedance-total">40</span>s · 失败不扣 · 通过后扣 ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(3)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回故事板</button>
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(5)}>确认视频,进入拼接 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{viewStage === 5 && (
|
||||
<section className="stage active" data-stage-pane="5">
|
||||
<div className="editor">
|
||||
<div className="editor-preview">
|
||||
<div className="canvas has-mock-media" id="ed-canvas" style={{ backgroundImage: "url(/exact/assets/mock/cover-mask-final.png)" }}><span id="ed-canvas-label">9:16 预览 · 1080×1920</span></div>
|
||||
<div className="controls">
|
||||
<button className="ctl-btn" type="button" id="ed-prev-btn" title="上一帧 (←)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3v10l4-5zM9 3v10l4-5z" fill="currentColor" /></svg></button>
|
||||
<button className="ctl-btn" type="button" id="ed-play-btn" title="播放 / 暂停 (空格)"><svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M5 4l7 4-7 4z" fill="currentColor" /></svg></button>
|
||||
<button className="ctl-btn" type="button" id="ed-next-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 className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">00:00.00</span> / <span id="ed-total-time">00:15.00</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-props">
|
||||
<div className="props-tabs"><div className="active">字幕</div><div>转场</div><div>BGM</div></div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 字幕样式</div>
|
||||
<div className="style-swatch">
|
||||
<div className="swatch-card selected"><div className="demo">真实分享</div><div className="nm">朴素白底</div></div>
|
||||
<div className="swatch-card"><div className="demo b">真实分享</div><div className="nm">影视黑底</div></div>
|
||||
<div className="swatch-card"><div className="demo c">真实分享</div><div className="nm">手写描边</div></div>
|
||||
<div className="swatch-card"><div className="demo d">真实分享</div><div className="nm">综艺暖黄</div></div>
|
||||
</div>
|
||||
<div className="divider"></div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 当前选中(<span id="ed-inspect-name">未选</span>)</div>
|
||||
<div className="props-row"><span className="k">起始</span><input className="input-mini" id="ed-inspect-start" defaultValue="—" /></div>
|
||||
<div className="props-row"><span className="k">时长</span><input className="input-mini" id="ed-inspect-dur" defaultValue="—" /></div>
|
||||
<div className="props-row"><span className="k">音量</span><input className="input-mini" defaultValue="100" /></div>
|
||||
<div className="props-row"><span className="k">速度</span><input className="input-mini" defaultValue="1.0x" /></div>
|
||||
<div className="props-row"><span className="k">入场</span><span className="mono" style={{ fontSize: "11.5px" }}>交叉淡化</span></div>
|
||||
<div className="divider"></div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// BGM</div>
|
||||
<div className="props-row" style={{ borderBottom: 0 }}><span style={{ fontSize: "12px", flex: 1 }}>温柔治愈钢琴 · 0:42</span><button className="btn btn-ghost btn-sm" type="button">替换</button></div>
|
||||
</div>
|
||||
|
||||
<div className="timeline" id="ed-timeline">
|
||||
<div className="tl-toolbar">
|
||||
<button className="tl-action" type="button" title="撤销"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-15-6.7L3 13" /></svg></button>
|
||||
<button className="tl-action" type="button" title="重做"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6" /><path d="M3 17a9 9 0 0 1 15-6.7L21 13" /></svg></button>
|
||||
<span className="tl-sep"></span>
|
||||
<button className="tl-action" type="button" title="在播放头处分割选中片段"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M20 4L8.12 15.88" /><path d="M14.47 14.48L20 20" /><path d="M8.12 8.12L12 12" /></svg>分割</button>
|
||||
<button className="tl-action" type="button" title="复制选中片段"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>复制</button>
|
||||
<button className="tl-action danger" type="button" title="删除选中片段 (Delete)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M19 6l-1.5 14a2 2 0 0 1-2 1.8H8.5a2 2 0 0 1-2-1.8L5 6" /></svg>删除</button>
|
||||
<span className="spacer"></span>
|
||||
<div className="tl-zoom"><span className="lbl">// zoom</span><input type="range" min={50} max={200} defaultValue={100} /></div>
|
||||
</div>
|
||||
|
||||
<div className="tl-ruler">
|
||||
<div className="l">// time</div>
|
||||
<div className="rule-track" id="ed-ruler">
|
||||
{ED_RULER.map((tick, i) => (
|
||||
<span className={`tick ${tick.major ? "major" : "minor"}`} key={i} style={{ left: tick.left }}>{tick.t && <span className="t">{tick.t}</span>}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tl-track video-track">
|
||||
<div className="label video"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" /><path d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5" /></svg></span>视频</div>
|
||||
<div className="lane" id="ed-lane-video" data-track="video">
|
||||
{edLayout(ED_VIDEO_CLIPS).map((clip) => (
|
||||
<div className="clip video" key={clip.n} data-track="video" data-label={clip.lbl} style={{ left: `${clip.leftPct}%`, width: `${clip.widthPct}%` }}>
|
||||
<span className="frames">{Array.from({ length: clip.dur + 1 }).map((_, i) => <span className="fr" key={i}></span>)}</span>
|
||||
<span className="num">{clip.n}</span><span className="lbl">{clip.lbl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tl-track subtitle-track">
|
||||
<div className="label subtitle"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7V4h16v3" /><path d="M9 20h6" /><path d="M12 4v16" /></svg></span>字幕</div>
|
||||
<div className="lane" id="ed-lane-subtitle" data-track="subtitle">
|
||||
{edLayout(ED_SUB_CLIPS).map((clip, i) => (
|
||||
<div className="clip subtitle" key={i} data-track="subtitle" data-label={clip.lbl} style={{ left: `${clip.leftPct}%`, width: `${clip.widthPct}%` }}><span className="lbl">{clip.lbl}</span></div>
|
||||
))}
|
||||
<div className="playhead" id="ed-playhead" style={{ left: "0%" }}><span className="ph-grab"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tl-track bgm-track">
|
||||
<div className="label bgm"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg></span>BGM</div>
|
||||
<div className="lane">
|
||||
<div className="clip bgm" data-track="bgm" data-label="温柔治愈钢琴" style={{ left: "0%", width: "100%" }}>
|
||||
<span className="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">{ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}</svg></span>
|
||||
<span className="lbl">温柔治愈钢琴 · 0:42(循环 1 次,淡入淡出)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ 合成预估 ~30s · 拼接 / 导出全程 0 token · 已结算 ¥1.39 ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(4)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回片段</button>
|
||||
<button className="btn" type="button">保存草稿</button>
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={onSubmitExport}>导出 MP4 · 1080P 9:16 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { ArrowLeft, Film, ImageIcon, Plus, Search, Settings, Trash2, Upload, Users, WandSparkles } from "lucide-react";
|
||||
import type { CSSProperties, FormEvent } from "react";
|
||||
import { ArrowLeft, Upload } from "lucide-react";
|
||||
import type { Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { statusPill } from "./stage-config";
|
||||
import { ConfirmModal, Drawer } from "../components/overlays";
|
||||
import { Drawer } from "../components/overlays";
|
||||
|
||||
type ProductPayload = {
|
||||
title?: string;
|
||||
@@ -16,17 +15,35 @@ type ProductPayload = {
|
||||
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
|
||||
};
|
||||
|
||||
// 复刻 mock-media productFor:按商品名关键词映射商品图
|
||||
function productCover(name: string): string {
|
||||
const t = name.replace(/\s+/g, "");
|
||||
if (/蓝牙|耳机|南卡/.test(t)) return "product-earbuds.png";
|
||||
if (/速食|牛肉面|泡面|面条/.test(t)) return "product-noodle.png";
|
||||
if (/防晒/.test(t)) return "product-sunscreen.png";
|
||||
if (/咖啡|冻干/.test(t)) return "product-coffee.png";
|
||||
if (/空气炸锅|小熊/.test(t)) return "product-air-fryer.png";
|
||||
if (/瑜伽裤|露露/.test(t)) return "product-yoga-pants.png";
|
||||
if (/收纳|北欧/.test(t)) return "product-storage.png";
|
||||
if (/面膜|补水|玻尿酸|透真/.test(t)) return "product-mask.png";
|
||||
return "";
|
||||
}
|
||||
const prodMock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
|
||||
|
||||
export function ProductsPage({ products, navigate, openProduct, onCreate }: {
|
||||
products: Product[];
|
||||
navigate: (page: Page) => void;
|
||||
openProduct: (productId: string) => void;
|
||||
onCreate: (payload: ProductPayload) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [drawer, setDrawer] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [brand, setBrand] = useState("");
|
||||
const [point, setPoint] = useState("");
|
||||
|
||||
const filtered = products.filter((product) => `${product.title} ${product.brand}`.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
onCreate({
|
||||
@@ -44,16 +61,45 @@ export function ProductsPage({ products, navigate, openProduct, onCreate }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="products-page">
|
||||
<div className="page-head">
|
||||
<div><h1>商品库</h1><div className="sub"><span className="mono">// {products.length} SKU</span> · 商品信息会作为脚本和资产生成素材</div></div>
|
||||
<div className="actions"><button className="btn" type="button" onClick={() => navigate("productCreateUpload")}><Upload size={13} />上传创建</button><button className="btn btn-primary" type="button" onClick={() => setDrawer(true)}><Plus size={13} />新建商品</button></div>
|
||||
<div>
|
||||
<h1>商品库</h1>
|
||||
<div className="sub"><span className="mono">// <span id="sku-count">{products.length}</span> SKU</span> · 商品信息会作为脚本和资产生成的素材</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-edit-toggle" type="button" id="edit-toggle-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
|
||||
<span className="btn-edit-label">管理商品</span>
|
||||
</button>
|
||||
<button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => setDrawer(true)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22V12" /><path d="M16 17h6" /><path d="M19 14v6" /><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="m7.5 4.3 9 5.1" /></svg>
|
||||
新建商品
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="toolbar"><div className="search-inline"><Search size={14} /><input className="input" placeholder="搜索商品名称、品牌" /></div><button className="chip active">全部 <span className="mono">{products.length}</span></button><button className="chip">美妆个护</button><button className="chip">数码 3C</button></div>
|
||||
<div className="product-grid">
|
||||
{products.map((product) => <ProductCard key={product.id} product={product} onOpen={() => openProduct(product.id)} />)}
|
||||
<button className="product-card add" type="button" onClick={() => navigate("productCreateUpload")}><div className="plus-ic"><Plus size={18} /></div><div>新建商品</div></button>
|
||||
|
||||
<div className="products-main">
|
||||
<div className="toolbar">
|
||||
<div className="search-inline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
<input className="input" id="search-input" placeholder="搜索商品名称、品牌" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
<div className="chip-wrap" data-key="cat"><button className="chip" type="button"><span className="chip-label">商品分类</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
|
||||
<div className="chip-wrap" data-key="date"><button className="chip" type="button"><span className="chip-label">创建时间</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
|
||||
</div>
|
||||
|
||||
<div className="result-meta" id="result-meta">
|
||||
<span>// 显示 <span className="count">{filtered.length}</span> / {products.length} 个商品</span>
|
||||
</div>
|
||||
|
||||
<div className="product-grid-wrap">
|
||||
<div className="product-grid" id="product-grid">
|
||||
{filtered.map((product) => <ProductCard key={product.id} product={product} onOpen={() => openProduct(product.id)} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer title="新建商品" open={drawer} close={() => setDrawer(false)}>
|
||||
<form onSubmit={submit}>
|
||||
<div className="field"><label className="field-label">商品名称<span className="req">*</span></label><input className="input" value={title} onChange={(event) => setTitle(event.target.value)} required /></div>
|
||||
@@ -62,16 +108,35 @@ export function ProductsPage({ products, navigate, openProduct, onCreate }: {
|
||||
<div className="drawer-actions"><button className="btn btn-ghost" type="button" onClick={() => setDrawer(false)}>取消</button><button className="btn btn-primary" type="submit">创建商品</button></div>
|
||||
</form>
|
||||
</Drawer>
|
||||
</>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductCard({ product, onOpen }: { product: Product; onOpen: () => void }) {
|
||||
const cover = productCover(product.title);
|
||||
const assetCount = product.images?.length || 0;
|
||||
return (
|
||||
<article className="product-card" role="button" tabIndex={0} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
|
||||
<div className="placeholder product-thumb"><span className="ph-frame">{product.title} · 1200×800</span></div>
|
||||
<div className="product-body"><div className="product-name">{product.title}</div><div className="product-meta">{product.category || "未分类"} · {product.selling_points.length} 个卖点</div><div className="product-tags">{(product.selling_points.length ? product.selling_points : [{ title: product.brand || "品牌" }]).slice(0, 3).map((tag) => <span className="tag-chip" key={tag.title}>{tag.title}</span>)}</div></div>
|
||||
</article>
|
||||
<div className="product-card" data-cat={product.category} data-name={product.title} role="button" tabIndex={0} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
|
||||
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
|
||||
<button className="card-del-btn" type="button" title="删除商品" onClick={(event) => event.stopPropagation()}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="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 className={`placeholder product-thumb${cover ? " has-mock-media" : ""}`} style={cover ? prodMock(cover) : undefined}><span className="ph-frame">{product.title} · 1200×800</span></div>
|
||||
<div className="product-body">
|
||||
<div className="product-name">{product.title}</div>
|
||||
<div className="product-cat">{product.category || "未分类"}</div>
|
||||
<div className="product-date">{(product.created_at || "").slice(0, 10)} 创建</div>
|
||||
</div>
|
||||
<div className="product-footer">
|
||||
<span className="stat">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="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>{assetCount}</b>
|
||||
</span>
|
||||
<span className="sep">·</span>
|
||||
<span className="stat">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,26 +165,300 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductDetailPage({ product, projects, navigate, onUpdate, onDelete }: {
|
||||
// 商品详情页 · 从 public/exact/product-detail.html 忠实转写。
|
||||
// 真实数据仅注入 api-bridge renderProductDetail 实际 hydrate 的 4 个字段
|
||||
// (名称 / 品类 / 目标人群 / 卖点),其余 图片/素材/项目 网格沿用设计稿镜像的
|
||||
// 静态占位(api-bridge 在 ?product_id 加载时同样保留 mock,故像素对齐)。
|
||||
const PD_ASSETS: Array<{ type: string; status: "pass" | "fail" | "archive" }> = [
|
||||
{ type: "模特上身图", status: "pass" },
|
||||
{ type: "模特上身图", status: "pass" },
|
||||
{ type: "模特上身图", status: "fail" },
|
||||
{ type: "模特上身图", status: "pass" },
|
||||
{ type: "模特上身图", status: "archive" },
|
||||
{ type: "平台套图", status: "pass" },
|
||||
{ type: "平台套图", status: "pass" },
|
||||
{ type: "平台套图", status: "fail" },
|
||||
{ type: "平台套图", status: "archive" },
|
||||
{ type: "平台套图", status: "pass" },
|
||||
{ type: "三视图", status: "pass" },
|
||||
{ type: "三视图", status: "archive" }
|
||||
];
|
||||
const PD_ASSET_STATUS_LABEL: Record<"pass" | "fail" | "archive", string> = { pass: "通过", fail: "不通过", archive: "归档" };
|
||||
|
||||
const PD_VIDEOS: Array<{ proj: string; pill: string; ver: string; label: string; date: string }> = [
|
||||
{ proj: "done", pill: "ok", ver: "补水面膜 · v3", label: "已完成", date: "2026-05-20 12:08" },
|
||||
{ proj: "wip", pill: "info", ver: "补水面膜 · v2", label: "视频生成 4/6", date: "2026-05-19 10:24" },
|
||||
{ proj: "archived", pill: "neutral", ver: "熬夜急救 · v1", label: "已归档", date: "2026-05-18 21:42" },
|
||||
{ proj: "fail", pill: "err", ver: "补水面膜 · v1", label: "故事板失败", date: "2026-05-17 16:00" }
|
||||
];
|
||||
|
||||
const PD_CAT_OPTIONS = ["美妆个护 / 精华液", "美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
|
||||
|
||||
export function ProductDetailPage({ product, navigate, onUpdate }: {
|
||||
product: Product;
|
||||
projects: Project[];
|
||||
navigate: (page: Page) => void;
|
||||
onUpdate: (payload: Partial<Product>) => Promise<unknown> | void;
|
||||
onDelete: () => Promise<unknown> | void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"assets" | "videos">("assets");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [title, setTitle] = useState(product.title);
|
||||
const [description, setDescription] = useState(product.description || "");
|
||||
const [triOpen, setTriOpen] = useState(false);
|
||||
// 素材状态筛选 · 镜像默认即「通过」(api-bridge ALWAYS_APPLY status),只显示通过卡
|
||||
const [assetStatus] = useState<"pass" | "fail" | "archive">("pass");
|
||||
const assetCount = PD_ASSETS.filter((asset) => asset.status === assetStatus).length;
|
||||
|
||||
// 真实字段 · 缺省时回退到设计稿镜像默认值(对齐 api-bridge setField 行为)
|
||||
const realName = product.title || "补水保湿精华液";
|
||||
const realCat = product.category || "美妆个护 / 精华液";
|
||||
const realTarget = product.target_audience || "22-32 岁女性、敏感肌、办公室通勤";
|
||||
const realBullets = product.selling_points.length
|
||||
? product.selling_points.map((point) => point.title)
|
||||
: ["透明质酸 + B5,敷完不黏不闷", "30g 大容量精华液", "0 香精 0 酒精,敏感肌可用"];
|
||||
|
||||
const [name, setName] = useState(realName);
|
||||
const [cat, setCat] = useState(realCat);
|
||||
const [target, setTarget] = useState(realTarget);
|
||||
|
||||
function save() {
|
||||
void onUpdate({ title: name, category: cat, target_audience: target });
|
||||
setEditing(false);
|
||||
}
|
||||
function cancel() {
|
||||
setName(realName);
|
||||
setCat(realCat);
|
||||
setTarget(realTarget);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>{product.title}</h1><div className="sub"><span className="mono">// product-detail</span> · 商品信息 / AI 素材 / 任务记录</div></div><div className="actions"><button className="btn" type="button" onClick={() => navigate("products")}><ArrowLeft size={13} />返回</button><button className="btn btn-primary" type="button" onClick={() => navigate("projectWizard")}><Film size={13} />创建视频项目</button></div></div>
|
||||
<div className="product-detail-layout">
|
||||
<section className="ov-card ov-main card-hard"><div className="detail-hero"><div className="placeholder detail-main-img"><span className="ph-frame">{product.title} · 主图</span></div><div className="detail-info"><div className="hstack"><span className="pill info"><span className="dot" />{product.category || "未分类"}</span><span className={`pill ${statusPill(product.status)}`}><span className="dot" />{product.status}</span><span className="spacer" /><button className="icon-btn-sm" type="button" onClick={() => setEditing(true)}><Settings size={13} /></button></div>{editing ? <><div className="field"><label className="field-label">商品名称</label><input className="input" value={title} onChange={(event) => setTitle(event.target.value)} /></div><div className="field"><label className="field-label">商品描述</label><textarea className="textarea" value={description} onChange={(event) => setDescription(event.target.value)} /></div><div className="hstack"><button className="btn" type="button" onClick={() => setEditing(false)}>取消</button><button className="btn btn-primary" type="button" onClick={() => { void onUpdate({ title, description }); setEditing(false); }}>保存</button></div></> : <><h2>{product.title}</h2><div className="muted">{product.brand || "未填写品牌"} · {product.target_audience || "未填写目标人群"}</div><p>{product.description || "暂无商品描述。"}</p></>}</div></div><div className="detail-section"><div className="section-h"><h2>商品卖点</h2><span className="more">[ {product.selling_points.length} POINTS ]</span></div><div className="bullet-grid">{product.selling_points.map((point) => <div className="bullet-card" key={point.id}><strong>{point.title}</strong><span>{point.detail || point.title}</span></div>)}</div></div></section>
|
||||
<aside className="detail-side"><div className="pane"><h3>AI 快捷动作</h3><div className="quick-actions"><button className="qa-item" type="button" onClick={() => navigate("modelPhoto")}><Users size={15} /><span>模特上身图</span></button><button className="qa-item" type="button" onClick={() => navigate("platformCover")}><ImageIcon size={15} /><span>平台套图</span></button><button className="qa-item" type="button" onClick={() => navigate("imageOptimize")}><WandSparkles size={15} /><span>图片创作</span></button></div></div><div className="pane"><h3>视频任务</h3>{projects.length ? projects.map((project) => <button className="task-mini" key={project.id} type="button" onClick={() => navigate("pipeline")}><span>{project.name}</span><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{project.status}</span></button>) : <div className="muted-2 mono">// 暂无关联视频项目</div>}</div><button className="btn danger-lite" type="button" onClick={() => setConfirmDelete(true)}><Trash2 size={13} />删除商品</button></aside>
|
||||
<section className="product-detail-page">
|
||||
|
||||
{/* 顶部 标题 + 状态 */}
|
||||
<div className="pd-title">
|
||||
<h1 id="pd-name">{realName}</h1>
|
||||
</div>
|
||||
<ConfirmModal open={confirmDelete} title="确认删除商品" detail={`即将删除 ${product.title},已有关联项目请先确认。`} confirmText="删除商品" onCancel={() => setConfirmDelete(false)} onConfirm={onDelete} />
|
||||
</>
|
||||
|
||||
{/* 商品信息(含图片) + 快速操作 · 主辅两栏 */}
|
||||
<div className="pd-overview">
|
||||
|
||||
<div className={`ov-card ov-main${editing ? " editing" : ""}`} id="ov-main-card">
|
||||
<div className="ov-h">
|
||||
<span className="ti">商品信息</span>
|
||||
{/* AI 生成三视图 · 按钮 + 弹出 panel(view 模式可见) */}
|
||||
<div className="ov-tri-wrap">
|
||||
<button className={`ov-edit ov-tri-trigger${triOpen ? " is-open" : ""}`} type="button" id="ov-tri-btn" title="AI 生成商品三视图" aria-haspopup="dialog" aria-expanded={triOpen} onClick={() => setTriOpen((value) => !value)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z" /><path d="M19 14l.9 2.1L22 17l-2.1.9L19 20l-.9-2.1L16 17l2.1-.9L19 14z" /></svg>
|
||||
AI 生成三视图
|
||||
</button>
|
||||
<div className={`ov-tri-pop${triOpen ? " show" : ""}`} id="ov-tri-pop" role="dialog" aria-label="AI 生成三视图">
|
||||
<button className="ov-tri-close" type="button" id="ov-tri-close" aria-label="关闭" onClick={() => setTriOpen(false)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
<div className="prod-preview-h">// 三视图预览 · <span id="ov-tri-status">待生成</span></div>
|
||||
<div className="placeholder prod-preview-img" id="ov-tri-img"><span className="ph-frame">// 尚未生成 · 点击下方按钮开始</span></div>
|
||||
<div className="prod-preview-foot" id="ov-tri-foot">
|
||||
<button className="ov-edit primary" type="button" id="ov-tri-start" style={{ height: "28px" }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z" /></svg>
|
||||
生成
|
||||
</button>
|
||||
<span style={{ flex: 1 }}></span>
|
||||
<span className="mono" style={{ fontSize: "11px", color: "var(--black-alpha-56)" }}>~¥0.30 / 次</span>
|
||||
</div>
|
||||
<div className="prod-preview-history" id="ov-tri-history">
|
||||
<div className="h-lbl">// 历史版本 · <span className="ct" id="ov-tri-history-count">0</span> 版</div>
|
||||
<div className="h-row" id="ov-tri-history-row"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* view 模式: 单个 [编辑信息] */}
|
||||
<button className="ov-edit ov-edit-single" type="button" id="ov-edit-btn" title="编辑商品信息" onClick={() => setEditing(true)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z" /></svg>
|
||||
编辑信息
|
||||
</button>
|
||||
{/* edit 模式: [重置] [取消] [保存] */}
|
||||
<div className="ov-edit-group">
|
||||
<button className="ov-edit" type="button" id="ov-reset-btn" title="重置为修改前" onClick={cancel}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5" /></svg>
|
||||
重置
|
||||
</button>
|
||||
<button className="ov-edit" type="button" id="ov-cancel-btn" onClick={cancel}>取消</button>
|
||||
<button className="ov-edit primary" type="button" id="ov-save-btn" onClick={save}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ov-main-grid">
|
||||
|
||||
<div className="ov-info">
|
||||
<div className="row" data-field="name">
|
||||
<div className="k">商品名称</div>
|
||||
<div className="v">
|
||||
<span className="v-static">{realName}</span>
|
||||
<input className="v-edit v-input" type="text" value={name} maxLength={100} onChange={(event) => setName(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" data-field="cat">
|
||||
<div className="k">品类</div>
|
||||
<div className="v">
|
||||
<span className="v-static">{realCat}</span>
|
||||
<select className="v-edit v-select" value={cat} onChange={(event) => setCat(event.target.value)}>
|
||||
{PD_CAT_OPTIONS.map((option) => <option key={option}>{option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" data-field="target">
|
||||
<div className="k">目标人群</div>
|
||||
<div className="v">
|
||||
<span className="v-static">{realTarget}</span>
|
||||
<input className="v-edit v-input" type="text" value={target} onChange={(event) => setTarget(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" data-field="bullets">
|
||||
<div className="k">核心卖点</div>
|
||||
<div className="v">
|
||||
<div className="v-static">
|
||||
{realBullets.map((bullet, index) => <span className="bullet" key={index}>{bullet}</span>)}
|
||||
</div>
|
||||
<ul className="v-edit v-bullet-list" id="v-bullets-list">
|
||||
{realBullets.map((bullet, index) => (
|
||||
<li className="bl-item" key={index}><span className="num">{index + 1}</span><span className="bl-text">{bullet}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ov-images-sub">
|
||||
<div className="sub-h">
|
||||
<span className="ti">商品图片</span>
|
||||
<span className="ct">(6)</span>
|
||||
</div>
|
||||
<div className="grid" id="ov-images-grid">
|
||||
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
|
||||
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
|
||||
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
|
||||
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
|
||||
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
|
||||
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
|
||||
<div className="img-upload" id="ov-img-add" title="上传图片">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ov-card ov-actions">
|
||||
<div className="ov-h"><span className="ti">快速操作</span></div>
|
||||
<div className="qa-section">
|
||||
<div className="qa-section-h">// 图片生成</div>
|
||||
<div className="qa-row-3">
|
||||
<div className="qa-item" data-go="model-photo" role="button" tabIndex={0} onClick={() => navigate("modelPhoto")}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 21v-2a4 4 0 014-4h8a4 4 0 014 4v2" /></svg></span>
|
||||
模特上身图
|
||||
</div>
|
||||
<div className="qa-item" data-go="platform-cover" role="button" tabIndex={0} onClick={() => navigate("platformCover")}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18M9 3v18" /></svg></span>
|
||||
平台套图
|
||||
</div>
|
||||
<div className="qa-item" data-go="image-optimize" role="button" tabIndex={0} onClick={() => navigate("imageOptimize")}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v18M3 12h18M5 5l14 14M5 19l14-14" /></svg></span>
|
||||
图片创作
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="qa-section">
|
||||
<div className="qa-section-h">// 视频生成</div>
|
||||
<div className="qa-row-1">
|
||||
<div className="qa-item primary" data-go="projects-new" role="button" tabIndex={0} onClick={() => navigate("projectWizard")}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg></span>
|
||||
生成视频
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="pd-tabs">
|
||||
<button className={`tab${tab === "assets" ? " active" : ""}`} type="button" data-tab="assets" onClick={() => setTab("assets")}>AI 生成素材</button>
|
||||
<button className={`tab${tab === "videos" ? " active" : ""}`} type="button" data-tab="videos" onClick={() => setTab("videos")}>视频项目</button>
|
||||
<button className="tab" type="button" data-tab="tasks" hidden>任务记录</button>
|
||||
</div>
|
||||
|
||||
{/* ===== AI 生成素材 ===== */}
|
||||
<div className={`tab-pane${tab === "assets" ? " active" : ""}`} data-pane="assets">
|
||||
|
||||
<div className="pd-toolbar">
|
||||
<div className="total">全部 AI 素材 <span className="ct">({assetCount})</span></div>
|
||||
<button className="filter" type="button" data-key="type">
|
||||
全部类型
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<button className="filter" type="button" data-key="status">
|
||||
通过
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="right">
|
||||
<div className="view-tog">
|
||||
<button type="button" className="active" title="网格视图">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></svg>
|
||||
</button>
|
||||
<button type="button" title="列表视图">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 6h18M3 12h18M3 18h18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button className="filter" type="button" data-key="sort">
|
||||
最新生成
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="asset-grid">
|
||||
{PD_ASSETS.map((asset, index) => {
|
||||
// 镜像 mock-media:平台套图 占位匹配 scene → scene-tabletop.png
|
||||
const hasMock = asset.type === "平台套图";
|
||||
return (
|
||||
<div className="asset-card" key={index} style={asset.status === assetStatus ? undefined : { display: "none" }}>
|
||||
<div className={`thumb placeholder${hasMock ? " has-mock-media" : ""}`} style={hasMock ? ({ "--mock-media-url": "url(/exact/assets/mock/scene-tabletop.png)" } as CSSProperties) : undefined}><span className="type-pill">{asset.type}</span><span className="ph-frame">3:4</span></div>
|
||||
<div className="meta"><span className={`pill ${asset.status}`} data-status={asset.status} title="点击切换状态">{PD_ASSET_STATUS_LABEL[asset.status]}</span><span className="date">2026-05-19 15:30</span></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pd-more"><button type="button">加载更多</button></div>
|
||||
</div>
|
||||
|
||||
{/* ===== 视频项目 ===== */}
|
||||
<div className={`tab-pane${tab === "videos" ? " active" : ""}`} data-pane="videos">
|
||||
<div className="pd-toolbar">
|
||||
<div className="total">该商品视频项目 <span className="ct">(4)</span></div>
|
||||
<div className="right">
|
||||
<button className="filter" type="button" data-key="sort">
|
||||
最新导出
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="asset-grid">
|
||||
{PD_VIDEOS.map((video, index) => (
|
||||
<div className="asset-card" data-proj-status={video.proj} key={index}>
|
||||
<div className="thumb placeholder" style={{ aspectRatio: "9/16" }}><span className="type-pill">视频 · 9:16</span><span className="ph-frame">{video.ver}</span></div>
|
||||
<div className="meta"><span className={`pill ${video.pill}`}><span className="dot"></span>{video.label}</span><span className="date">{video.date}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pd-more"><button type="button">加载更多</button></div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { ArrowLeft, ArrowRight, Grid2X2, List, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { CSSProperties, FormEvent } from "react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import type { Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { stageMeta, stageOrder, statusPill } from "./stage-config";
|
||||
import { ConfirmModal, EmptyPanel } from "../components/overlays";
|
||||
import { Progress } from "../components/pipeline-stage";
|
||||
|
||||
export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
products: Product[];
|
||||
@@ -37,6 +35,34 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
);
|
||||
}
|
||||
|
||||
// 对齐 api-bridge:阶段编号 / 状态分桶 / 友好标签 / pill 类
|
||||
const PROJ_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 5 };
|
||||
function projStageNo(project: Project) { return project.status === "completed" ? 5 : (PROJ_STAGE_NO[project.current_stage] || 1); }
|
||||
function projBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; }
|
||||
function projStatusLabel(project: Project) {
|
||||
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
|
||||
}
|
||||
function projPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "fail" : "info"; }
|
||||
function projDate(project: Project) { return (project.updated_at || "").slice(0, 10); }
|
||||
// 复刻 mock-media coverFor:按项目名关键词映射封面图(无匹配 → 占位)
|
||||
function projCover(name: string): string {
|
||||
const t = name.replace(/\s+/g, "");
|
||||
if (/蓝牙|耳机|南卡/.test(t)) return "cover-earbuds.png";
|
||||
if (/速食|牛肉面|泡面|面条/.test(t)) return "cover-noodle.png";
|
||||
if (/防晒/.test(t)) return "cover-sunscreen.png";
|
||||
if (/咖啡|冻干/.test(t)) return "cover-coffee.png";
|
||||
if (/空气炸锅|小熊/.test(t)) return "cover-air-fryer.png";
|
||||
if (/瑜伽裤|露露/.test(t)) return "cover-yoga.png";
|
||||
if (/v1|final|已完成|成片|敷面膜|化妆台/.test(t)) return "cover-mask-final.png";
|
||||
if (/面膜|补水|玻尿酸|透真/.test(t)) return "cover-mask-v3.png";
|
||||
return "";
|
||||
}
|
||||
const projMock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
|
||||
|
||||
const PROJ_TABS: Array<{ filter: "all" | "wip" | "done" | "fail"; label: string }> = [
|
||||
{ filter: "all", label: "全部" }, { filter: "wip", label: "进行中" }, { filter: "done", label: "已完成" }, { filter: "fail", label: "失败" }
|
||||
];
|
||||
|
||||
export function ProjectsPage({ products, projects, navigate, openPipeline, onDelete }: {
|
||||
products: Product[];
|
||||
projects: Project[];
|
||||
@@ -46,10 +72,21 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
onDelete: (projectId: string) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [view, setView] = useState<"list" | "grid">("list");
|
||||
const [tab, setTab] = useState<"all" | "wip" | "done" | "fail">("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
|
||||
const filtered = projects.filter((project) => `${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const counts = {
|
||||
all: projects.length,
|
||||
wip: projects.filter((p) => projBucket(p) === "wip").length,
|
||||
done: projects.filter((p) => projBucket(p) === "done").length,
|
||||
fail: projects.filter((p) => projBucket(p) === "fail").length
|
||||
};
|
||||
const filtered = projects.filter((project) => {
|
||||
if (tab !== "all" && projBucket(project) !== tab) return false;
|
||||
return `${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase());
|
||||
});
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
@@ -58,12 +95,122 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>视频项目</h1><div className="sub"><span className="mono">// {projects.length} 个 · {projects.filter((p) => p.status !== "completed").length} 进行中</span></div></div><div className="actions"><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate("projectWizard")}><Plus size={13} />新建视频项目</button></div></div>
|
||||
<div className="toolbar"><div className="search-inline"><Search size={14} /><input className="input" placeholder="搜索项目名称、商品" value={query} onChange={(event) => setQuery(event.target.value)} /></div><span className="spacer" /><div className="view-toggle"><button className={view === "list" ? "active" : ""} type="button" onClick={() => setView("list")}><List size={13} />列表</button><button className={view === "grid" ? "active" : ""} type="button" onClick={() => setView("grid")}><Grid2X2 size={13} />网格</button></div></div>
|
||||
{view === "list" ? <table className="t"><thead><tr><th>项目</th><th>商品</th><th>当前阶段</th><th>5 段进度</th><th>状态</th><th /></tr></thead><tbody>{filtered.map((project) => <tr key={project.id} onClick={() => openPipeline(project.id)}><td><div className="proj-name-cell"><div className="placeholder proj-thumb"><span className="ph-frame">9:16</span></div><div><div className="proj-name">{project.name}</div><div className="proj-sub">4 段 · 60s · AI 全生</div></div></div></td><td>{productTitle(project.product)}</td><td>{stageMeta[project.current_stage]?.label || project.current_stage}</td><td><div className="hstack"><Progress status={project.current_stage} /><span className="muted-2 mono">{Math.max(stageOrder.indexOf(project.current_stage as never) + 1, 1)}/5</span></div></td><td><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{project.status}</span></td><td><button className="icon-btn-sm" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><Trash2 size={13} /></button></td></tr>)}</tbody></table> : <div className="project-card-grid">{filtered.map((project) => <article className="proj-card" key={project.id} onClick={() => openPipeline(project.id)}><div className="placeholder proj-card-thumb"><span className="ph-frame">9:16</span></div><div className="proj-card-body"><strong>{project.name}</strong><div className="proj-sub">{productTitle(project.product)}</div><Progress status={project.current_stage} /></div></article>)}</div>}
|
||||
<section className="projects-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>视频项目</h1>
|
||||
<div className="sub"><span className="mono">// {counts.all} 个 · {counts.wip} 进行中 · {counts.done} 完成 · {counts.fail} 失败</span></div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" id="proj-manage-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
|
||||
<span className="proj-manage-label">管理项目</span>
|
||||
</button>
|
||||
<button className="btn btn-primary btn-lg btn-create" type="button" onClick={() => navigate("projectWizard")}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m12.3 3.5 3 4" /><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z" /><path d="m6.2 5.3 3.1 3.9" /><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /></svg>
|
||||
新建项目
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs" id="status-tabs">
|
||||
{PROJ_TABS.map((t) => (
|
||||
<div className={`tab${tab === t.filter ? " active" : ""}`} key={t.filter} data-filter={t.filter} onClick={() => setTab(t.filter)}>{t.label} <span className="count">{counts[t.filter]}</span></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<div className="search-inline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
<input className="input" id="search-input" placeholder="搜索项目名称、商品" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
<div className="chip-wrap" data-key="product"><button className="chip" type="button"><span className="chip-label">商品品类</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
|
||||
<div className="chip-wrap" data-key="source"><button className="chip" type="button"><span className="chip-label">脚本来源</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
|
||||
<div className="chip-wrap" data-key="time"><button className="chip" type="button"><span className="chip-label">创建时间</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
|
||||
<span className="spacer"></span>
|
||||
<div className="view-toggle">
|
||||
<button className={view === "grid" ? "active" : ""} type="button" data-view="grid" onClick={() => setView("grid")}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="2" width="5" height="5" /><rect x="9" y="2" width="5" height="5" /><rect x="2" y="9" width="5" height="5" /><rect x="9" y="9" width="5" height="5" /></svg>
|
||||
网格
|
||||
</button>
|
||||
<button className={view === "list" ? "active" : ""} type="button" data-view="list" onClick={() => setView("list")}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M2 4h12M2 8h12M2 12h12" /></svg>
|
||||
列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="result-meta" id="result-meta">// 显示 <span className="count">{filtered.length}</span> / {projects.length} 个项目</div>
|
||||
|
||||
{view === "list" ? (
|
||||
<div id="list-view">
|
||||
<table className="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">
|
||||
{filtered.map((project) => {
|
||||
const no = projStageNo(project);
|
||||
const shots = project.video_segments.length || 4;
|
||||
const cover = projCover(project.name);
|
||||
return (
|
||||
<tr key={project.id} data-status={projBucket(project)} data-name={project.name} onClick={() => openPipeline(project.id)}>
|
||||
<td>
|
||||
<div className="proj-name-cell">
|
||||
<div className={`placeholder proj-thumb${cover ? " has-mock-media" : ""}`} style={cover ? projMock(cover) : undefined}><span className="ph-frame">9:16</span></div>
|
||||
<div><div className="proj-name">{project.name}</div><div className="proj-sub">{shots} 镜 · 0-60s</div></div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{productTitle(project.product)}</td>
|
||||
<td><span className="muted">AI 全生</span></td>
|
||||
<td>
|
||||
<div className="hstack">
|
||||
<div className="prog">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={i < no ? "done" : i === no ? "cur" : ""} />)}</div>
|
||||
<span className="muted-2 mono" style={{ fontSize: "11px" }}>{no}/5</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span></td>
|
||||
<td className="muted-2">{projDate(project)}</td>
|
||||
<td>
|
||||
<div className="row-action">
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); event.stopPropagation(); openPipeline(project.id); }} title="继续"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor" /></svg></a>
|
||||
<span className="row-more" onClick={(event) => event.stopPropagation()}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg>
|
||||
<div className="row-more-tip"><button className="mi" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>删除项目</button></div>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="proj-grid">{filtered.map((project) => {
|
||||
const cover = projCover(project.name);
|
||||
return (
|
||||
<article className="proj-card" key={project.id} onClick={() => openPipeline(project.id)}>
|
||||
<div className={`placeholder card-thumb${cover ? " has-mock-media" : ""}`} style={cover ? projMock(cover) : undefined}><span className="ph-frame">9:16</span></div>
|
||||
<div className="card-body">
|
||||
<div className="card-name">{project.name}</div>
|
||||
<div className="card-sub">{productTitle(project.product)}</div>
|
||||
<div className="card-foot"><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span><span className="card-time">{projDate(project)}</span></div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}</div>
|
||||
)}
|
||||
{filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
|
||||
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}。`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
|
||||
</>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,214 @@
|
||||
import { useState } from "react";
|
||||
import { CircleDollarSign, KeyRound, Settings, UserPlus } from "lucide-react";
|
||||
import type { Team, TeamMember, User } from "../types";
|
||||
import { CircleDollarSign, UserPlus } from "lucide-react";
|
||||
import type { BillingSummary, Team, TeamMember, User } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { money, statusPill } from "./stage-config";
|
||||
import { money } from "./stage-config";
|
||||
import { TeamModal } from "../components/overlays";
|
||||
|
||||
export function TeamPage({ team, user, members, navigate }: { team: Team; user: User; members: TeamMember[]; navigate: (page: Page) => void }) {
|
||||
// 角色 → pill key/label(对齐 api-bridge roleUi)
|
||||
function roleUi(role: string): { key: "super" | "admin" | "member"; label: string } {
|
||||
if (role === "owner" || role === "super") return { key: "super", label: "超管" };
|
||||
if (role === "admin") return { key: "admin", label: "团管" };
|
||||
if (role === "viewer") return { key: "member", label: "访客" };
|
||||
return { key: "member", label: "成员" };
|
||||
}
|
||||
|
||||
const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: boolean }> = [
|
||||
{ cap: "邀请 / 移除成员", cells: ["✓", "✓", "—"] },
|
||||
{ cap: "设置成员额度", cells: ["✓", "✓", "—"] },
|
||||
{ cap: "团队充值", cells: ["✓", "—", "—"] },
|
||||
{ cap: "设置月限额", cells: ["✓", "—", "—"] },
|
||||
{ cap: "编辑别人项目", cells: ["✓", "✓", "—"] },
|
||||
{ cap: "团队共享资产库管理", cells: ["✓", "✓", "仅自传"] },
|
||||
{ cap: "查看团队消费明细", cells: ["✓", "✓", "仅自己"] },
|
||||
{ cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true }
|
||||
];
|
||||
|
||||
export function TeamPage({ team, user, members, billing, navigate }: {
|
||||
team: Team;
|
||||
user: User;
|
||||
members: TeamMember[];
|
||||
billing: BillingSummary | null;
|
||||
navigate: (page: Page) => void;
|
||||
}) {
|
||||
const [modal, setModal] = useState<"" | "invite" | "limit">("");
|
||||
const rows = members.length ? members : [{ id: "owner", role: "owner", status: "active", monthly_credit_limit: "3000.00", user } as TeamMember];
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const rows: TeamMember[] = members.length
|
||||
? members
|
||||
: [{ id: "owner", role: "owner", status: "active", monthly_credit_limit: "0", user } as TeamMember];
|
||||
|
||||
// 统计 · 对齐 api-bridge renderLiveTeamPayload
|
||||
const balance = Number(billing?.account.balance || 0);
|
||||
const used = Number(billing?.charged_total || 0);
|
||||
const limit = rows.reduce((sum, member) => sum + Math.max(0, Number(member.monthly_credit_limit || 0)), 0) || balance;
|
||||
const left = Math.max(0, limit - used);
|
||||
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
|
||||
|
||||
const needle = search.trim().toLowerCase();
|
||||
const list = rows.filter((member) => {
|
||||
const name = member.user.username || "";
|
||||
const email = member.user.email || "";
|
||||
return !needle || `${name} ${email}`.toLowerCase().includes(needle);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>团队管理</h1><div className="sub"><span className="mono">// 成员 · 角色 · 额度 · 共享资产库</span></div></div><button className="btn btn-primary" type="button" onClick={() => setModal("invite")}><UserPlus size={13} />创建账户</button></div>
|
||||
<div className="team-top"><div className="team-banner"><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="banner-head"><div className="banner-id"><div className="lbl">[ TEAM ]</div><div className="nm">{team.name} <span className="tag">企业</span></div><div className="meta">// 团队 ID: {team.id.slice(0, 8)} · {rows.length} 名成员</div></div><div className="banner-actions"><button className="btn btn-sm" type="button" onClick={() => navigate("account")}>充值</button><button className="btn btn-ghost btn-sm" type="button" onClick={() => setModal("limit")}>设置月限额</button></div></div></div></div>
|
||||
<div className="team-grid"><div className="pane" style={{ padding: 0 }}><div className="pane-table-h"><h3>成员列表 <span className="ct">// {rows.length} 人</span></h3><input className="input" placeholder="搜索姓名 / 手机号" /></div><table className="t members-table"><thead><tr><th>成员</th><th>角色</th><th>月度额度</th><th>状态</th><th /></tr></thead><tbody>{rows.map((member) => <tr key={member.id}><td><div className="proj-name-cell"><div className="av">{member.user.username.slice(0, 1).toUpperCase()}</div><div><div className="proj-name">{member.user.username}</div><div className="proj-sub">{member.user.email || "未填写邮箱"}</div></div></div></td><td><span className="pill info"><span className="dot" />{member.role}</span></td><td>{money(member.monthly_credit_limit)}</td><td><span className={`pill ${statusPill(member.status)}`}><span className="dot" />{member.status}</span></td><td><button className="icon-btn-sm" type="button"><Settings size={13} /></button><button className="icon-btn-sm" type="button"><KeyRound size={13} /></button></td></tr>)}</tbody></table></div></div>
|
||||
<section className="team-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>团队管理</h1>
|
||||
<div className="sub"><span className="mono">// 成员 · 角色 · 额度 · 共享资产库</span></div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary" type="button" id="open-invite" onClick={() => setModal("invite")}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="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>
|
||||
创建账户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 顶部行:团队 banner(左)+ 团队动态(右) */}
|
||||
<div className="team-top">
|
||||
|
||||
<div className="team-banner">
|
||||
<span className="corner-tr" aria-hidden></span><span className="corner-bl" aria-hidden></span>
|
||||
|
||||
<div className="banner-head">
|
||||
<div className="banner-id">
|
||||
<div className="lbl">[ TEAM ]</div>
|
||||
<div className="nm">{team.name} <span className="tag">企业</span></div>
|
||||
<div className="meta">// 团队 ID: {team.id} · {rows.length} 名成员</div>
|
||||
</div>
|
||||
<div className="banner-actions">
|
||||
<button className="btn btn-sm" type="button" onClick={() => navigate("account")}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="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>
|
||||
充值
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" type="button" id="open-limit" onClick={() => setModal("limit")}>设置月限额</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="banner-divider"></div>
|
||||
|
||||
<div className="banner-stats">
|
||||
<div className="stat">
|
||||
<div className="lbl">[ 充值余额 ]</div>
|
||||
<div className="v">{money(balance)}</div>
|
||||
<div className="sub">// 团队总池</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="lbl">[ 月限额 ]</div>
|
||||
<div className="v" id="stat-limit">{money(limit)}</div>
|
||||
<div className="sub">// 自然月重置</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="lbl">[ 当月已用 ]</div>
|
||||
<div className="v" id="stat-used">{money(used)}</div>
|
||||
<div className="sub" id="stat-used-sub">// 占月限 {pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="lbl">[ 当月剩余 ]</div>
|
||||
<div className="v warn" id="stat-left">{money(left)}</div>
|
||||
<div className="sub" id="stat-left-sub">// 还可生成约 {Math.max(0, Math.round(left / 10))} 个项目</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 团队动态(banner 右栏)· 对齐 api-bridge renderLiveTeamActivity 的真实状态占位 */}
|
||||
<div className="team-feed">
|
||||
<div className="h">
|
||||
<h3>团队动态</h3>
|
||||
<span className="ct">// 真实动态接口待接入</span>
|
||||
<a className="more" id="open-feed-all" role="button" tabIndex={0}>全部 →</a>
|
||||
</div>
|
||||
<div className="feed-list">
|
||||
<div className="feed-item">
|
||||
<div className="av">Q</div>
|
||||
<div>
|
||||
<div className="txt"><span className="who">真实团队表</span><span className="act">已同步</span><span className="obj">{rows.length} 名成员</span></div>
|
||||
<div className="ts">local cache</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{/* /.team-top */}
|
||||
|
||||
<div className="team-grid">
|
||||
{/* 左:成员表 */}
|
||||
<div>
|
||||
<div className="pane" style={{ padding: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", padding: "16px 20px", borderBottom: "1px solid var(--border-faint)" }}>
|
||||
<h3 style={{ margin: 0 }}>成员列表 <span className="ct">// {list.length} / {rows.length} 人 · 真实团队表</span></h3>
|
||||
<span className="spacer"></span>
|
||||
<input className="input" id="member-search" placeholder="搜索姓名 / 手机号" style={{ height: "32px", fontSize: "12px", width: "220px" }} value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||
</div>
|
||||
<table className="t members-table" style={{ border: 0, borderRadius: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>成员</th>
|
||||
<th>角色</th>
|
||||
<th>每日额度</th>
|
||||
<th>月度额度</th>
|
||||
<th style={{ width: "140px" }}>当月已用</th>
|
||||
<th style={{ textAlign: "right", width: "88px" }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="members-tbody">
|
||||
{list.map((member) => {
|
||||
const name = member.user.username || member.user.email || "成员";
|
||||
const role = roleUi(member.role);
|
||||
const monthly = Number(member.monthly_credit_limit || 0);
|
||||
const memberPct = monthly > 0 ? Math.min(100, (0 / monthly) * 100) : 0;
|
||||
const isOwner = member.role === "owner";
|
||||
return (
|
||||
<tr key={member.id} data-id={member.id}>
|
||||
<td><span className="member-cell"><span className="av">{name.slice(0, 1).toUpperCase()}</span><span><span className="nm">{name}</span><span className="em">{member.user.email || ""}</span></span></span></td>
|
||||
<td><span className={`role-pill role-${role.key}`}><span className="dot"></span>{role.label}</span></td>
|
||||
<td><span className="quota-cell"><span className="v">不限</span></span></td>
|
||||
<td><span className="quota-cell"><span className="v">{monthly > 0 ? money(monthly) : "不限"}</span></span></td>
|
||||
<td><div className="quota-cell"><span className="v">¥0.00</span> <span className="lbl">/ {memberPct.toFixed(0)}%</span></div><div className="used-bar"><span className="ok" style={{ width: `${memberPct.toFixed(0)}%` }}></span></div></td>
|
||||
<td><div className="acts">{isOwner
|
||||
? <span style={{ fontFamily: "var(--font-mono)", fontSize: "10.5px", color: "var(--black-alpha-32)", alignSelf: "center" }}>不可编辑</span>
|
||||
: <>
|
||||
<button className="icon-btn-sm" type="button" title="编辑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" /></svg></button>
|
||||
<button className="icon-btn-sm" type="button" title="重置密码"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></button>
|
||||
<button className="icon-btn-sm danger" type="button" title="移出"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
|
||||
</>}</div></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右:权限矩阵 */}
|
||||
<div>
|
||||
<div className="pane">
|
||||
<h3>角色权限</h3>
|
||||
<div style={{ fontSize: "12px", color: "var(--black-alpha-48)", marginTop: "-10px", marginBottom: "12px", fontFamily: "var(--font-mono)", letterSpacing: ".02em" }}>// PRD §10.2 权限矩阵节选</div>
|
||||
<table className="perm-table">
|
||||
<thead>
|
||||
<tr><th>能力</th><th>超管</th><th>团管</th><th>成员</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PERM_ROWS.map((row) => (
|
||||
<tr key={row.cap} style={row.last ? { borderBottom: 0 } : undefined}>
|
||||
<td>{row.cap}</td>
|
||||
{row.cells.map((cell, index) => (
|
||||
<td key={index} className={cell === "✓" ? "yes" : "no"}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TeamModal open={modal === "limit"} title="设置月限额" subtitle="// 自然月重置 · 仅超管可改" icon={<CircleDollarSign size={16} />} close={() => setModal("")}><div className="field"><label className="field-label">月限额 ¥</label><input className="input" defaultValue="3000" /></div></TeamModal>
|
||||
<TeamModal open={modal === "invite"} title="创建账户" subtitle="// 直接生成账号 · 分享给成员登录" icon={<UserPlus size={16} />} close={() => setModal("")}><div className="field"><label className="field-label">用户名</label><input className="input" defaultValue="zhang.yunying" /></div><div className="field"><label className="field-label">登录密码</label><input className="input mono" defaultValue="AirShelf2026" /></div></TeamModal>
|
||||
</>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user