feat: add AirShelf core implementation
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import type { BillingSummary, Ledger, Project, TeamMember } from "../types";
|
||||
import { money, stageMeta, statusPill } from "./stage-config";
|
||||
|
||||
export function AccountPage({ billing, ledgers, projects, teamMembers }: {
|
||||
billing: BillingSummary | null;
|
||||
ledgers: Ledger[];
|
||||
projects: Project[];
|
||||
teamMembers: TeamMember[];
|
||||
}) {
|
||||
const [tab, setTab] = useState<"overview" | "projects" | "members" | "bills">("overview");
|
||||
const [amount, setAmount] = useState(500);
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>消费</h1><div className="sub"><span className="mono">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div></div></div>
|
||||
<div className="top-grid"><div className="balance-banner"><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="balance-hero"><div className="lbl">团队余额</div><div className="v">{money(billing?.account.balance)}</div><div className="meta">// 冻结 {money(billing?.account.reserved_balance)} · 已消费 {money(billing?.charged_total)}</div></div></div><div className="pane topup-pane"><div className="topup-head"><div><h3>快速充值</h3><div className="desc">// 充值后立刻到账,可开发票</div></div><div className="topup-selected">已选 ¥{amount}</div></div><div className="recharge-row">{[100, 500, 1000, 3000].map((item) => <button className={`recharge-card ${amount === item ? "selected" : ""}`} type="button" key={item} onClick={() => setAmount(item)}><div className="amt">¥{item}</div><div className="gift">+ 赠送</div></button>)}</div><button className="btn pay-method-btn" type="button"><CreditCard size={13} />微信支付</button></div></div>
|
||||
<div className="billing-tabs">{[["overview", "总览"], ["projects", "项目"], ["members", "成员"], ["bills", "账单流水"]].map(([key, label]) => <button className={`tab ${tab === key ? "active" : ""}`} type="button" key={key} onClick={() => setTab(key as typeof tab)}>{label}</button>)}</div>
|
||||
{tab === "overview" && <div className="overview-grid"><div className="pane trend-pane"><h3>消费趋势</h3><div className="bars">{Array.from({ length: 14 }).map((_, index) => <span key={index} style={{ height: `${18 + (index % 6) * 10}%` }} />)}</div></div><div className="pane stage-pane"><h3>本月按阶段分布</h3><UsageLine label="视频片段" value="¥98.40" width="60%" color="var(--orange)" /><UsageLine label="故事板" value="¥36.00" width="22%" color="var(--green)" /></div></div>}
|
||||
{tab === "projects" && <table className="billing-table"><thead><tr><th>项目</th><th>当前阶段</th><th>状态</th><th>消耗</th></tr></thead><tbody>{projects.map((project) => <tr key={project.id}><td>{project.name}</td><td>{stageMeta[project.current_stage]?.label || project.current_stage}</td><td><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{project.status}</span></td><td>¥0.00</td></tr>)}</tbody></table>}
|
||||
{tab === "members" && <table className="billing-table"><thead><tr><th>成员</th><th>角色</th><th>额度</th><th>状态</th></tr></thead><tbody>{teamMembers.map((member) => <tr key={member.id}><td>{member.user.username}</td><td>{member.role}</td><td>{money(member.monthly_credit_limit)}</td><td><span className={`pill ${statusPill(member.status)}`}><span className="dot" />{member.status}</span></td></tr>)}</tbody></table>}
|
||||
{tab === "bills" && <table className="billing-table bills"><thead><tr><th>时间</th><th>类型</th><th>详情</th><th>金额</th></tr></thead><tbody>{ledgers.map((item) => <tr key={item.id}><td>{new Date(item.created_at).toLocaleString("zh-CN")}</td><td>{item.ledger_type}</td><td>{item.reason}</td><td>{item.amount}</td></tr>)}</tbody></table>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsageLine({ label, value, width, color }: { label: string; value: string; width: string; color: string }) {
|
||||
return <><div className="usage-line"><span>{label}</span><span className="v">{value}</span></div><div className="usage-bar"><span style={{ width, background: color }} /></div></>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, Check, Grid2X2, List, RefreshCw, Search, WandSparkles } from "lucide-react";
|
||||
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { statusPill } from "./stage-config";
|
||||
|
||||
export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) {
|
||||
const cards = [
|
||||
{ page: "modelPhoto" as Page, tag: "[ MODEL · TRY-ON ]", title: "模特上身图", desc: "选择模特,AI 生成商品模特上身效果图", cost: "≈ ¥0.30 / 张" },
|
||||
{ page: "platformCover" as Page, tag: "[ PLATFORM · KIT ]", title: "平台套图", desc: "选择平台模板,AI 生成电商平台套图", cost: "≈ ¥0.50 / 张" },
|
||||
{ page: "imageOptimize" as Page, tag: "[ IMAGE · STUDIO ]", title: "图片创作", desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写", cost: "≈ ¥0.40 / 组" }
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>图片生成</h1><div className="sub"><span className="mono">// 一键生成</span><span>·</span><span>电商视觉素材,提升内容制作效率</span></div></div></div>
|
||||
<div className="factory-hero">{cards.map((card) => <article className="factory-card with-corners" key={card.title}><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="factory-body"><div className="factory-text"><span className="factory-tag">{card.tag}</span><div className="factory-title">{card.title}</div><div className="factory-desc">{card.desc}</div><div className="factory-cta"><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate(card.page)}>开始生成<ArrowRight size={13} /></button><span className="cost">[ {card.cost} ]</span></div></div><div className="factory-visual"><div className="placeholder main"><span className="ph-frame">{card.title}</span></div></div></div></article>)}</div>
|
||||
<div className="section-h"><h2>任务中心</h2><span className="more">// {aiTasks.length} 个真实 AI 任务</span></div>
|
||||
<div className="toolbar"><div className="search-inline"><Search size={14} /><input className="input" placeholder="搜索任务名" /></div><span className="spacer" /><div className="view-toggle"><button type="button" className="active"><List size={13} />列表</button><button type="button"><Grid2X2 size={13} />网格</button></div></div>
|
||||
<table className="t"><thead><tr><th>任务</th><th>类型</th><th>状态</th><th /></tr></thead><tbody>{aiTasks.slice(0, 8).map((task) => <tr key={task.id}><td>{task.id.slice(0, 8)}</td><td>{task.task_type}</td><td><span className={`pill ${statusPill(task.status)}`}><span className="dot" />{task.status}</span></td><td /></tr>)}</tbody></table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageWorkbenchPage({ mode, products, assets, modelConfigs, onBack, navigate }: {
|
||||
mode: "image" | "model" | "cover";
|
||||
products: Product[];
|
||||
assets: Asset[];
|
||||
modelConfigs: ModelConfig[];
|
||||
onBack: () => void;
|
||||
navigate?: (page: Page) => void;
|
||||
}) {
|
||||
const meta = {
|
||||
image: { title: "图片创作", tag: "[ IMAGE · STUDIO ]", desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写。", ratio: "1:1" },
|
||||
model: { title: "模特上身图", tag: "[ MODEL · TRY-ON ]", desc: "选择模特和商品,生成电商模特上身图。", ratio: "3:4" },
|
||||
cover: { title: "平台套图", tag: "[ PLATFORM · KIT ]", desc: "选择平台模板,一键生成主图 / 封面 / 详情套图。", ratio: "4:5" }
|
||||
}[mode];
|
||||
const [productId, setProductId] = useState(products[0]?.id || "");
|
||||
const [prompt, setPrompt] = useState(`${products[0]?.title || "商品"},电商高转化视觉,干净背景,商品主体清晰`);
|
||||
const product = products.find((item) => item.id === productId) || products[0];
|
||||
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
|
||||
|
||||
useEffect(() => {
|
||||
if (product) setPrompt(`${product.title},电商高转化视觉,干净背景,商品主体清晰`);
|
||||
}, [productId]);
|
||||
|
||||
return (
|
||||
<div className="tool-shell">
|
||||
<div className="tool-topbar"><button className="back-pill" type="button" onClick={onBack}><ArrowLeft size={14} />返回</button><div><h1>{meta.title}</h1><div className="sub"><span className="mono">{meta.tag}</span> {meta.desc}</div></div><span className="spacer" />{mode === "model" && navigate && <button className="btn" type="button" onClick={() => navigate("modelPhotoDemoA")}>方案 A</button>}<button className="btn btn-primary" type="button"><WandSparkles size={13} />生成图片</button></div>
|
||||
<div className="tool-layout"><aside className="tool-rail"><div className="rail-h">商品空间</div>{products.slice(0, 8).map((item) => <button className={`rail-product ${productId === item.id ? "active" : ""}`} type="button" key={item.id} onClick={() => setProductId(item.id)}><div className="placeholder"><span className="ph-frame">{item.title.slice(0, 4)}</span></div><span>{item.title}</span></button>)}</aside><section className="tool-canvas"><div className="tool-toolbar"><button className="chip active">全部</button><span className="spacer" /><span className="mono muted-2">{imageModels[0]?.display_name || "Volcano Image"}</span></div><div className="result-board">{Array.from({ length: 4 }).map((_, index) => <button className="result-tile" type="button" key={index}><div className="placeholder"><span className="ph-frame">{meta.ratio} · v{index + 1}</span></div><div className="result-meta-row"><span>候选 {index + 1}</span><span className="pill info"><span className="dot" />待采用</span></div></button>)}</div><div className="candidate-actions"><button className="btn" type="button"><RefreshCw size={13} />重跑</button><button className="btn btn-primary" type="button"><Check size={13} />采用并入资产库</button></div></section><aside className="tool-side"><div className="pane"><h3>生成参数</h3><textarea className="textarea" value={prompt} onChange={(event) => setPrompt(event.target.value)} /></div><div className="pane"><h3>资产库</h3><div className="asset-mini-grid">{assets.slice(0, 6).map((asset) => <div className="placeholder" key={asset.id}><span className="ph-frame">{asset.asset_type}</span></div>)}</div></div></aside></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A" | "B"; products: Product[]; onBack: () => void }) {
|
||||
return <><div className="page-head"><div><h1>模特图方案 {variant}</h1><div className="sub"><span className="mono">// model-photo-demo-{variant.toLowerCase()}</span> · 原型对比页补齐</div></div><div className="actions"><button className="btn" type="button" onClick={onBack}><ArrowLeft size={13} />返回模特图</button></div></div><div className={`demo-layout demo-${variant.toLowerCase()}`}><aside className="tool-rail"><div className="rail-h">商品空间</div>{products.slice(0, 6).map((product) => <button className="rail-product" type="button" key={product.id}><div className="placeholder"><span className="ph-frame">{product.title.slice(0, 4)}</span></div><span>{product.title}</span></button>)}</aside><section className="pane"><div className="pane-h"><strong>方案 {variant}</strong><span className="spacer" /><span className="mono muted-2">DEMO</span></div><div className="result-board dense">{Array.from({ length: 6 }).map((_, index) => <div className="result-tile" key={index}><div className="placeholder"><span className="ph-frame">MODEL {index + 1}</span></div></div>)}</div></section></div></>;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { api } from "../api";
|
||||
import type { Team, User } from "../types";
|
||||
import type { AuthMode } from "./route-config";
|
||||
|
||||
export function AuthScreen({
|
||||
initialMode,
|
||||
onModeChange,
|
||||
onAuthed
|
||||
}: {
|
||||
initialMode: AuthMode;
|
||||
onModeChange: (mode: AuthMode) => void;
|
||||
onAuthed: (payload: { token: string; user: User; team: Team }) => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<AuthMode>(initialMode);
|
||||
const [username, setUsername] = useState("li@shop.com");
|
||||
const [password, setPassword] = useState("demo-1234");
|
||||
const [teamName, setTeamName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [registerPassword, setRegisterPassword] = useState("");
|
||||
const [registerPassword2, setRegisterPassword2] = useState("");
|
||||
const [agreed, setAgreed] = useState(true);
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => setMode(initialMode), [initialMode]);
|
||||
useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []);
|
||||
|
||||
function showToast(title: string, sub: string) {
|
||||
setToast({ title, sub });
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||
toastTimer.current = setTimeout(() => setToast(null), 2800);
|
||||
}
|
||||
|
||||
function switchMode(next: AuthMode) {
|
||||
setMode(next);
|
||||
setError("");
|
||||
onModeChange(next);
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (mode === "register") {
|
||||
if (registerPassword.length < 8) throw new Error("密码至少 8 位");
|
||||
if (registerPassword !== registerPassword2) throw new Error("两次密码不一致");
|
||||
if (!agreed) throw new Error("请同意用户协议");
|
||||
}
|
||||
const payload =
|
||||
mode === "login"
|
||||
? await api.login({ username, password })
|
||||
: await api.register({
|
||||
username: email,
|
||||
email,
|
||||
password: registerPassword,
|
||||
team_name: teamName
|
||||
});
|
||||
onAuthed(payload);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "登录失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={`auth-exact-page ${mode === "register" ? "auth-register-page" : "auth-login-page"}`}>
|
||||
<a className="top-back" href={mode === "register" ? "/login" : "/dashboard"} onClick={(event) => { if (mode === "register") { event.preventDefault(); switchMode("login"); } }}>
|
||||
{mode === "register" ? "← 返回登录" : "← 返回工作台"}
|
||||
</a>
|
||||
<div className="auth-wrap">
|
||||
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
|
||||
<aside className="auth-brand">
|
||||
<div className="logo"><img className="logo-img" src="/assets/logo-dark.png" alt="Airshelf" /></div>
|
||||
<div className="tag">// SHORT-VIDEO COMMERCE PLATFORM</div>
|
||||
<div className="hero">
|
||||
{mode === "login" ? <><h1>AI 全流程<br /><span className="h">短剧化</span>带货生成</h1><p>商品 → AI 脚本 → 故事板 → Seedance 视频片段 → 一键导出 9:16 ≤60s 成片。</p></> : <><h1>开通团队,<br />开始 <span className="h">AI 带货</span> 第一条短剧</h1><p>个人 / 企业团队 1-2 小时出成片,失败不扣费,确认后扣。</p></>}
|
||||
</div>
|
||||
<div className="ascii">
|
||||
<div className="ln"><span className="k">// step·1</span> 脚本生成 <span className="v">●●●●●</span></div>
|
||||
<div className="ln"><span className="k">// step·2</span> 基础资产 <span className="v">●●●●○</span></div>
|
||||
<div className="ln"><span className="k">// step·3</span> 故事板 <span className="v">●●●○○</span></div>
|
||||
<div className="ln"><span className="k">// step·4</span> 视频片段 <span className="v">●●○○○</span></div>
|
||||
<div className="ln"><span className="k">// step·5</span> 拼接导出 <span className="v">●○○○○</span></div>
|
||||
</div>
|
||||
<div className="foot"><a href="#">关于</a><a href="#">定价</a><a href="#">联系</a><a href="#">隐私</a></div>
|
||||
</aside>
|
||||
<main className="auth-form">
|
||||
<div className="h-row"><h2>{mode === "login" ? "登录" : "注册团队"}</h2><span className="sub">{mode === "login" ? "// /auth/login" : "// /auth/register"}</span></div>
|
||||
<p className="lead">{mode === "login" ? "使用团队邀请邮箱登录,接受邀请后自动加入对应团队。" : "填写团队信息开通账户,默认成为团队超管。"}</p>
|
||||
<form id={mode === "login" ? "login-form" : "register-form"} autoComplete="off" onSubmit={submit}>
|
||||
{mode === "login" ? (
|
||||
<>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="auth-email">邮箱 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" /><path d="m22 6-10 7L2 6" /></svg>
|
||||
<input type="email" id="auth-email" placeholder="name@company.com" value={username} onChange={(event) => setUsername(event.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="auth-pwd">密码 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||
<input type={showPwd ? "text" : "password"} id="auth-pwd" placeholder="••••••••" value={password} onChange={(event) => setPassword(event.target.value)} required />
|
||||
<button type="button" className="toggle-pwd" aria-label="切换密码可见" onClick={() => setShowPwd((value) => !value)}>
|
||||
<svg id="pwd-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z" /><circle cx="12" cy="12" r="3" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<label><input type="checkbox" defaultChecked /> 记住我 7 天</label>
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("已发送重置邮件", "请到 li@shop.com 收件箱查看 · 链接 30 分钟有效"); }}>忘记密码?</a>
|
||||
</div>
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<button className="btn-cta" type="submit" disabled={busy}>
|
||||
{busy ? <span className="busy-copy">// 验证中...</span> : <>登录<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></>}
|
||||
</button>
|
||||
<div className="divider"><span className="line"></span><span className="txt">OR</span><span className="line"></span></div>
|
||||
<div className="sso-row">
|
||||
<button type="button" className="sso-btn" onClick={() => showToast("微信扫码", "请在 60s 内用微信扫一扫完成授权 · 内测中,以邮箱登录为准")}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 4C5 4 2 6.5 2 9.5c0 1.7 1 3.2 2.5 4.2L4 16l2.2-1.1c.7.2 1.5.3 2.3.3-.3-.5-.5-1.1-.5-1.7 0-2.8 2.9-5 6.5-5h.5C14.6 6 12 4 9 4zm-2 3.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zM15 9c-3.3 0-6 2.2-6 5s2.7 5 6 5c.7 0 1.4-.1 2-.3L19 20l-.4-1.7c1.4-.9 2.4-2.3 2.4-3.8 0-2.8-2.7-5-6-5zm-2 2.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1z" /></svg>
|
||||
微信扫码
|
||||
</button>
|
||||
<button type="button" className="sso-btn" onClick={() => showToast("飞书 SSO", "即将打开企业飞书登录授权页 · 内测中,以邮箱登录为准")}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="18" height="18" rx="3" /></svg>
|
||||
飞书 SSO
|
||||
</button>
|
||||
</div>
|
||||
<div className="switch-row">还没账号? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}>注册团队 →</a></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="field"><label className="field-label" htmlFor="reg-team">团队名 <span className="req">*</span></label><div className="field-input-wrap"><input id="reg-team" value={teamName} onChange={(event) => setTeamName(event.target.value)} required /></div></div>
|
||||
<div className="field"><label className="field-label" htmlFor="reg-email">超管邮箱 <span className="req">*</span></label><div className="field-input-wrap"><input type="email" id="reg-email" value={email} onChange={(event) => setEmail(event.target.value)} required /></div></div>
|
||||
<div className="field-row"><div className="field"><label className="field-label" htmlFor="reg-pwd">密码 <span className="req">*</span></label><div className="field-input-wrap"><input type="password" id="reg-pwd" value={registerPassword} onChange={(event) => setRegisterPassword(event.target.value)} required /></div></div><div className="field"><label className="field-label" htmlFor="reg-pwd2">确认密码 <span className="req">*</span></label><div className="field-input-wrap"><input type="password" id="reg-pwd2" value={registerPassword2} onChange={(event) => setRegisterPassword2(event.target.value)} required /></div></div></div>
|
||||
<label className="agree"><input type="checkbox" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} /><span>我已阅读并同意 用户协议 与 隐私政策。</span></label>
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<button className="btn-cta" type="submit" disabled={busy}>{busy ? <span className="busy-copy">// 验证中...</span> : "创建团队 · 开始使用"}</button>
|
||||
<div className="switch-row">已有账号? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}>登录 →</a></div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{toast && <div className="login-toast"><div>{toast.title}</div><div style={{ fontSize: "11.5px", color: "rgba(0,0,0,.56)", fontWeight: 400, letterSpacing: ".02em" }}>// {toast.sub}</div></div>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Film, Home, Library, Package, Plus, Wallet } from "lucide-react";
|
||||
import type { Asset, BillingSummary, Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { money, stageMeta, statusPill } from "./stage-config";
|
||||
import { Progress } from "../components/pipeline-stage";
|
||||
|
||||
export function Dashboard({ products, projects, assets, billing, userName, navigate }: {
|
||||
products: Product[];
|
||||
projects: Project[];
|
||||
assets: Asset[];
|
||||
billing: BillingSummary | null;
|
||||
userName?: string;
|
||||
navigate: (page: Page) => void;
|
||||
}) {
|
||||
const completed = projects.filter((project) => project.status === "completed").length;
|
||||
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
|
||||
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>欢迎回来{userName ? `,${userName}` : ""}</h1><div className="sub"><span className="mono">// {new Date().toLocaleDateString("zh-CN")}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span></div></div><div className="actions"><button className="btn" type="button" onClick={() => navigate("products")}><Plus size={13} />新建商品</button><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate("projectWizard")}><Plus size={13} />新建项目</button></div></div>
|
||||
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projects.length} delta={`↑ 本月 +${Math.max(projects.length, 0)}`} /><KpiStat label="进行中" badge="WIP" value={running} delta="待处理" /><KpiStat label="成片" badge="DONE" value={completed} delta="导出完成" /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl">余额 <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: "38%" }} /></div><div className="sub">已冻结 {money(billing?.account.reserved_balance)}</div></button></div>
|
||||
<div className="dash-grid"><div><div className="section-h"><h2>最近项目</h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projects.length} ] →</button></div><div className="card-hard">{projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI 全生 / 4 镜</div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm">继续</span></button>)}</div></div><div className="dash-side"><div className="section-h"><h2>快捷入口</h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut icon={Package} title="商品库" desc={`${products.length} SKU`} onClick={() => navigate("products")} /><Shortcut icon={Library} title="资产库" desc={`${assets.length} assets`} onClick={() => navigate("library")} /><Shortcut icon={Wallet} title="充值" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut icon={Film} title="所有项目" desc={`${projects.length} 个`} onClick={() => navigate("projects")} /></div></div></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiStat({ label, badge, value, delta }: { label: string; badge: string; value: string | number; delta: string }) {
|
||||
return <div className="stat"><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div><div className="delta up">{delta}</div></div>;
|
||||
}
|
||||
|
||||
export function Shortcut({ icon: Icon, title, desc, onClick }: { icon: typeof Home; title: string; desc: string; onClick: () => void }) {
|
||||
return <button className="shortcut" type="button" onClick={onClick}><div className="ic"><Icon size={15} /></div><div><div className="t">{title}</div><div className="d">{desc}</div></div></button>;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { IconKitSvg } from "../components/IconKitSvg";
|
||||
import { CornerMark } from "../components/app-shell";
|
||||
import type { NavigateFn, Page } from "./route-config";
|
||||
|
||||
export function ExactDashboardApp({ navigate, logout }: { navigate: NavigateFn; logout: () => void }) {
|
||||
const go = (next: Page) => (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
navigate(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-head"><a className="brand" href="/dashboard" onClick={go("dashboard")}><span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span></a></div>
|
||||
<div className="search-box"><IconKitSvg name="search" /><input placeholder="搜索" readOnly /><span className="kbd">Ctrl K</span></div>
|
||||
<div className="nav-section">主要</div>
|
||||
<nav>
|
||||
<a href="/dashboard" className="active" onClick={go("dashboard")}><IconKitSvg name="dashboard" /><span>工作台</span></a>
|
||||
<a href="/products" onClick={go("products")}><IconKitSvg name="package" /><span>商品库</span><span className="pill-mini">7</span></a>
|
||||
<a href="/projects" onClick={go("projects")}><IconKitSvg name="clapperboard" /><span>视频项目</span><span className="pill-mini">8</span></a>
|
||||
<a href="/asset-factory" onClick={go("assetFactory")}><IconKitSvg name="sparkles" /><span>图片生成</span></a>
|
||||
<a href="/library" onClick={go("library")}><IconKitSvg name="library" /><span>资产库</span></a>
|
||||
<a href="/team" onClick={go("team")}><IconKitSvg name="users" /><span>团队</span></a>
|
||||
<a href="/account" onClick={go("account")}><IconKitSvg name="creditCard" /><span>消费</span></a>
|
||||
<a href="/settings" onClick={go("settings")}><IconKitSvg name="settings" /><span>设置</span></a>
|
||||
</nav>
|
||||
<div className="aside-foot"><div className="user"><div className="av">李</div><div className="em">小李的店</div></div></div>
|
||||
</aside>
|
||||
<main>
|
||||
<div className="grid-bg"></div>
|
||||
<header className="topbar">
|
||||
<div className="crumbs"><span className="here">工作台</span></div>
|
||||
<div className="right">
|
||||
<button className="balance-chip" type="button" onClick={() => navigate("account")}><IconKitSvg name="creditCard" />余额 <strong>¥327.40</strong></button>
|
||||
<button className="icon-btn" type="button" onClick={() => navigate("messages")}><IconKitSvg name="bell" /><span className="count-noti">12</span></button>
|
||||
<button className="topbar-avatar" type="button" onDoubleClick={logout}><span>李</span></button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="content" id="page-content">
|
||||
<CornerMark pos="tl" /><CornerMark pos="tr" /><CornerMark pos="bl" /><CornerMark pos="br" />
|
||||
<div className="page-head">
|
||||
<div><h1>欢迎回来,小李</h1><div className="sub"><span className="mono">// 05.14 · 周三</span><span>·</span><span>你有 <b>3 个项目</b> 正在进行中</span></div></div>
|
||||
<div className="actions"><a className="btn btn-create" href="/products/new" onClick={go("productCreateUpload")}><IconKitSvg name="productPlus" />新建商品</a><a className="btn btn-primary btn-lg btn-create" href="/projects/new" onClick={go("projectWizard")}><IconKitSvg name="clapperboard" />新建项目</a></div>
|
||||
</div>
|
||||
<div className="stats with-corners">
|
||||
<span className="corner-tr" /><span className="corner-bl" />
|
||||
<a className="stat" href="/projects" onClick={go("projects")}><div className="lbl">总项目 <span className="badge">ALL</span></div><div className="v">8</div><div className="delta up"><IconKitSvg name="arrowUp" size={14} /> 本月 +3</div></a>
|
||||
<a className="stat" href="/projects" onClick={go("projects")}><div className="lbl">进行中 <span className="badge">WIP</span></div><div className="v">3</div><div className="delta">2 个待审核</div></a>
|
||||
<a className="stat" href="/projects" onClick={go("projects")}><div className="lbl">本月成片 <span className="badge">DONE</span></div><div className="v">3</div><div className="delta up">较上月 +33%</div></a>
|
||||
<a className="stat" href="/account" onClick={go("account")}><div className="lbl">余额 <span className="badge">¥</span></div><div className="v">¥327<small>.40</small></div><div className="bar"><span style={{ width: "33%" }} /></div><div className="sub">已用 ¥162.60 / ¥500</div></a>
|
||||
</div>
|
||||
<div className="dash-grid">
|
||||
<div><div className="section-h"><h2>最近项目</h2><a className="more" href="/projects" onClick={go("projects")}>[ ALL · 8 ]</a></div><div className="card-hard">{["补水面膜 · 痛点种草 · v3", "透真防晒 · 通勤对比", "蓝牙耳机 · 开箱测评", "春日新品 · 立体口红"].map((title) => <ExactRecentRow key={title} title={title} go={go("pipeline")} />)}</div></div>
|
||||
<div className="dash-side"><div className="section-h"><h2>快捷入口</h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts">{[["商品库", "products"], ["资产库", "library"], ["充值", "account"], ["所有项目", "projects"]].map(([label, target]) => <a className="shortcut" href={`/${target}`} onClick={go(target as Page)} key={label}><div className="ic"><IconKitSvg name="package" /></div><div><div className="t">{label}</div><div className="d">快速进入</div></div></a>)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExactRecentRow({ title, go }: { title: string; go: (event: { preventDefault: () => void }) => void }) {
|
||||
return (
|
||||
<a className="recent-row" href="/pipeline" onClick={go}>
|
||||
<div className="placeholder thumb"><span className="ph-frame">9:16</span></div>
|
||||
<div className="recent-meta"><div className="name">{title}</div><div className="sub">AI 全生 / 4 段</div></div>
|
||||
<div className="prog"><span className="done" /><span className="done" /><span className="cur" /><span /><span /></div>
|
||||
<span className="pill info"><span className="dot" />进行中</span>
|
||||
<span className="btn btn-sm">继续</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { exactHtmlDocuments } from "./exact-html";
|
||||
import type { ExactHtmlKey } from "./exact-html";
|
||||
import type { AuthMode, NavigateFn, Page } from "./route-config";
|
||||
|
||||
const fileToPage: Record<string, Page | "login" | "register" | null> = {
|
||||
"account.html": "account",
|
||||
"asset-factory.html": "assetFactory",
|
||||
"image-optimize.html": "imageOptimize",
|
||||
"index.html": "dashboard",
|
||||
"library.html": "library",
|
||||
"login.html": "login",
|
||||
"messages.html": "messages",
|
||||
"model-photo.html": "modelPhoto",
|
||||
"model-photo-demo-a.html": "modelPhotoDemoA",
|
||||
"model-photo-demo-b.html": "modelPhotoDemoB",
|
||||
"pipeline.html": "pipeline",
|
||||
"platform-cover.html": "platformCover",
|
||||
"product-create.html": "productCreateUpload",
|
||||
"product-create-upload.html": "productCreateUpload",
|
||||
"product-detail.html": "productDetail",
|
||||
"products.html": "products",
|
||||
"projects-new.html": "projectWizard",
|
||||
"projects.html": "projects",
|
||||
"register.html": "register",
|
||||
"settings.html": "settings",
|
||||
"team.html": "team"
|
||||
};
|
||||
|
||||
const pageToExactKey: Record<Page, ExactHtmlKey> = {
|
||||
dashboard: "dashboard",
|
||||
products: "products",
|
||||
productDetail: "productDetail",
|
||||
productCreateUpload: "productCreateUpload",
|
||||
projects: "projects",
|
||||
projectWizard: "projectWizard",
|
||||
pipeline: "pipeline",
|
||||
library: "library",
|
||||
account: "account",
|
||||
team: "team",
|
||||
messages: "messages",
|
||||
assetFactory: "assetFactory",
|
||||
imageOptimize: "imageOptimize",
|
||||
modelPhoto: "modelPhoto",
|
||||
modelPhotoDemoA: "modelPhotoDemoA",
|
||||
modelPhotoDemoB: "modelPhotoDemoB",
|
||||
platformCover: "platformCover",
|
||||
settings: "settings",
|
||||
settingsNotify: "settings"
|
||||
};
|
||||
|
||||
const exactKeyToPage: Partial<Record<ExactHtmlKey, Page | "login" | "register">> = {
|
||||
account: "account",
|
||||
assetFactory: "assetFactory",
|
||||
dashboard: "dashboard",
|
||||
imageOptimize: "imageOptimize",
|
||||
library: "library",
|
||||
login: "login",
|
||||
messages: "messages",
|
||||
modelPhoto: "modelPhoto",
|
||||
modelPhotoDemoA: "modelPhotoDemoA",
|
||||
modelPhotoDemoB: "modelPhotoDemoB",
|
||||
pipeline: "pipeline",
|
||||
platformCover: "platformCover",
|
||||
productCreate: "productCreateUpload",
|
||||
productCreateUpload: "productCreateUpload",
|
||||
productDetail: "productDetail",
|
||||
products: "products",
|
||||
projectWizard: "projectWizard",
|
||||
projects: "projects",
|
||||
register: "register",
|
||||
settings: "settings",
|
||||
team: "team"
|
||||
};
|
||||
|
||||
const exactKeyToFile: Record<ExactHtmlKey, string> = {
|
||||
account: "account.html",
|
||||
assetFactory: "asset-factory.html",
|
||||
dashboard: "index.html",
|
||||
imageOptimize: "image-optimize.html",
|
||||
library: "library.html",
|
||||
login: "login.html",
|
||||
messages: "messages.html",
|
||||
modelPhoto: "model-photo.html",
|
||||
modelPhotoDemoA: "model-photo-demo-a.html",
|
||||
modelPhotoDemoB: "model-photo-demo-b.html",
|
||||
pipeline: "pipeline.html",
|
||||
platformCover: "platform-cover.html",
|
||||
productCreate: "product-create.html",
|
||||
productCreateUpload: "product-create-upload.html",
|
||||
productDetail: "product-detail.html",
|
||||
products: "products.html",
|
||||
projectWizard: "projects-new.html",
|
||||
projects: "projects.html",
|
||||
register: "register.html",
|
||||
settings: "settings.html",
|
||||
team: "team.html"
|
||||
};
|
||||
|
||||
const liveHydratePages = new Set<ExactHtmlKey>([
|
||||
"dashboard",
|
||||
"products",
|
||||
"productDetail",
|
||||
"projectWizard",
|
||||
"projects",
|
||||
"pipeline",
|
||||
"library",
|
||||
"account",
|
||||
"settings",
|
||||
"team"
|
||||
]);
|
||||
|
||||
function routeFromHref(rawHref: string | null) {
|
||||
if (!rawHref || rawHref === "#" || rawHref.startsWith("javascript:")) return null;
|
||||
const url = new URL(rawHref, "https://airshelf.local/exact/");
|
||||
const fileName = url.pathname.split("/").filter(Boolean).pop() || "index.html";
|
||||
const page = fileToPage[fileName];
|
||||
if (!page) return null;
|
||||
const params = new URLSearchParams(url.search);
|
||||
return {
|
||||
page,
|
||||
hash: url.hash || "",
|
||||
search: url.search || "",
|
||||
productId: params.get("product_id") || undefined,
|
||||
projectId: params.get("project_id") || undefined
|
||||
};
|
||||
}
|
||||
|
||||
function routeFromInlineAction(action: string | null) {
|
||||
if (!action) return null;
|
||||
const hrefMatch = action.match(/location\.href\s*=\s*['"]([^'"]+)/);
|
||||
if (hrefMatch) return routeFromHref(hrefMatch[1]);
|
||||
const hashMatch = action.match(/location\.hash\s*=\s*['"]([^'"]+)/);
|
||||
if (hashMatch) return { page: null, hash: hashMatch[1], search: "" };
|
||||
return null;
|
||||
}
|
||||
|
||||
function setFrameHeight(frame: HTMLIFrameElement) {
|
||||
frame.style.height = `${Math.max(window.innerHeight, 720)}px`;
|
||||
}
|
||||
|
||||
export type ExactDocumentPageProps = {
|
||||
pageKey: ExactHtmlKey;
|
||||
hash?: string;
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
navigate?: NavigateFn;
|
||||
onAuthModeChange?: (mode: AuthMode) => void;
|
||||
onAuthSubmit?: (mode: AuthMode, event: FormEvent<HTMLFormElement>, form: HTMLFormElement) => void;
|
||||
};
|
||||
|
||||
export function exactKeyForPage(page: Page): ExactHtmlKey {
|
||||
return pageToExactKey[page] || "dashboard";
|
||||
}
|
||||
|
||||
function contextSearch(pageKey: ExactHtmlKey, productId?: string, projectId?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (pageKey === "productDetail" && productId) params.set("product_id", productId);
|
||||
if (pageKey === "pipeline" && projectId) params.set("project_id", projectId);
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
|
||||
export function ExactDocumentPage({
|
||||
pageKey,
|
||||
hash,
|
||||
productId,
|
||||
projectId,
|
||||
navigate,
|
||||
onAuthModeChange,
|
||||
onAuthSubmit
|
||||
}: ExactDocumentPageProps) {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const html = useMemo(() => {
|
||||
const context = {
|
||||
page: exactKeyToFile[pageKey],
|
||||
search: contextSearch(pageKey, productId, projectId),
|
||||
hash: hash ? `#${hash.replace(/^#/, "")}` : "",
|
||||
liveHydrate: liveHydratePages.has(pageKey)
|
||||
};
|
||||
return exactHtmlDocuments[pageKey].replace(
|
||||
"</head>",
|
||||
`<script>window.__AIR_SHELF_EXACT_CONTEXT__=${JSON.stringify(context)};</script></head>`
|
||||
);
|
||||
}, [hash, pageKey, productId, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current;
|
||||
if (!frame) return;
|
||||
const currentFrame: HTMLIFrameElement = frame;
|
||||
|
||||
function onLoad() {
|
||||
const doc = currentFrame.contentDocument;
|
||||
const win = currentFrame.contentWindow;
|
||||
if (!doc || !win) return;
|
||||
|
||||
(win as Window & { __AIR_SHELF_HOST_NAVIGATE__?: (href: string) => void }).__AIR_SHELF_HOST_NAVIGATE__ = (
|
||||
href: string
|
||||
) => {
|
||||
const hostRoute = routeFromHref(href);
|
||||
if (!hostRoute?.page) return;
|
||||
if (hostRoute.page === "login" || hostRoute.page === "register") {
|
||||
onAuthModeChange?.(hostRoute.page);
|
||||
return;
|
||||
}
|
||||
navigate?.(hostRoute.page, {
|
||||
hash: hostRoute.hash || undefined,
|
||||
productId: hostRoute.productId || (hostRoute.page === "productDetail" ? productId : undefined),
|
||||
projectId: hostRoute.projectId || (hostRoute.page === "pipeline" ? projectId : undefined)
|
||||
});
|
||||
};
|
||||
|
||||
const applyFrameHash = (nextHash: string) => {
|
||||
const cleanHash = nextHash.replace(/^#/, "");
|
||||
const stageMatch = cleanHash.match(/^stage-(\d+)$/);
|
||||
const pipelineWindow = win as Window & { activateStage?: (stage: number) => void };
|
||||
if (pageKey === "pipeline" && stageMatch && typeof pipelineWindow.activateStage === "function") {
|
||||
pipelineWindow.activateStage(Number(stageMatch[1]));
|
||||
return;
|
||||
}
|
||||
const settingsWindow = win as Window & { showSection?: (sectionId: string) => void };
|
||||
if (pageKey === "settings" && cleanHash.startsWith("sec-") && typeof settingsWindow.showSection === "function") {
|
||||
settingsWindow.showSection(cleanHash);
|
||||
return;
|
||||
}
|
||||
doc.getElementById(cleanHash)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
if (hash) {
|
||||
setTimeout(() => {
|
||||
applyFrameHash(hash);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const clickHandler = (event: MouseEvent) => {
|
||||
const target = event.target as Element | null;
|
||||
if (!target) return;
|
||||
|
||||
const syncHashOnlyRoute = (nextHash: string) => {
|
||||
const cleanHash = nextHash.replace(/^#/, "");
|
||||
applyFrameHash(cleanHash);
|
||||
if (exactKeyToPage[pageKey]) {
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}#${cleanHash}`;
|
||||
window.history.replaceState(null, "", nextUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const actionNode = target.closest("[onclick]") as HTMLElement | null;
|
||||
const actionRoute = routeFromInlineAction(actionNode?.getAttribute("onclick") || null);
|
||||
if (actionRoute?.hash && actionRoute.page === null) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
syncHashOnlyRoute(actionRoute.hash);
|
||||
return;
|
||||
}
|
||||
if (actionRoute?.page) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
if (actionRoute.page === "login" || actionRoute.page === "register") {
|
||||
onAuthModeChange?.(actionRoute.page);
|
||||
return;
|
||||
}
|
||||
navigate?.(actionRoute.page, {
|
||||
hash: actionRoute.hash || undefined,
|
||||
productId: actionRoute.productId || (actionRoute.page === "productDetail" ? productId || "exact" : undefined),
|
||||
projectId: actionRoute.projectId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = target.closest("a[href]") as HTMLAnchorElement | null;
|
||||
const rawAnchorHref = anchor?.getAttribute("href") || null;
|
||||
if (rawAnchorHref?.startsWith("#")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
syncHashOnlyRoute(rawAnchorHref);
|
||||
return;
|
||||
}
|
||||
const anchorRoute = routeFromHref(rawAnchorHref);
|
||||
if (!anchorRoute) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (anchorRoute.page === "login" || anchorRoute.page === "register") {
|
||||
onAuthModeChange?.(anchorRoute.page);
|
||||
return;
|
||||
}
|
||||
navigate?.(anchorRoute.page, {
|
||||
hash: anchorRoute.hash || undefined,
|
||||
productId: anchorRoute.productId || (anchorRoute.page === "productDetail" ? productId || "exact" : undefined),
|
||||
projectId: anchorRoute.projectId
|
||||
});
|
||||
};
|
||||
|
||||
const submitHandler = (event: SubmitEvent) => {
|
||||
const form = event.target as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
const isLogin = pageKey === "login" && form.id === "login-form";
|
||||
const isRegister = pageKey === "register" && form.id === "register-form";
|
||||
if (!isLogin && !isRegister) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
onAuthSubmit?.(isLogin ? "login" : "register", event as unknown as FormEvent<HTMLFormElement>, form);
|
||||
};
|
||||
|
||||
doc.addEventListener("click", clickHandler, true);
|
||||
doc.addEventListener("submit", submitHandler, true);
|
||||
setFrameHeight(currentFrame);
|
||||
const resizeFrame = () => setFrameHeight(currentFrame);
|
||||
window.addEventListener("resize", resizeFrame);
|
||||
const observer = new ResizeObserver(() => setFrameHeight(currentFrame));
|
||||
observer.observe(doc.documentElement);
|
||||
observer.observe(doc.body);
|
||||
|
||||
const cleanup = () => {
|
||||
doc.removeEventListener("click", clickHandler, true);
|
||||
doc.removeEventListener("submit", submitHandler, true);
|
||||
window.removeEventListener("resize", resizeFrame);
|
||||
observer.disconnect();
|
||||
};
|
||||
currentFrame.dataset.cleanupKey = String(Date.now());
|
||||
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup?.();
|
||||
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup = cleanup;
|
||||
}
|
||||
|
||||
currentFrame.addEventListener("load", onLoad);
|
||||
if (currentFrame.contentDocument?.readyState !== "loading") onLoad();
|
||||
return () => {
|
||||
currentFrame.removeEventListener("load", onLoad);
|
||||
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup?.();
|
||||
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup = undefined;
|
||||
};
|
||||
}, [hash, navigate, onAuthModeChange, onAuthSubmit, pageKey]);
|
||||
|
||||
return (
|
||||
<div className="exact-document-route" data-exact-page={pageKey}>
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
title={`Airshelf ${pageKey}`}
|
||||
className="exact-document-frame"
|
||||
srcDoc={html}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactAccountPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="account" />;
|
||||
}
|
||||
|
||||
export default ExactAccountPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactAssetFactoryPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="assetFactory" />;
|
||||
}
|
||||
|
||||
export default ExactAssetFactoryPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactDashboardPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="dashboard" />;
|
||||
}
|
||||
|
||||
export default ExactDashboardPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactImageOptimizePage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="imageOptimize" />;
|
||||
}
|
||||
|
||||
export default ExactImageOptimizePage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactLibraryPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="library" />;
|
||||
}
|
||||
|
||||
export default ExactLibraryPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactLoginPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="login" />;
|
||||
}
|
||||
|
||||
export default ExactLoginPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactMessagesPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="messages" />;
|
||||
}
|
||||
|
||||
export default ExactMessagesPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactModelPhotoDemoAPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="modelPhotoDemoA" />;
|
||||
}
|
||||
|
||||
export default ExactModelPhotoDemoAPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactModelPhotoDemoBPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="modelPhotoDemoB" />;
|
||||
}
|
||||
|
||||
export default ExactModelPhotoDemoBPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactModelPhotoPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="modelPhoto" />;
|
||||
}
|
||||
|
||||
export default ExactModelPhotoPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactPipelinePage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="pipeline" />;
|
||||
}
|
||||
|
||||
export default ExactPipelinePage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactPlatformCoverPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="platformCover" />;
|
||||
}
|
||||
|
||||
export default ExactPlatformCoverPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactProductCreateUploadPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="productCreateUpload" />;
|
||||
}
|
||||
|
||||
export default ExactProductCreateUploadPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactProductCreatePage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="productCreate" />;
|
||||
}
|
||||
|
||||
export default ExactProductCreatePage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactProductDetailPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="productDetail" />;
|
||||
}
|
||||
|
||||
export default ExactProductDetailPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactProductsPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="products" />;
|
||||
}
|
||||
|
||||
export default ExactProductsPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactProjectWizardPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="projectWizard" />;
|
||||
}
|
||||
|
||||
export default ExactProjectWizardPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactProjectsPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="projects" />;
|
||||
}
|
||||
|
||||
export default ExactProjectsPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactRegisterPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="register" />;
|
||||
}
|
||||
|
||||
export default ExactRegisterPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactSettingsPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="settings" />;
|
||||
}
|
||||
|
||||
export default ExactSettingsPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||||
import { ExactDocumentPage } from "../exact-document";
|
||||
import type { ExactDocumentPageProps } from "../exact-document";
|
||||
|
||||
export function ExactTeamPage(props: Omit<ExactDocumentPageProps, "pageKey">) {
|
||||
return <ExactDocumentPage {...props} pageKey="team" />;
|
||||
}
|
||||
|
||||
export default ExactTeamPage;
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
|
||||
export { AuthScreen } from "./auth-screen";
|
||||
export { ExactDashboardApp } from "./exact-dashboard";
|
||||
export { Dashboard } from "./dashboard";
|
||||
export { ProductsPage, ProductCreateUploadPage, ProductDetailPage } from "./products";
|
||||
export { ProjectWizardPage, ProjectsPage } from "./projects";
|
||||
export { PipelinePage } from "./pipeline";
|
||||
export { LibraryPage } from "./library";
|
||||
export { AccountPage } from "./account";
|
||||
export { TeamPage } from "./team";
|
||||
export { MessagesPage } from "./messages";
|
||||
export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-tools";
|
||||
export { SettingsPage } from "./settings";
|
||||
@@ -0,0 +1,33 @@
|
||||
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";
|
||||
|
||||
export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (formData: FormData) => Promise<unknown> | void }) {
|
||||
const [drawer, setDrawer] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("name", name || file.name);
|
||||
formData.append("asset_type", "image");
|
||||
formData.append("category", "upload");
|
||||
await onUpload(formData);
|
||||
setDrawer(false);
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import { Bell, Search } from "lucide-react";
|
||||
import type { Page } from "./route-config";
|
||||
import { routeLabels } from "./route-config";
|
||||
|
||||
export function MessagesPage({ navigate }: { navigate: (page: Page) => void }) {
|
||||
const messages = [
|
||||
{ id: "m1", type: "task", priority: "ok", title: "补水面膜 · 痛点种草 v3 成片已完成", brief: "7 镜 · 40 秒 · ¥18.40 已结算。", body: "视频生成全部完成。", target: "pipeline" as Page },
|
||||
{ id: "m2", type: "billing", priority: "warn", title: "团队余额低于预警线", brief: "当前余额低于 ¥100。", body: "建议先充值或降低任务量。", target: "account" as Page }
|
||||
];
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedId, setSelectedId] = useState(messages[0].id);
|
||||
const visible = messages.filter((item) => !query || `${item.title} ${item.brief}`.toLowerCase().includes(query.toLowerCase()));
|
||||
const selected = messages.find((item) => item.id === selectedId) || visible[0] || messages[0];
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>消息中心</h1><div className="sub"><span className="mono">// {messages.length} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div></div><div className="actions"><button className="btn" type="button" onClick={() => navigate("settingsNotify")}>通知设置</button></div></div>
|
||||
<div className="msg-workbench"><section className="msg-panel msg-inbox"><div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 显示 {visible.length} 条</span></div><div className="msg-search"><Search size={14} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" /></div><div className="msg-list">{visible.map((item) => <button className={`msg-item ${selected.id === item.id ? "active" : ""}`} type="button" key={item.id} onClick={() => setSelectedId(item.id)}><span className={`msg-type-ic ${item.type}`}><Bell size={13} /></span><span className="msg-item-main"><span className="msg-item-title">{item.title}</span><span className="msg-brief">{item.brief}</span></span></button>)}</div></section><section className="msg-panel msg-detail"><div className="msg-detail-body"><div className="msg-detail-top"><span className={`msg-type-ic ${selected.type}`}><Bell size={15} /></span><div className="msg-detail-title"><h2>{selected.title}</h2><div className="meta"><span>{selected.type}</span></div></div></div><p className="msg-body-text">{selected.body}</p><div className="msg-props"><span className="k">关联资源</span><span className="v">{routeLabels[selected.target]}</span></div></div><div className="msg-detail-f"><span className="spacer" /><button className="btn btn-primary" type="button" onClick={() => navigate(selected.target)}>进入{routeLabels[selected.target]}</button></div></section></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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";
|
||||
|
||||
export function PipelinePage({
|
||||
project,
|
||||
loading,
|
||||
onRefresh,
|
||||
onGenerateScript,
|
||||
onAdoptScript,
|
||||
onGenerateBaseAsset,
|
||||
onGenerateStoryboard,
|
||||
onSkipStoryboard,
|
||||
onSubmitVideo,
|
||||
onPollVideo,
|
||||
onSubmitAllVideos,
|
||||
onPollAllVideos,
|
||||
onSubmitExport
|
||||
}: {
|
||||
project: Project;
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
onGenerateScript: (prompt: string) => void;
|
||||
onAdoptScript: (scriptId: string) => void;
|
||||
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string) => void;
|
||||
onGenerateStoryboard: (prompt: string) => void;
|
||||
onSkipStoryboard: () => void;
|
||||
onSubmitVideo: (segmentId: string, prompt: string) => void;
|
||||
onPollVideo: (segmentId: string) => void;
|
||||
onSubmitAllVideos: (prompt: string) => void;
|
||||
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 canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
|
||||
|
||||
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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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 { Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { statusPill } from "./stage-config";
|
||||
import { ConfirmModal, Drawer } from "../components/overlays";
|
||||
|
||||
type ProductPayload = {
|
||||
title?: string;
|
||||
brand?: string;
|
||||
category?: string;
|
||||
target_audience?: string;
|
||||
description?: string;
|
||||
specs?: Record<string, unknown>;
|
||||
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
|
||||
};
|
||||
|
||||
export function ProductsPage({ products, navigate, openProduct, onCreate }: {
|
||||
products: Product[];
|
||||
navigate: (page: Page) => void;
|
||||
openProduct: (productId: string) => void;
|
||||
onCreate: (payload: ProductPayload) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [drawer, setDrawer] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [brand, setBrand] = useState("");
|
||||
const [point, setPoint] = useState("");
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
onCreate({
|
||||
title,
|
||||
brand,
|
||||
category: "电商商品",
|
||||
target_audience: "泛人群",
|
||||
description: point,
|
||||
selling_points: [{ title: point || "核心卖点", detail: point || "待补充", sort_order: 0 }]
|
||||
});
|
||||
setDrawer(false);
|
||||
setTitle("");
|
||||
setBrand("");
|
||||
setPoint("");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<div className="field"><label className="field-label">品牌</label><input className="input" value={brand} onChange={(event) => setBrand(event.target.value)} /></div>
|
||||
<div className="field"><label className="field-label">核心卖点<span className="req">*</span></label><textarea className="textarea" value={point} onChange={(event) => setPoint(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">创建商品</button></div>
|
||||
</form>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductCard({ product, onOpen }: { product: Product; onOpen: () => void }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (payload: ProductPayload) => Promise<unknown> | void; onBack: () => void }) {
|
||||
const [title, setTitle] = useState("补水保湿精华液");
|
||||
const [brand, setBrand] = useState("透真");
|
||||
const [category, setCategory] = useState("美妆个护");
|
||||
const [audience, setAudience] = useState("熬夜党 / 学生党 / 通勤白领");
|
||||
const [description, setDescription] = useState("主打补水、舒缓、快速上脸,适合短视频痛点种草。");
|
||||
const [points, setPoints] = useState("透明质酸 + B5\n30g 大容量精华\n0 香精 0 酒精");
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
onCreate({ title, brand, category, target_audience: audience, description, specs: { source: "product-create-upload" }, selling_points: points.split("\n").filter(Boolean).map((item, index) => ({ title: item, detail: item, sort_order: index })) });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>上传创建商品</h1><div className="sub"><span className="mono">// product-create-upload</span> · 图片 / 文案 / 卖点一次补齐</div></div><div className="actions"><button className="btn btn-ghost" type="button" onClick={onBack}><ArrowLeft size={13} />返回商品库</button></div></div>
|
||||
<form className="create-product-layout" onSubmit={submit}>
|
||||
<section className="card-hard create-upload-zone with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="mono muted-2">[ PRODUCT IMAGES ]</div><div className="upload-stage"><Upload size={28} /><strong>拖入商品图片或点击上传</strong><span>// 文件后续走资产库 TOS 上传接口</span></div></section>
|
||||
<section className="pane create-form-pane"><div className="pane-h"><strong>商品信息</strong><span className="spacer" /><span className="mono muted-2">AUTO SAVE READY</span></div><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><div className="two-col"><div className="field"><label className="field-label">品牌</label><input className="input" value={brand} onChange={(event) => setBrand(event.target.value)} /></div><div className="field"><label className="field-label">类目</label><input className="input" value={category} onChange={(event) => setCategory(event.target.value)} /></div></div><div className="field"><label className="field-label">目标人群</label><input className="input" value={audience} onChange={(event) => setAudience(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="field"><label className="field-label">核心卖点<span className="req">*</span></label><textarea className="textarea" value={points} onChange={(event) => setPoints(event.target.value)} /></div><div className="drawer-actions"><button className="btn" type="button" onClick={onBack}>取消</button><button className="btn btn-primary" type="submit">创建商品并查看详情</button></div></section>
|
||||
<aside className="wiz-preview"><div className="pv-h"><span>资料完整度</span><span className="live">LIVE</span></div><div className="pv-title">{title}</div><div className="pv-section"><div className="lbl">下一步</div><ul className="pv-list"><li>进入商品详情页</li><li>生成模特图 / 平台套图</li><li>创建视频项目</li></ul></div></aside>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductDetailPage({ product, projects, navigate, onUpdate, onDelete }: {
|
||||
product: Product;
|
||||
projects: Project[];
|
||||
navigate: (page: Page) => void;
|
||||
onUpdate: (payload: Partial<Product>) => Promise<unknown> | void;
|
||||
onDelete: () => Promise<unknown> | void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [title, setTitle] = useState(product.title);
|
||||
const [description, setDescription] = useState(product.description || "");
|
||||
|
||||
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>
|
||||
</div>
|
||||
<ConfirmModal open={confirmDelete} title="确认删除商品" detail={`即将删除 ${product.title},已有关联项目请先确认。`} confirmText="删除商品" onCancel={() => setConfirmDelete(false)} onConfirm={onDelete} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { ArrowLeft, ArrowRight, Grid2X2, List, Plus, Search, Trash2 } 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[];
|
||||
onBack: () => void;
|
||||
onCreate: (payload: { name: string; product: string }) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [productId, setProductId] = useState(products[0]?.id || "");
|
||||
const product = products.find((item) => item.id === productId) || products[0];
|
||||
const [name, setName] = useState(() => `${products[0]?.title || "商品"} · 短视频 · ${new Date().toLocaleDateString("zh-CN")}`);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId && products[0]) setProductId(products[0].id);
|
||||
}, [productId, products]);
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (product) void onCreate({ name: name || `${product.title} · 短视频`, product: product.id });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>新建视频项目</h1><div className="sub"><span className="mono">// 选择商品 → 创建项目 → 进入 Stage 1 脚本</span></div></div><div className="actions"><button className="btn btn-ghost" type="button" onClick={onBack}>退出</button></div></div>
|
||||
<form className="wizard" onSubmit={submit}>
|
||||
<nav className="steps" aria-label="新建项目步骤"><div className="step active"><div className="num">1</div><div><div className="label">选择商品</div><div className="desc">{product?.title || "未选择"}</div></div></div><div className="step"><div className="num">2</div><div><div className="label">项目名称</div><div className="desc">自动生成可修改</div></div></div><div className="step"><div className="num">3</div><div><div className="label">进入脚本</div><div className="desc">Stage 1 配置脚本</div></div></div></nav>
|
||||
<div><section className="wiz-pane active"><div className="wiz-step-h"><h2>为哪个商品创建视频?</h2><p>按定稿,创建页只绑定商品和项目名称,脚本风格、卖点重点放到 Stage 1。</p></div><div className="product-select-grid">{products.map((item) => <button className={`product-pick ${productId === item.id ? "selected" : ""}`} type="button" key={item.id} onClick={() => setProductId(item.id)}><div className="placeholder"><span className="ph-frame">{item.title}</span></div><strong>{item.title}</strong><span>{item.category || "未分类"} · {item.selling_points.length} 个卖点</span></button>)}</div>{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={onBack} />}<div className="field" style={{ marginTop: 18 }}><label className="field-label">项目名称</label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="wiz-foot"><button className="btn" type="button" onClick={onBack}><ArrowLeft size={13} />返回</button><div className="hstack"><span className="muted-2 mono">// 下一步:流水线 Stage 1</span><button className="btn btn-primary btn-lg" type="submit" disabled={!product}><ArrowRight size={13} />创建并进入脚本</button></div></div></section></div>
|
||||
<aside className="wiz-preview"><div className="pv-h"><span>实时预估</span><span className="live">LIVE</span></div><div className="pv-title">{name || product?.title || "未命名项目"}</div><div className="pv-metrics"><div className="pv-metric"><div className="l">片段</div><div className="v">4<small>段</small></div></div><div className="pv-metric accent"><div className="l">总时长</div><div className="v">60<small>s</small></div></div><div className="pv-metric"><div className="l">阶段</div><div className="v">5</div></div></div></aside>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsPage({ products, projects, navigate, openPipeline, onDelete }: {
|
||||
products: Product[];
|
||||
projects: Project[];
|
||||
navigate: (page: Page) => void;
|
||||
onCreate: (payload: { name: string; product: string }) => Promise<unknown> | void;
|
||||
openPipeline: (projectId: string) => void;
|
||||
onDelete: (projectId: string) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [view, setView] = useState<"list" | "grid">("list");
|
||||
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()));
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
await onDelete(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
|
||||
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>}
|
||||
{filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
|
||||
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}。`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
Film,
|
||||
FolderKanban,
|
||||
Home,
|
||||
ImageIcon,
|
||||
Library,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Settings,
|
||||
Users,
|
||||
Wallet,
|
||||
WandSparkles
|
||||
} from "lucide-react";
|
||||
|
||||
export type Page =
|
||||
| "dashboard"
|
||||
| "products"
|
||||
| "productDetail"
|
||||
| "productCreateUpload"
|
||||
| "projects"
|
||||
| "projectWizard"
|
||||
| "pipeline"
|
||||
| "library"
|
||||
| "account"
|
||||
| "team"
|
||||
| "messages"
|
||||
| "assetFactory"
|
||||
| "imageOptimize"
|
||||
| "modelPhoto"
|
||||
| "modelPhotoDemoA"
|
||||
| "modelPhotoDemoB"
|
||||
| "platformCover"
|
||||
| "settings"
|
||||
| "settingsNotify";
|
||||
|
||||
export type AuthMode = "login" | "register";
|
||||
export type ResolvedRoute = {
|
||||
page: Page;
|
||||
authMode: AuthMode;
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
hash?: string;
|
||||
};
|
||||
export type NavigateOptions = {
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
replace?: boolean;
|
||||
hash?: string;
|
||||
};
|
||||
export type NavigateFn = (page: Page, options?: NavigateOptions) => void;
|
||||
export type Notice = { type: "success" | "error" | "info"; text: string } | null;
|
||||
export type NavItem = { page: Page; label: string; icon: typeof Home; badge?: string };
|
||||
|
||||
export const mainNav: NavItem[] = [
|
||||
{ page: "dashboard", label: "工作台", icon: Home },
|
||||
{ page: "products", label: "商品库", icon: Package },
|
||||
{ page: "projects", label: "视频项目", icon: FolderKanban },
|
||||
{ page: "pipeline", label: "生产管线", icon: WandSparkles },
|
||||
{ page: "library", label: "资产库", icon: Library },
|
||||
{ page: "account", label: "账户", icon: Wallet }
|
||||
];
|
||||
|
||||
export const supplementNav: NavItem[] = [
|
||||
{ page: "team", label: "团队", icon: Users },
|
||||
{ page: "messages", label: "消息", icon: MessageSquare, badge: "3" },
|
||||
{ page: "assetFactory", label: "图片工具", icon: ImageIcon, badge: "AI" },
|
||||
{ page: "settings", label: "设置", icon: Settings }
|
||||
];
|
||||
|
||||
export const allNav = [...mainNav, ...supplementNav];
|
||||
export const routeLabels: Record<Page, string> = {
|
||||
dashboard: "工作台",
|
||||
products: "商品库",
|
||||
productDetail: "商品详情",
|
||||
productCreateUpload: "上传创建商品",
|
||||
projects: "视频项目",
|
||||
projectWizard: "新建视频项目",
|
||||
pipeline: "生产管线",
|
||||
library: "资产库",
|
||||
account: "账户",
|
||||
team: "团队",
|
||||
messages: "消息",
|
||||
assetFactory: "图片工具",
|
||||
imageOptimize: "图片创作",
|
||||
modelPhoto: "模特上身图",
|
||||
modelPhotoDemoA: "模特图方案 A",
|
||||
modelPhotoDemoB: "模特图方案 B",
|
||||
platformCover: "平台套图",
|
||||
settings: "设置",
|
||||
settingsNotify: "通知设置"
|
||||
};
|
||||
export const routePages = Object.keys(routeLabels) as Page[];
|
||||
|
||||
export function isPage(value: string): value is Page {
|
||||
return routePages.includes(value as Page);
|
||||
}
|
||||
|
||||
export function parentPage(page: Page): Page {
|
||||
if (["productDetail", "productCreateUpload"].includes(page)) return "products";
|
||||
if (page === "projectWizard") return "projects";
|
||||
if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) {
|
||||
return "assetFactory";
|
||||
}
|
||||
if (page === "settingsNotify") return "settings";
|
||||
return page;
|
||||
}
|
||||
|
||||
export function resolveRoute(): ResolvedRoute {
|
||||
const rawPath = window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
const path = rawPath.toLowerCase();
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
const hash = window.location.hash.replace("#", "");
|
||||
|
||||
if (path === "/register" || hash === "register") return { page: "dashboard", authMode: "register", hash };
|
||||
if (path === "/login") return { page: "dashboard", authMode: "login", hash };
|
||||
if (path === "/" && isPage(hash)) return { page: hash, authMode: "login", hash };
|
||||
if (path === "/" || path === "/dashboard") return { page: "dashboard", authMode: "login", hash };
|
||||
if (path === "/products") return { page: "products", authMode: "login", hash };
|
||||
if (path === "/products/new") return { page: "productCreateUpload", authMode: "login", hash };
|
||||
if (path.startsWith("/products/")) {
|
||||
return { page: "productDetail", authMode: "login", productId: decodeURIComponent(path.slice("/products/".length)), hash };
|
||||
}
|
||||
if (path === "/projects") return { page: "projects", authMode: "login", hash };
|
||||
if (path === "/projects/new") return { page: "projectWizard", authMode: "login", hash };
|
||||
if (path === "/pipeline") {
|
||||
return { page: "pipeline", authMode: "login", projectId: search.get("project_id") || undefined, hash };
|
||||
}
|
||||
if (path.startsWith("/pipeline/")) {
|
||||
return { page: "pipeline", authMode: "login", projectId: decodeURIComponent(path.slice("/pipeline/".length)), hash };
|
||||
}
|
||||
if (path === "/library") return { page: "library", authMode: "login", hash };
|
||||
if (path === "/account") return { page: "account", authMode: "login", hash };
|
||||
if (path === "/team") return { page: "team", authMode: "login", hash };
|
||||
if (path === "/messages") return { page: "messages", authMode: "login", hash };
|
||||
if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash };
|
||||
if (path === "/image-optimize") return { page: "imageOptimize", authMode: "login", hash };
|
||||
if (path === "/model-photo") return { page: "modelPhoto", authMode: "login", hash };
|
||||
if (path === "/model-photo/demo-a") return { page: "modelPhotoDemoA", authMode: "login", hash };
|
||||
if (path === "/model-photo/demo-b") return { page: "modelPhotoDemoB", authMode: "login", hash };
|
||||
if (path === "/platform-cover") return { page: "platformCover", authMode: "login", hash };
|
||||
if (path === "/settings/notify") return { page: "settingsNotify", authMode: "login", hash };
|
||||
if (path === "/settings") return { page: "settings", authMode: "login", hash };
|
||||
return { page: "dashboard", authMode: "login", hash };
|
||||
}
|
||||
|
||||
export function pathForPage(page: Page, options: NavigateOptions = {}) {
|
||||
switch (page) {
|
||||
case "dashboard":
|
||||
return "/dashboard";
|
||||
case "products":
|
||||
return "/products";
|
||||
case "productCreateUpload":
|
||||
return "/products/new";
|
||||
case "productDetail":
|
||||
return options.productId ? `/products/${encodeURIComponent(options.productId)}` : "/products";
|
||||
case "projects":
|
||||
return "/projects";
|
||||
case "projectWizard":
|
||||
return "/projects/new";
|
||||
case "pipeline":
|
||||
return options.projectId ? `/pipeline/${encodeURIComponent(options.projectId)}` : "/pipeline";
|
||||
case "library":
|
||||
return "/library";
|
||||
case "account":
|
||||
return "/account";
|
||||
case "team":
|
||||
return "/team";
|
||||
case "messages":
|
||||
return "/messages";
|
||||
case "assetFactory":
|
||||
return "/asset-factory";
|
||||
case "imageOptimize":
|
||||
return "/image-optimize";
|
||||
case "modelPhoto":
|
||||
return "/model-photo";
|
||||
case "modelPhotoDemoA":
|
||||
return "/model-photo/demo-a";
|
||||
case "modelPhotoDemoB":
|
||||
return "/model-photo/demo-b";
|
||||
case "platformCover":
|
||||
return "/platform-cover";
|
||||
case "settingsNotify":
|
||||
return "/settings/notify";
|
||||
case "settings":
|
||||
return "/settings";
|
||||
default:
|
||||
return "/dashboard";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { LogOut, Upload } from "lucide-react";
|
||||
import type { Team, User } from "../types";
|
||||
import { ConfirmModal, SettingRow, TeamModal } from "../components/overlays";
|
||||
|
||||
export function SettingsPage({ user, team, initialSection = "profile" }: { user: User; team: Team; initialSection?: string }) {
|
||||
const [section, setSection] = useState(initialSection);
|
||||
const [modal, setModal] = useState<"" | "avatar" | "logout">("");
|
||||
const sections = [["profile", "个人资料"], ["security", "安全"], ["notify", "通知"], ["pref", "创作偏好"], ["display", "显示"]];
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>设置</h1><div className="sub"><span className="mono">// profile · security · notify · preference · display</span></div></div><div className="actions"><button className="btn" type="button">取消</button><button className="btn btn-primary" type="button">保存设置</button></div></div>
|
||||
<div className="settings-layout"><aside className="settings-side">{sections.map(([key, label]) => <button className={section === key ? "active" : ""} type="button" key={key} onClick={() => setSection(key)}>{label}</button>)}<button className="logout-pill" type="button" onClick={() => setModal("logout")}><LogOut size={13} />退出当前设备</button></aside><section className="settings-main">{section === "profile" && <div className="pane"><h3>个人资料</h3><div className="profile-row"><div className="av-big">{user.username.slice(0, 1).toUpperCase()}</div><button className="btn btn-sm" type="button" onClick={() => setModal("avatar")}>上传新头像</button></div><div className="field"><label className="field-label">用户名</label><input className="input" value={user.username} readOnly /></div><div className="field"><label className="field-label">邮箱</label><input className="input" value={user.email || ""} readOnly /></div><div className="field"><label className="field-label">团队</label><input className="input" value={team.name} readOnly /></div></div>}{section === "security" && <div className="pane"><h3>安全</h3><SettingRow title="登录密码" desc="上次更新: 2026-05-28" action="修改" /><SettingRow title="双因素认证" desc="建议超管开启" toggle /></div>}{section === "notify" && <div className="pane"><h3>通知策略</h3><SettingRow title="导出完成" desc="成片 / 套图完成后提醒" toggle checked /><SettingRow title="任务失败" desc="失败和扣费异常必须提醒" toggle checked /><SettingRow title="额度预警" desc="余额低于阈值时提醒" toggle checked /></div>}{section === "pref" && <div className="pane"><h3>创作偏好</h3><SettingRow title="自动水印" desc="VIP 可关闭" toggle checked /></div>}{section === "display" && <div className="pane"><h3>显示偏好</h3><div className="field"><label className="field-label">主题</label><select className="select"><option>跟随系统</option><option>浅色</option></select></div></div>}</section></div>
|
||||
<TeamModal open={modal === "avatar"} title="上传头像" subtitle="// JPG / PNG / WebP" icon={<Upload size={16} />} close={() => setModal("")}><div className="upload-zone">点击或拖入图片上传<br /><span className="mono">// 头像上传接口待后端补充</span></div></TeamModal>
|
||||
<ConfirmModal open={modal === "logout"} title="退出当前账号" detail="当前有未保存的设置变更时,退出后这些变更不会保存。" confirmText="退出" onCancel={() => setModal("")} onConfirm={() => setModal("")} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export const stageOrder = ["script", "base_assets", "storyboard", "video", "export"] as const;
|
||||
|
||||
export const stageMeta: Record<string, { no: string; label: string }> = {
|
||||
script: { no: "1", label: "脚本" },
|
||||
base_assets: { no: "2", label: "基础资产" },
|
||||
storyboard: { no: "3", label: "故事板" },
|
||||
video: { no: "4", label: "视频片段" },
|
||||
export: { no: "5", label: "拼接导出" }
|
||||
};
|
||||
|
||||
export function money(value: string | number | undefined) {
|
||||
if (value === undefined || value === "") return "¥0.00";
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : `¥${value}`;
|
||||
}
|
||||
|
||||
export function statusPill(status: string) {
|
||||
if (["succeeded", "completed", "done", "ok", "skipped"].includes(status)) return "ok";
|
||||
if (["failed", "error"].includes(status)) return "err";
|
||||
if (["running", "queued", "needs_review", "polling"].includes(status)) return "info";
|
||||
return "neutral";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import { CircleDollarSign, KeyRound, Settings, UserPlus } from "lucide-react";
|
||||
import type { Team, TeamMember, User } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { money, statusPill } 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 }) {
|
||||
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];
|
||||
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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user