大量修改UI
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { CreditCard, X } from "lucide-react";
|
||||
import { ArrowLeft, CreditCard, X } from "lucide-react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api } from "../api";
|
||||
import type { BillingSummary, BillingTrend, Ledger, Project, Team, TeamMember } from "../types";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
import { money, pts, stageMeta, yuan } from "./stage-config";
|
||||
import { pageWindow } from "../components/pager";
|
||||
import { useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
||||
@@ -14,9 +15,9 @@ const ROLE_PILL: Record<string, string> = { owner: "role-super", admin: "role-ad
|
||||
|
||||
type TrendRange = "day" | "week" | "month";
|
||||
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
|
||||
day: { chip: "日", sub: "// 近 14 天 · 单位 积分", totalLabel: "14 天合计", avgLabel: "日均" },
|
||||
week: { chip: "周", sub: "// 近 8 周 · 单位 积分", totalLabel: "8 周合计", avgLabel: "周均" },
|
||||
month: { chip: "月", sub: "// 近 6 个月 · 单位 积分", totalLabel: "6 月合计", avgLabel: "月均" }
|
||||
day: { chip: "日", sub: "近 14 天 · 单位 积分", totalLabel: "14 天合计", avgLabel: "日均" },
|
||||
week: { chip: "周", sub: "近 8 周 · 单位 积分", totalLabel: "8 周合计", avgLabel: "周均" },
|
||||
month: { chip: "月", sub: "近 6 个月 · 单位 积分", totalLabel: "6 月合计", avgLabel: "月均" }
|
||||
};
|
||||
|
||||
type Tab = "overview" | "by-project" | "by-member" | "bills";
|
||||
@@ -63,7 +64,7 @@ const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: num
|
||||
const MIN_RECHARGE_AMOUNT = 50;
|
||||
|
||||
const STAGES: Array<{ k: string; color: string; bucket: keyof BillingTrend["by_stage"] }> = [
|
||||
{ k: "视频片段(Seedance)", color: "var(--heat)", bucket: "video" },
|
||||
{ k: "视频片段(Seedance)", color: "var(--klein)", bucket: "video" },
|
||||
{ k: "故事板(image-2)", color: "var(--accent-forest)", bucket: "storyboard" },
|
||||
{ k: "基础资产", color: "var(--black-alpha-56)", bucket: "base" },
|
||||
{ k: "脚本 LLM", color: "var(--black-alpha-32)", bucket: "script" }
|
||||
@@ -92,18 +93,18 @@ function TopupModal({ open, channel, amount, bonus, rate, close, onDone }: {
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><CreditCard size={16} /></div>
|
||||
<div className="ti">扫码支付<span>// {channelLabel}</span></div>
|
||||
<div className="ti">扫码支付<span>{channelLabel}</span></div>
|
||||
<button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
<div className="topup-info">支付金额</div>
|
||||
<div className="topup-amt">{yuan(amount)}</div>
|
||||
<div className="topup-note">{`// 到账 ${money(Math.round(amount * rate + bonus))}`}{bonus > 0 ? `(含 ${bonus} 积分赠送)` : ""}</div>
|
||||
<div className="topup-note">{`到账 ${money(Math.round(amount * rate + bonus))}`}{bonus > 0 ? `(含 ${bonus} 积分赠送)` : ""}</div>
|
||||
<div className="topup-qr" aria-label="二维码占位">
|
||||
<div className="center">{scanLabel}<br /><span className="ref">{txRef}</span></div>
|
||||
</div>
|
||||
<div className="topup-valid">// 5 分钟内有效 · 到账后自动关闭</div>
|
||||
<div className="topup-valid">// 积分不可提现、不可转让 · 长期有效 · 发票按实际支付金额开具</div>
|
||||
<div className="topup-valid">5 分钟内有效 · 到账后自动关闭</div>
|
||||
<div className="topup-valid">积分不可提现、不可转让 · 长期有效 · 发票按实际支付金额开具</div>
|
||||
</div>
|
||||
<div className="modal-f">
|
||||
<button className="btn" type="button" onClick={close}>取消</button>
|
||||
@@ -115,11 +116,12 @@ function TopupModal({ open, channel, amount, bonus, rate, close, onDone }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
export function AccountPage({ billing, projects, team, navigate, onRecharge, onNotify }: {
|
||||
billing: BillingSummary | null;
|
||||
projects: Project[];
|
||||
// team prop 用于读取团队级月限额(与团队管理页保持同一来源 · #14)
|
||||
team?: Team | null;
|
||||
navigate: NavigateFn;
|
||||
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
|
||||
onNotify: (type: "success" | "error" | "info", text: string) => void;
|
||||
}) {
|
||||
@@ -281,9 +283,12 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
return (
|
||||
<section className="account-page">
|
||||
<div className="page-head">
|
||||
<button className="ac-back" type="button" onClick={() => navigate("dashboard")} aria-label="返回">
|
||||
<ArrowLeft size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
<div>
|
||||
<h1>消费</h1>
|
||||
<div className="sub"><span className="mono">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div>
|
||||
<h1>账户</h1>
|
||||
<div className="sub">{money(balance)} 余额 · {money(used)} 本月已用 · 充值与账单流水</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -293,18 +298,18 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
<div className="balance-hero">
|
||||
<div className="lbl">团队余额</div>
|
||||
<div className="v">{money(balance)}</div>
|
||||
<div className="meta">// 充值累加 · 不重置</div>
|
||||
<div className="meta">充值累加 · 不重置</div>
|
||||
</div>
|
||||
<div className="balance-sub">
|
||||
<div className="col">
|
||||
<div className="lbl">本月限额</div>
|
||||
<div className="v">{money(limit)}</div>
|
||||
<div className="meta">// 按自然月重置</div>
|
||||
<div className="meta">按自然月重置</div>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="lbl">当月已用</div>
|
||||
<div className="v">{money(used)}</div>
|
||||
<div className="meta">// 占比 {pct.toFixed(1)}% · {pct >= 80 ? "注意" : "健康"}</div>
|
||||
<div className="meta">占比 {pct.toFixed(1)}% · {pct >= 80 ? "注意" : "健康"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="balance-foot">
|
||||
@@ -320,7 +325,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
<div className="topup-head">
|
||||
<div>
|
||||
<h3>快速充值</h3>
|
||||
<div className="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
|
||||
<div className="desc">充值后立刻到账,可开发票 · 仅超管可操作</div>
|
||||
</div>
|
||||
<div className="topup-selected">已选 ¥{effectiveAmount}(到账 {Math.round(effectiveAmount * pointsRate + effectiveBonus)} 积分){effectiveBonus > 0 ? ` · 含 ${effectiveBonus} 积分赠送` : ""}</div>
|
||||
</div>
|
||||
@@ -377,10 +382,10 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
</div>
|
||||
|
||||
<div className="billing-tabs" role="tablist">
|
||||
<button className={`tab ${tab === "overview" ? "active" : ""}`} type="button" onClick={() => setTab("overview")}>总览</button>
|
||||
<button className={`tab ${tab === "by-project" ? "active" : ""}`} type="button" onClick={() => setTab("by-project")}>项目 <span className="count">{projects.length}</span></button>
|
||||
<button className={`tab ${tab === "by-member" ? "active" : ""}`} type="button" onClick={() => setTab("by-member")}>成员 <span className="count">{teamMembers.length}</span></button>
|
||||
<button className={`tab ${tab === "bills" ? "active" : ""}`} type="button" onClick={() => setTab("bills")}>账单流水 <span className="count">{ledgerCount}</span></button>
|
||||
<button className={`ac-tab ${tab === "overview" ? "active" : ""}`} type="button" onClick={() => setTab("overview")}>总览</button>
|
||||
<button className={`ac-tab ${tab === "by-project" ? "active" : ""}`} type="button" onClick={() => setTab("by-project")}>项目 <span className="count">{projects.length}</span></button>
|
||||
<button className={`ac-tab ${tab === "by-member" ? "active" : ""}`} type="button" onClick={() => setTab("by-member")}>成员 <span className="count">{teamMembers.length}</span></button>
|
||||
<button className={`ac-tab ${tab === "bills" ? "active" : ""}`} type="button" onClick={() => setTab("bills")}>账单流水 <span className="count">{ledgerCount}</span></button>
|
||||
</div>
|
||||
|
||||
<div className={`tab-panel ${tab === "overview" ? "active" : ""}`}>
|
||||
@@ -391,7 +396,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
<span className="sub">{meta.sub}</span>
|
||||
<span className="spacer"></span>
|
||||
{(["day", "week", "month"] as TrendRange[]).map((r) => (
|
||||
<button key={r} className={`chip${range === r ? " active" : ""}`} type="button" onClick={() => setRange(r)}>{RANGE_META[r].chip}</button>
|
||||
<button key={r} className={`ac-seg${range === r ? " active" : ""}`} type="button" onClick={() => setRange(r)}>{RANGE_META[r].chip}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="trend-chart">
|
||||
@@ -422,7 +427,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
|
||||
<div className="pane stage-pane">
|
||||
<h3>本月按阶段分布</h3>
|
||||
<div className="desc">// PRD §5.3.5 扣费规则 · 仅确认后扣</div>
|
||||
<div className="desc">PRD §5.3.5 扣费规则 · 仅确认后扣</div>
|
||||
{STAGES.map((s) => {
|
||||
const amt = Number(trend?.by_stage[s.bucket] || 0);
|
||||
const w = stageTotal > 0 ? Math.min(100, (amt / stageTotal) * 100) : 0;
|
||||
@@ -439,7 +444,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
|
||||
<div className="pane rule-pane" style={{ marginTop: 16 }}>
|
||||
<h3>扣费 + 四层额度预检规则</h3>
|
||||
<div className="desc">// PRD §5.3.5 + §10.3 · 对接团队请以此页为准</div>
|
||||
<div className="desc">PRD §5.3.5 + §10.3 · 对接团队请以此页为准</div>
|
||||
<div className="rule-list">
|
||||
<strong>① 失败不扣</strong>:模型超时 / 内容审核拦截 / 生成异常一律不扣费。<br />
|
||||
<strong>② 用户重跑不扣首次</strong>:第一次重跑保留原扣费,第二次起按次结算。<br />
|
||||
@@ -447,7 +452,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
<strong>④ 导出不再扣费</strong>,所有 token 已在过程中结算。
|
||||
</div>
|
||||
<div className="quota-rules">
|
||||
<div className="qr-head">// 任务确认前 · 四层额度预检(任一不通过即拦截)</div>
|
||||
<div className="qr-head">任务确认前 · 四层额度预检(任一不通过即拦截)</div>
|
||||
<div className="step"><span className="num">1</span><span><strong>个人日剩余</strong> ≥ 任务预估 × <span className="formula">1.2</span></span></div>
|
||||
<div className="step"><span className="num">2</span><span><strong>个人月剩余</strong> ≥ 同上</span></div>
|
||||
<div className="step"><span className="num">3</span><span><strong>团队月剩余</strong> ≥ 同上</span></div>
|
||||
@@ -482,9 +487,9 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
<thead><tr><th>时间</th><th>项目 / 类型</th><th>详情</th><th>成员</th><th>状态</th><th style={{ textAlign: "right" }}>金额</th></tr></thead>
|
||||
<tbody>
|
||||
{ledgersLoading && ledgerRows.length === 0 ? (
|
||||
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 加载中…</td></tr>
|
||||
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>加载中…</td></tr>
|
||||
) : ledgerRows.length === 0 ? (
|
||||
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>{billFiltered ? "// 无匹配筛选条件的账单" : "// 暂无账单流水"}</td></tr>
|
||||
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>{billFiltered ? "无匹配筛选条件的账单" : "暂无账单流水"}</td></tr>
|
||||
) : ledgerRows.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
|
||||
@@ -502,7 +507,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
</div>
|
||||
{ledgerCount > BILLS_PER_PAGE && (
|
||||
<div className="bill-pager">
|
||||
<span className="total">// 共 {ledgerCount} 条 · 第 {safeBillPage} / {billTotalPages} 页</span>
|
||||
<span className="total">共 {ledgerCount} 条 · 第 {safeBillPage} / {billTotalPages} 页</span>
|
||||
<div className="pages">
|
||||
<button type="button" disabled={safeBillPage <= 1} onClick={() => setBillPage(safeBillPage - 1)}>上一页</button>
|
||||
{pageWindow(safeBillPage, billTotalPages).map((p, i) => (
|
||||
@@ -548,7 +553,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
<thead><tr><th>项目</th><th>商品</th><th>当前阶段</th><th>状态</th><th style={{ textAlign: "right" }}>消耗</th></tr></thead>
|
||||
<tbody>
|
||||
{filteredProjects.length === 0 ? (
|
||||
<tr><td colSpan={5} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 当前筛选条件下没有项目</td></tr>
|
||||
<tr><td colSpan={5} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>当前筛选条件下没有项目</td></tr>
|
||||
) : filteredProjects.map((p) => {
|
||||
const spend = projectSpend(p.id);
|
||||
const stg = projStage(p);
|
||||
@@ -587,7 +592,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
<thead><tr><th>成员</th><th>角色</th><th>已用 / 月度额度</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
{filteredMembers.length === 0 ? (
|
||||
<tr><td colSpan={4} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 当前筛选条件下没有成员</td></tr>
|
||||
<tr><td colSpan={4} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>当前筛选条件下没有成员</td></tr>
|
||||
) : filteredMembers.map((m) => {
|
||||
const monthly = Number(m.monthly_credit_limit || 0);
|
||||
const monthUsed = Number(m.month_charged || 0);
|
||||
@@ -600,7 +605,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
|
||||
<td className="quota">
|
||||
<span className="used">{money(monthUsed)}</span> <span className="lim">/ {monthly > 0 ? money(monthly) : "不限"}{monthly > 0 ? ` · ${usedPct.toFixed(1)}%` : ""}</span>
|
||||
{monthly > 0 && (
|
||||
<span className="progress-mini"><span style={{ width: `${usedPct}%`, background: usedPct >= 85 ? "var(--accent-honey)" : "var(--heat)" }} /></span>
|
||||
<span className="progress-mini"><span style={{ width: `${usedPct}%`, background: usedPct >= 85 ? "var(--accent-honey)" : "var(--klein)" }} /></span>
|
||||
)}
|
||||
</td>
|
||||
<td>{STATUS_LABEL[m.status] || m.status}</td>
|
||||
|
||||
@@ -82,7 +82,7 @@ export function AdminApp({ section, user, team, navigateAdmin, navigate, logout
|
||||
navigateAdmin("");
|
||||
}}
|
||||
>
|
||||
<span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span>
|
||||
<span className="brand-clip"><img className="brand-logo" src="/assets/yz/logo-horizontal.png" alt="影擎" /></span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="admin-badge mono">[ PLATFORM ADMIN ]</div>
|
||||
@@ -127,7 +127,7 @@ export function AdminApp({ section, user, team, navigateAdmin, navigate, logout
|
||||
<div className="av">{(user.username || "A").slice(0, 1).toUpperCase()}</div>
|
||||
<div className="admin-user-meta">
|
||||
<span className="nm">{user.username}</span>
|
||||
<span className="rl mono">// 平台超管</span>
|
||||
<span className="rl mono">平台超管</span>
|
||||
</div>
|
||||
<button type="button" className="icon-btn admin-logout" title="退出登录" aria-label="退出登录" onClick={logout}>
|
||||
<IconKitSvg name="logOut" size={16} />
|
||||
@@ -218,7 +218,7 @@ function AdminOverview({ navigateAdmin }: { navigateAdmin: (s: string) => void }
|
||||
<div>
|
||||
<h1>平台概览</h1>
|
||||
<div className="sub">
|
||||
<span className="mono">// 跨团队后台</span> · 邀请码、团队用户、内容审核、生成与计费治理
|
||||
<span className="mono">跨团队后台</span> · 邀请码、团队用户、内容审核、生成与计费治理
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,7 +232,7 @@ function AdminOverview({ navigateAdmin }: { navigateAdmin: (s: string) => void }
|
||||
<span className="ic"><IconKitSvg name={s.icon} size={16} /></span>
|
||||
<span className="admin-shortcut-text">
|
||||
<span className="t">{s.label}</span>
|
||||
<span className="d">// {s.slug}</span>
|
||||
<span className="d">{s.slug}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -248,14 +248,14 @@ function AdminPlaceholder({ section }: { section: AdminSection }) {
|
||||
<div>
|
||||
<h1>{section.label}</h1>
|
||||
<div className="sub">
|
||||
<span className="mono">// admin · {section.slug}</span>
|
||||
<span className="mono">admin · {section.slug}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="empty-state show">
|
||||
<div className="ic-empty"><IconKitSvg name={section.icon} size={24} /></div>
|
||||
<h3>模块即将上线</h3>
|
||||
<p>// 计划于 Phase {section.phase} 落地</p>
|
||||
<p>计划于 Phase {section.phase} 落地</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -159,7 +159,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>计费审计</h1>
|
||||
<div className="sub"><span className="mono">// {count} 条流水</span> · 全局信用流水 · 手动调额</div>
|
||||
<div className="sub"><span className="mono">{count} 条流水</span> · 全局信用流水 · 手动调额</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary" type="button" onClick={() => setModalOpen(true)}>+ 手动调额</button>
|
||||
@@ -178,7 +178,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
||||
{loading ? (
|
||||
<SystemLoading title="正在加载计费数据" description="正在同步最新数据,请稍候。" icon="creditCard" />
|
||||
) : ledgers.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3>暂无流水</h3><p>// no ledgers</p></div>
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3>暂无流水</h3></div>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="t admin-table">
|
||||
@@ -207,7 +207,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><Wallet size={16} /></div>
|
||||
<div className="ti">手动调额<span>// credit adjust</span></div>
|
||||
<div className="ti">手动调额</div>
|
||||
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModalOpen(false)}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
@@ -324,7 +324,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>额度策略</h1>
|
||||
<div className="sub"><span className="mono">// {count} 条</span> · 4 层额度(团队月度 / 项目 / 单任务)· 设了才拦,留空不限</div>
|
||||
<div className="sub"><span className="mono">{count} 条</span> · 4 层额度(团队月度 / 项目 / 单任务)· 设了才拦,留空不限</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary" type="button" onClick={openNew}>+ 新建策略</button>
|
||||
@@ -334,7 +334,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
|
||||
{loading ? (
|
||||
<SystemLoading title="正在加载额度配置" description="正在同步最新数据,请稍候。" icon="gauge" />
|
||||
) : policies.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3>暂无额度策略</h3><p>// 默认仅余额管控 · 点右上新建</p></div>
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3>暂无额度策略</h3><p>默认仅余额管控 · 点右上新建</p></div>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="t admin-table">
|
||||
@@ -365,7 +365,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><Gauge size={16} /></div>
|
||||
<div className="ti">{editing.id ? "编辑额度策略" : "新建额度策略"}<span>// quota policy</span></div>
|
||||
<div className="ti">{editing.id ? "编辑额度策略" : "新建额度策略"}</div>
|
||||
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModalOpen(false)}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
|
||||
@@ -77,7 +77,7 @@ export function AdminGovernancePage({ notify }: { notify: Notify }) {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>治理</h1>
|
||||
<div className="sub"><span className="mono">// 项目监控 + 数据完整性</span> · 跨团队项目流水线 · 孤儿记录体检</div>
|
||||
<div className="sub"><span className="mono">项目监控 + 数据完整性</span> · 跨团队项目流水线 · 孤儿记录体检</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" disabled={checking} onClick={() => void runCheck()}>
|
||||
@@ -110,7 +110,7 @@ export function AdminGovernancePage({ notify }: { notify: Notify }) {
|
||||
{loading ? (
|
||||
<SystemLoading title="正在加载项目" description="正在同步最新数据,请稍候。" icon="clapperboard" />
|
||||
) : projects.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="clapperboard" size={24} /></div><h3>暂无项目</h3><p>// no projects</p></div>
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="clapperboard" size={24} /></div><h3>暂无项目</h3></div>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="t admin-table">
|
||||
|
||||
@@ -104,7 +104,7 @@ export function AdminInvitesPage({ notify }: { notify: Notify }) {
|
||||
<div>
|
||||
<h1>邀请码</h1>
|
||||
<div className="sub">
|
||||
<span className="mono">// {count} 个</span> · 平台发「开团队码」开新团队;团管发「加入码」拉成员
|
||||
<span className="mono">{count} 个</span> · 平台发「开团队码」开新团队;团管发「加入码」拉成员
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
@@ -135,7 +135,7 @@ export function AdminInvitesPage({ notify }: { notify: Notify }) {
|
||||
<div className="empty-state show">
|
||||
<div className="ic-empty"><IconKitSvg name="ticket" size={24} /></div>
|
||||
<h3>暂无邀请码</h3>
|
||||
<p>// 点右上「发开团队码」生成第一个</p>
|
||||
<p>点右上「发开团队码」生成第一个</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
@@ -191,7 +191,7 @@ export function AdminInvitesPage({ notify }: { notify: Notify }) {
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><Ticket size={16} /></div>
|
||||
<div className="ti">发放开团队码<span>// issue create_team</span></div>
|
||||
<div className="ti">发放开团队码</div>
|
||||
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModalOpen(false)}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
|
||||
@@ -119,7 +119,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>模型供应商</h1>
|
||||
<div className="sub"><span className="mono">// {providers.length} 供应商 · {models.length} 模型</span> · 热插拔中转站 · 定价 · 设默认</div>
|
||||
<div className="sub"><span className="mono">{providers.length} 供应商 · {models.length} 模型</span> · 热插拔中转站 · 定价 · 设默认</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" onClick={() => setProvModal({ ...EMPTY_PROVIDER })}>+ 供应商</button>
|
||||
@@ -188,7 +188,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><Server size={16} /></div>
|
||||
<div className="ti">{provModal.id ? "编辑供应商" : "新建供应商"}<span>// model provider</span></div>
|
||||
<div className="ti">{provModal.id ? "编辑供应商" : "新建供应商"}</div>
|
||||
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setProvModal(null)}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
@@ -223,7 +223,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><Server size={16} /></div>
|
||||
<div className="ti">{modelModal.id ? "编辑模型" : "新建模型"}<span>// model config</span></div>
|
||||
<div className="ti">{modelModal.id ? "编辑模型" : "新建模型"}</div>
|
||||
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModelModal(null)}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
|
||||
@@ -64,7 +64,7 @@ export function AdminPromptsPage({ notify }: { notify: Notify }) {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>提示词</h1>
|
||||
<div className="sub"><span className="mono">// 视频线 6 条</span> · 生图/视频提示词正文 + 比例可改;留空或停用 → 回落写死默认</div>
|
||||
<div className="sub"><span className="mono">视频线 6 条</span> · 生图/视频提示词正文 + 比例可改;留空或停用 → 回落写死默认</div>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
@@ -79,7 +79,7 @@ export function AdminPromptsPage({ notify }: { notify: Notify }) {
|
||||
<div key={r.id} className={`card-hard pt-card${r.enabled ? "" : " pt-off"}`}>
|
||||
<div className="pt-head">
|
||||
<span className="pt-title">{r.label}</span>
|
||||
<span className="mono pt-key">// {r.key}</span>
|
||||
<span className="mono pt-key">{r.key}</span>
|
||||
<span className="spacer" />
|
||||
<label className="pt-enabled">
|
||||
<input type="checkbox" checked={r.enabled} onChange={(e) => void toggle(r, e.target.checked)} />
|
||||
|
||||
@@ -74,7 +74,7 @@ export function AdminQualityPage({ notify }: { notify: Notify }) {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>质量词</h1>
|
||||
<div className="sub"><span className="mono">// 平台单层</span> · 各生成阶段的画质 / 风格词,留空则用默认</div>
|
||||
<div className="sub"><span className="mono">平台单层</span> · 各生成阶段的画质 / 风格词,留空则用默认</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className={`btn${editMode ? " btn-primary" : ""}`} type="button" onClick={() => setEditMode((v) => !v)}>
|
||||
@@ -93,7 +93,7 @@ export function AdminQualityPage({ notify }: { notify: Notify }) {
|
||||
<div key={s.key} className="card-hard qw-stage">
|
||||
<div className="qw-stage-h">
|
||||
<span className="qw-stage-title">{s.label}</span>
|
||||
<span className="mono qw-stage-hint">// {s.key}</span>
|
||||
<span className="mono qw-stage-hint">{s.key}</span>
|
||||
</div>
|
||||
{ws.length === 0 && !editMode && <div className="qw-empty mono">未配置 · 使用默认质量词</div>}
|
||||
<div className="qw-chips">
|
||||
|
||||
@@ -98,7 +98,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>资产审核</h1>
|
||||
<div className="sub"><span className="mono">// {count} 个真人资产</span> · 火山人像素材库绿盾 / 红标</div>
|
||||
<div className="sub"><span className="mono">{count} 个真人资产</span> · 火山人像素材库绿盾 / 红标</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" disabled={busy} onClick={() => void poll()}>
|
||||
@@ -118,7 +118,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
||||
{loading ? (
|
||||
<SystemLoading title="正在加载审核数据" description="正在同步最新数据,请稍候。" icon="shield" />
|
||||
) : assets.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="shield" size={24} /></div><h3>暂无资产</h3><p>// no person assets</p></div>
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="shield" size={24} /></div><h3>暂无资产</h3></div>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="t admin-table">
|
||||
|
||||
@@ -112,7 +112,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>任务监控</h1>
|
||||
<div className="sub"><span className="mono">// {count} 个任务</span> · 全局 AI 任务 · 成本异常 · 失败重投</div>
|
||||
<div className="sub"><span className="mono">{count} 个任务</span> · 全局 AI 任务 · 成本异常 · 失败重投</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -130,7 +130,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
|
||||
{loading ? (
|
||||
<SystemLoading title="正在加载任务" description="正在同步最新数据,请稍候。" icon="activity" />
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3>暂无任务</h3><p>// no tasks</p></div>
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3>暂无任务</h3></div>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="t admin-table">
|
||||
@@ -201,7 +201,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
|
||||
{detail.attempts.map((attempt) => (
|
||||
<div className="admin-attempt" key={attempt.id}>
|
||||
<div className="admin-attempt-head">
|
||||
<span className="admin-attempt-seq mono">// {String(attempt.sequence).padStart(2, "0")}</span>
|
||||
<span className="admin-attempt-seq mono">{String(attempt.sequence).padStart(2, "0")}</span>
|
||||
{statusPill(attempt.status)}
|
||||
<span className="admin-attempt-kind mono">{attemptKind(attempt)}</span>
|
||||
</div>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>团队</h1>
|
||||
<div className="sub"><span className="mono">// {count} 个团队</span> · 跨团队总览 · 启停 / 详情</div>
|
||||
<div className="sub"><span className="mono">{count} 个团队</span> · 跨团队总览 · 启停 / 详情</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
{loading ? (
|
||||
<SystemLoading title="正在加载团队数据" description="正在同步最新数据,请稍候。" icon="building" />
|
||||
) : teams.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="building" size={24} /></div><h3>暂无团队</h3><p>// no teams</p></div>
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="building" size={24} /></div><h3>暂无团队</h3></div>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="t admin-table">
|
||||
@@ -169,7 +169,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><Percent size={16} /></div>
|
||||
<div className="ti">团队定价<span>// {pricingTarget.name}</span></div>
|
||||
<div className="ti">团队定价<span>{pricingTarget.name}</span></div>
|
||||
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setPricingTarget(null)}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
@@ -177,7 +177,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
<div className="field">
|
||||
<label className="field-label">价格系数 <span className="lbl-note">(0.10 ~ 10.00,1.00 = 标准价)</span></label>
|
||||
<input className="input num-input" type="number" step="0.05" min="0.1" max="10" value={pricingValue} onChange={(e) => setPricingValue(e.target.value)} />
|
||||
{Number(pricingValue) < lossThreshold && <div className="field-hint">// ⚠ 低于 ×{lossThreshold.toFixed(2)} 时视频将卖低于平台成本(毛利 ×{videoMargin} 被抵消),I9 审计会告警</div>}
|
||||
{Number(pricingValue) < lossThreshold && <div className="field-hint">⚠ 低于 ×{lossThreshold.toFixed(2)} 时视频将卖低于平台成本(毛利 ×{videoMargin} 被抵消),I9 审计会告警</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-f">
|
||||
@@ -194,7 +194,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><Building size={16} /></div>
|
||||
<div className="ti">{detail?.name || "团队详情"}<span>// team detail</span></div>
|
||||
<div className="ti">{detail?.name || "团队详情"}</div>
|
||||
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setDetail(null)}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
@@ -298,7 +298,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>用户</h1>
|
||||
<div className="sub"><span className="mono">// {count} 个用户</span> · 跨团队 · 启停 / 强制改密</div>
|
||||
<div className="sub"><span className="mono">{count} 个用户</span> · 跨团队 · 启停 / 强制改密</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -314,7 +314,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
{loading ? (
|
||||
<SystemLoading title="正在加载用户数据" description="正在同步最新数据,请稍候。" icon="users" />
|
||||
) : users.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="users" size={24} /></div><h3>暂无用户</h3><p>// no users</p></div>
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="users" size={24} /></div><h3>暂无用户</h3></div>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="t admin-table">
|
||||
@@ -361,7 +361,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><KeyRound size={16} /></div>
|
||||
<div className="ti">重置密码<span>// {pwdTarget.username}</span></div>
|
||||
<div className="ti">重置密码<span>{pwdTarget.username}</span></div>
|
||||
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setPwdTarget(null)}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,24 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Boxes,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
Lock,
|
||||
LockKeyhole,
|
||||
ScanLine,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Ticket,
|
||||
UserRound,
|
||||
Users
|
||||
Users,
|
||||
Workflow
|
||||
} from "lucide-react";
|
||||
import { api, getRemember, setRemember as persistRemember } from "../api";
|
||||
import type { Team, User } from "../types";
|
||||
import type { AuthMode } from "./route-config";
|
||||
import "../login-page.css";
|
||||
|
||||
type LoginProgress = "checking" | "entering";
|
||||
|
||||
@@ -23,6 +28,10 @@ const LOGIN_PROGRESS_COPY: Record<LoginProgress, string> = {
|
||||
entering: "登录成功,正在进入工作台…"
|
||||
};
|
||||
|
||||
const LOGIN_PLUS_MARKS = Array.from({ length: 168 }, (_, index) => (
|
||||
<span key={index}>+</span>
|
||||
));
|
||||
|
||||
type FieldErrors = {
|
||||
username?: string;
|
||||
password?: string;
|
||||
@@ -252,48 +261,76 @@ export function AuthScreen({
|
||||
}
|
||||
|
||||
const brand = (
|
||||
<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 === "register" ? (
|
||||
<>
|
||||
<h1>开通团队,<br />拍出第一条 <span className="h">带货短剧</span></h1>
|
||||
<p>1-2 小时,从一张商品图,到一条能卖货的短剧。</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1>让每件商品<br />都有自己的<span className="h">带货短剧</span></h1>
|
||||
<p>商品图进,带货短剧出 —— 你只管挑成片。</p>
|
||||
</>
|
||||
)}
|
||||
<aside className="login-visual">
|
||||
<div className="login-brand-lockup">
|
||||
<img className="login-logo" src="/assets/yz/logo-on-dark.png" alt="影擎" />
|
||||
<div className="login-brand-copy"><span>YINGQING AIGC STUDIO</span></div>
|
||||
</div>
|
||||
<div className="login-hero">
|
||||
<p className="login-eyebrow">// AIGC COMMERCE CONTENT ENGINE</p>
|
||||
<div className="login-hero-copy">
|
||||
<h1>
|
||||
<span className="login-hero-line">让每一次商品表达,</span>
|
||||
<span className="login-hero-line login-hero-accent">更快成为内容。</span>
|
||||
</h1>
|
||||
<p>从商品资产、脚本、故事板到视频生成,以统一的创作工作流连接品牌内容生产的每一个环节。</p>
|
||||
</div>
|
||||
<div className="login-capabilities" aria-label="平台能力">
|
||||
<div className="login-capability">
|
||||
<Sparkles />
|
||||
<strong>智能创作</strong>
|
||||
<span>图像与视频生成</span>
|
||||
</div>
|
||||
<div className="login-capability">
|
||||
<Workflow />
|
||||
<strong>流程协同</strong>
|
||||
<span>五阶段项目管理</span>
|
||||
</div>
|
||||
<div className="login-capability">
|
||||
<Boxes />
|
||||
<strong>资产沉淀</strong>
|
||||
<span>商品、角色与场景</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-visual-footer">
|
||||
<span>© 2026 影擎智能内容平台</span>
|
||||
<span className="login-system-status">创作服务正常</span>
|
||||
</div>
|
||||
<div className="foot"><a href="#">关于</a><a href="#">定价</a><a href="#">联系</a><a href="#">隐私</a></div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
const toastNode = toast ? (
|
||||
<div className="login-toast" role="status" aria-live="polite">
|
||||
{toast.title}
|
||||
<span>{toast.sub}</span>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (mode === "register") {
|
||||
return (
|
||||
<main className="auth-exact-page auth-register-page">
|
||||
<div className="auth-wrap">
|
||||
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
|
||||
{brand}
|
||||
<section className="auth-form" aria-label="注册">
|
||||
<div className="h-row"><h2>注册</h2><span className="sub">// /auth/register</span></div>
|
||||
<p className="lead">
|
||||
{inviteState.status === "valid"
|
||||
? inviteState.kind === "create_team"
|
||||
? "邀请码有效 · 开新团队,你将成为团队超管。"
|
||||
: `邀请码有效 · 加入「${inviteState.teamName || "团队"}」。`
|
||||
: "输入邀请码开通账户 —— 没有码请联系团队管理员或平台。"}
|
||||
</p>
|
||||
<form id="register-form" autoComplete="off" onSubmit={submitRegister} noValidate>
|
||||
{/* 第一步:邀请码(必填)· 验过才展开后续表单 */}
|
||||
<div className={`field${inviteState.status === "invalid" ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-invite">邀请码 <span className="req">*</span></label>
|
||||
<div className="invite-verify-row">
|
||||
<div className="field-input-wrap">
|
||||
<Ticket className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<section className="login-page" aria-labelledby="loginTitle">
|
||||
{brand}
|
||||
<main className="login-form-panel">
|
||||
<div className="login-plus-pattern" aria-hidden="true">{LOGIN_PLUS_MARKS}</div>
|
||||
<div className="login-card is-register">
|
||||
<div className="login-card-heading">
|
||||
<span className="login-step"><ShieldCheck />安全访问</span>
|
||||
<h2 id="loginTitle">开通账户</h2>
|
||||
<p>
|
||||
{inviteState.status === "valid"
|
||||
? inviteState.kind === "create_team"
|
||||
? "邀请码有效 · 开新团队,你将成为团队超管。"
|
||||
: `邀请码有效 · 加入「${inviteState.teamName || "团队"}」。`
|
||||
: "输入邀请码开通账户 —— 没有码请联系团队管理员或平台。"}
|
||||
</p>
|
||||
</div>
|
||||
<form className="login-form" autoComplete="off" onSubmit={submitRegister} noValidate>
|
||||
<label className={`login-field${inviteState.status === "invalid" ? " has-error" : ""}`}>
|
||||
<span>邀请码 <em className="req">*</em></span>
|
||||
<div className="login-verify">
|
||||
<span className="login-input-wrap">
|
||||
<Ticket />
|
||||
<input
|
||||
id="reg-invite"
|
||||
type="text"
|
||||
@@ -304,33 +341,32 @@ export function AuthScreen({
|
||||
onChange={(event) => onInviteChange(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void checkInvite(); } }}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn invite-verify-btn"
|
||||
className="login-verify-btn"
|
||||
disabled={inviteState.status === "checking" || !regInvite.trim()}
|
||||
onClick={() => void checkInvite()}
|
||||
>
|
||||
{inviteState.status === "checking" ? "验证中…" : inviteState.status === "valid" ? "重新验证" : "验证"}
|
||||
</button>
|
||||
</div>
|
||||
{inviteState.status === "invalid" && <p className="field-note err">{inviteState.detail}</p>}
|
||||
{inviteState.status === "invalid" && <p className="login-note err">{inviteState.detail}</p>}
|
||||
{inviteState.status === "valid" && (
|
||||
<p className="field-note ok">
|
||||
<ShieldCheck size={13} strokeWidth={1.8} aria-hidden="true" />
|
||||
<p className="login-note ok">
|
||||
<ShieldCheck size={13} />
|
||||
{inviteState.kind === "create_team" ? "可开新团队" : `将加入「${inviteState.teamName || "团队"}」`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* 第二步:验过码后展开 —— 不暴露任何团队列表 */}
|
||||
{inviteState.status === "valid" && (
|
||||
<>
|
||||
{inviteState.kind === "create_team" ? (
|
||||
<div className={`field${regErrors.team ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-team">团队名 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<Users className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<label className={`login-field${regErrors.team ? " has-error" : ""}`}>
|
||||
<span>团队名 <em className="req">*</em></span>
|
||||
<span className="login-input-wrap">
|
||||
<Users />
|
||||
<input
|
||||
id="reg-team"
|
||||
type="text"
|
||||
@@ -339,23 +375,23 @@ export function AuthScreen({
|
||||
aria-invalid={Boolean(regErrors.team)}
|
||||
onChange={(event) => setRegValue("team", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
) : (
|
||||
<div className="field">
|
||||
<label className="field-label">团队</label>
|
||||
<div className="field-input-wrap is-readonly">
|
||||
<Users className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<label className="login-field">
|
||||
<span>团队</span>
|
||||
<span className="login-input-wrap">
|
||||
<Users />
|
||||
<input type="text" value={inviteState.teamName || "已绑定团队"} readOnly disabled />
|
||||
</div>
|
||||
<p className="field-note">邀请码已绑定该团队,你将作为成员加入。</p>
|
||||
</div>
|
||||
</span>
|
||||
<p className="login-note">邀请码已绑定该团队,你将作为成员加入。</p>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className={`field${regErrors.username ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-username">用户名 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<UserRound className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<label className={`login-field${regErrors.username ? " has-error" : ""}`}>
|
||||
<span>用户名 <em className="req">*</em></span>
|
||||
<span className="login-input-wrap">
|
||||
<UserRound />
|
||||
<input
|
||||
id="reg-username"
|
||||
type="text"
|
||||
@@ -365,14 +401,14 @@ export function AuthScreen({
|
||||
aria-invalid={Boolean(regErrors.username)}
|
||||
onChange={(event) => setRegValue("username", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="field-row">
|
||||
<div className={`field${regErrors.password ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-pwd">密码 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<LockKeyhole className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<div className="login-pass-row">
|
||||
<label className={`login-field${regErrors.password ? " has-error" : ""}`}>
|
||||
<span>密码 <em className="req">*</em></span>
|
||||
<span className="login-input-wrap">
|
||||
<LockKeyhole />
|
||||
<input
|
||||
id="reg-pwd"
|
||||
type={regShowPwd ? "text" : "password"}
|
||||
@@ -384,20 +420,18 @@ export function AuthScreen({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-pwd${regShowPwd ? " active" : ""}`}
|
||||
className="login-password-toggle"
|
||||
aria-label={regShowPwd ? "隐藏密码" : "显示密码"}
|
||||
aria-pressed={regShowPwd}
|
||||
title={regShowPwd ? "隐藏密码" : "显示密码"}
|
||||
onClick={() => setRegShowPwd((value) => !value)}
|
||||
>
|
||||
{regShowPwd ? <EyeOff size={18} strokeWidth={1.5} aria-hidden="true" /> : <Eye size={18} strokeWidth={1.5} aria-hidden="true" />}
|
||||
{regShowPwd ? <EyeOff /> : <Eye />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`field${regErrors.password2 ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-pwd2">确认密码 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<LockKeyhole className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
<label className={`login-field${regErrors.password2 ? " has-error" : ""}`}>
|
||||
<span>确认密码 <em className="req">*</em></span>
|
||||
<span className="login-input-wrap">
|
||||
<LockKeyhole />
|
||||
<input
|
||||
id="reg-pwd2"
|
||||
type={regShowPwd2 ? "text" : "password"}
|
||||
@@ -409,19 +443,17 @@ export function AuthScreen({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-pwd${regShowPwd2 ? " active" : ""}`}
|
||||
className="login-password-toggle"
|
||||
aria-label={regShowPwd2 ? "隐藏密码" : "显示密码"}
|
||||
aria-pressed={regShowPwd2}
|
||||
title={regShowPwd2 ? "隐藏密码" : "显示密码"}
|
||||
onClick={() => setRegShowPwd2((value) => !value)}
|
||||
>
|
||||
{regShowPwd2 ? <EyeOff size={18} strokeWidth={1.5} aria-hidden="true" /> : <Eye size={18} strokeWidth={1.5} aria-hidden="true" />}
|
||||
{regShowPwd2 ? <EyeOff /> : <Eye />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className={`agree${regErrors.agree ? " has-error" : ""}`}>
|
||||
<label className={`login-agree${regErrors.agree ? " has-error" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={regAgree}
|
||||
@@ -429,76 +461,72 @@ export function AuthScreen({
|
||||
/>
|
||||
<span>
|
||||
我已阅读并同意{" "}
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("用户协议", "含数据处理 / 内容生成版权 / 计费规则三章 · 完整文本将在正式版接入"); }}>用户协议</a>{" "}
|
||||
与{" "}
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("用户协议", "含数据处理 / 内容生成版权 / 计费规则三章 · 完整文本将在正式版接入"); }}>用户协议</a>
|
||||
{" "}与{" "}
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("隐私政策", "遵循《个人信息保护法》· 团队数据存中国境内 · 默认不用于模型训练"); }}>隐私政策</a>。
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{loginProgress && !error && (
|
||||
<div className="login-progress" role="status" aria-live="polite">
|
||||
<span className="login-progress-spinner" aria-hidden="true"></span>
|
||||
<span>{inviteState.kind === "create_team" ? "正在创建团队,进入工作台…" : "正在加入团队,进入工作台…"}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
|
||||
<button className="btn-cta" type="submit" disabled={busy}>
|
||||
<p className={`login-error${error ? " show" : ""}`} role="alert">{error || "\u00a0"}</p>
|
||||
<button className="login-submit" type="submit" disabled={busy}>
|
||||
{busy ? <LoaderCircle /> : <ArrowRight />}
|
||||
{busy
|
||||
? "提交中"
|
||||
: inviteState.kind === "create_team"
|
||||
? <>创建团队 · 开始使用 <ArrowRight size={15} strokeWidth={2} aria-hidden="true" /></>
|
||||
: <>加入团队 · 开始使用 <ArrowRight size={15} strokeWidth={2} aria-hidden="true" /></>}
|
||||
? "创建团队 · 开始使用"
|
||||
: "加入团队 · 开始使用"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{inviteState.status !== "valid" && error && <div className="form-error" role="alert">{error}</div>}
|
||||
|
||||
<div className="switch-row">
|
||||
已有账号? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}>去登录 →</a>
|
||||
</div>
|
||||
{inviteState.status !== "valid" && (
|
||||
<p className={`login-error${error ? " show" : ""}`} role="alert">{error || "\u00a0"}</p>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{toast && (
|
||||
<div className="toast show" role="status" aria-live="polite">
|
||||
<div className="ic-t"><Info size={13} aria-hidden="true" /></div>
|
||||
<div className="txt">{toast.title}<span className="mono">// {toast.sub}</span></div>
|
||||
<p className="login-card-footer"><Lock />登录信息仅用于当前演示会话</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<div className="login-extras">
|
||||
<p className="login-switch">
|
||||
已有账号? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}>去登录 →</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
{toastNode}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-exact-page auth-login-page">
|
||||
<div className="auth-wrap">
|
||||
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
|
||||
{brand}
|
||||
<section className="auth-form" aria-label="登录">
|
||||
<div className="h-row"><h2>登录</h2><span className="sub">// /auth/login</span></div>
|
||||
<form id="login-form" autoComplete="off" onSubmit={submitLogin} noValidate>
|
||||
<p className="lead">用团队账号登录,进入你的工作台与生产管线。</p>
|
||||
<div className={`field${fieldErrors.username ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="auth-username">用户名 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<UserRound className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<section className="login-page" aria-labelledby="loginTitle">
|
||||
{brand}
|
||||
<main className="login-form-panel">
|
||||
<div className="login-plus-pattern" aria-hidden="true">{LOGIN_PLUS_MARKS}</div>
|
||||
<div className={`login-card${error ? " shake" : ""}`}>
|
||||
<div className="login-card-heading">
|
||||
<span className="login-step"><ShieldCheck />安全访问</span>
|
||||
<h2 id="loginTitle">欢迎回来</h2>
|
||||
<p>请输入影擎账号与密码,进入内容创作工作台。</p>
|
||||
</div>
|
||||
<form className="login-form" autoComplete="off" onSubmit={submitLogin} noValidate>
|
||||
<label className={`login-field${fieldErrors.username ? " has-error" : ""}`}>
|
||||
<span>账号</span>
|
||||
<span className="login-input-wrap">
|
||||
<UserRound />
|
||||
<input
|
||||
id="auth-username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
placeholder="请输入账号"
|
||||
value={username}
|
||||
autoComplete="username"
|
||||
aria-invalid={Boolean(fieldErrors.username)}
|
||||
onChange={(event) => setFieldValue("username", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`field${fieldErrors.password ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="auth-pwd">密码 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<LockKeyhole className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
<label className={`login-field${fieldErrors.password ? " has-error" : ""}`}>
|
||||
<span>密码</span>
|
||||
<span className="login-input-wrap">
|
||||
<LockKeyhole />
|
||||
<input
|
||||
id="auth-pwd"
|
||||
type={showPwd ? "text" : "password"}
|
||||
@@ -510,53 +538,47 @@ export function AuthScreen({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-pwd${showPwd ? " active" : ""}`}
|
||||
className="login-password-toggle"
|
||||
aria-label={showPwd ? "隐藏密码" : "显示密码"}
|
||||
aria-pressed={showPwd}
|
||||
title={showPwd ? "隐藏密码" : "显示密码"}
|
||||
onClick={() => setShowPwd((value) => !value)}
|
||||
>
|
||||
{showPwd ? <EyeOff size={18} strokeWidth={1.5} aria-hidden="true" /> : <Eye size={18} strokeWidth={1.5} aria-hidden="true" />}
|
||||
{showPwd ? <EyeOff /> : <Eye />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<label><input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} /> 记住我 7 天</label>
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("重置密码", "请联系团队超管重置你的登录密码"); }}>忘记密码?</a>
|
||||
</div>
|
||||
<div className="auth-submit-zone">
|
||||
{loginProgress && !error && (
|
||||
<div className="login-progress" role="status" aria-live="polite">
|
||||
<span className="login-progress-spinner" aria-hidden="true"></span>
|
||||
<span>{LOGIN_PROGRESS_COPY[loginProgress]}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
<button className="btn-cta" type="submit" disabled={busy}>
|
||||
{busy ? "登录中" : "登录"}
|
||||
</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("微信扫码", "对接中 · 当前请用用户名 + 密码登录")}>
|
||||
<ScanLine size={14} strokeWidth={1.6} aria-hidden="true" /> 微信扫码
|
||||
</span>
|
||||
</label>
|
||||
<p className={`login-error${error ? " show" : ""}`} role="alert">
|
||||
{error || "账号或密码不正确,请重新输入。"}
|
||||
</p>
|
||||
<button className="login-submit" type="submit" disabled={busy}>
|
||||
{busy ? (LOGIN_PROGRESS_COPY[loginProgress || "checking"] || "正在进入工作台") : "确认登录"}
|
||||
{busy ? <LoaderCircle /> : <ArrowRight />}
|
||||
</button>
|
||||
<button type="button" className="sso-btn" onClick={() => showToast("飞书 SSO", "对接中 · 当前请用用户名 + 密码登录")}>
|
||||
<ShieldCheck size={14} strokeWidth={1.6} aria-hidden="true" /> 飞书 SSO
|
||||
</button>
|
||||
</div>
|
||||
<div className="switch-row">
|
||||
还没账号? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}>注册团队 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{toast && (
|
||||
<div className="toast show" role="status" aria-live="polite">
|
||||
<div className="ic-t"><Info size={13} aria-hidden="true" /></div>
|
||||
<div className="txt">{toast.title}<span className="mono">// {toast.sub}</span></div>
|
||||
<p className="login-card-footer"><Lock />登录信息仅用于当前演示会话</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<div className="login-extras">
|
||||
<div className="login-row">
|
||||
<label>
|
||||
<input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} />
|
||||
记住我 7 天
|
||||
</label>
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("重置密码", "请联系团队超管重置你的登录密码"); }}>忘记密码?</a>
|
||||
</div>
|
||||
<div className="login-sso">
|
||||
<button type="button" onClick={() => showToast("微信扫码", "对接中 · 当前请用账号 + 密码登录")}>
|
||||
<ScanLine /> 微信扫码
|
||||
</button>
|
||||
<button type="button" onClick={() => showToast("飞书 SSO", "对接中 · 当前请用账号 + 密码登录")}>
|
||||
<ShieldCheck /> 飞书 SSO
|
||||
</button>
|
||||
</div>
|
||||
<p className="login-switch">
|
||||
还没账号? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}>注册团队 →</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
{toastNode}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
FolderKanban,
|
||||
Replace,
|
||||
ScanSearch,
|
||||
WandSparkles,
|
||||
} from "lucide-react";
|
||||
import type { BillingSummary, Product, Project } from "../types";
|
||||
import type { NavigateFn, Page } from "./route-config";
|
||||
import { IconKitSvg } from "../components/IconKitSvg";
|
||||
|
||||
type CreateTone = "blue-a" | "blue-b" | "light";
|
||||
type DashTab = "all" | "wip" | "done";
|
||||
type EntryTone = "primary" | "subtle";
|
||||
|
||||
const CREATE_GROUPS: Array<{
|
||||
label: string;
|
||||
hint: string;
|
||||
cards: Array<{
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: string;
|
||||
tone: CreateTone;
|
||||
tone: EntryTone;
|
||||
page: Page;
|
||||
icon: "wand" | "folder" | "scan" | "replace";
|
||||
}>;
|
||||
}> = [
|
||||
{
|
||||
label: "从商品开始",
|
||||
hint: "围绕商品卖点生成完整带货内容",
|
||||
cards: [
|
||||
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", icon: "wand", tone: "blue-a", page: "projectWizard" },
|
||||
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", icon: "clapperboard", tone: "blue-b", page: "projectWizard" },
|
||||
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "projectWizard", icon: "wand" },
|
||||
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", tone: "primary", page: "projectWizard", icon: "folder" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "从参考视频开始",
|
||||
hint: "复用成熟视频的镜头表达与节奏",
|
||||
cards: [
|
||||
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", icon: "scan", tone: "light", page: "freeCreate" },
|
||||
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品", icon: "swap", tone: "light", page: "freeCreate" },
|
||||
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", tone: "subtle", page: "freeCreate", icon: "scan" },
|
||||
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品", tone: "subtle", page: "freeCreate", icon: "replace" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -50,7 +59,7 @@ function dashStageNo(project: Project) {
|
||||
function dashProgClass(project: Project, i: number, no: number): string {
|
||||
if (project.status === "completed") return i <= no ? "done" : "";
|
||||
if (project.status === "failed") return i === no ? "fail" : i < no ? "done" : "";
|
||||
return i < no ? "done" : i === no ? "cur" : "";
|
||||
return i < no ? "done" : i === no ? "warn" : "";
|
||||
}
|
||||
function dashStageLabel(project: Project): string {
|
||||
if (project.status === "failed") return "失败";
|
||||
@@ -67,7 +76,13 @@ function dashCardMeta(project: Project, productTitle: string): string {
|
||||
const shots = project.video_segment_count ?? project.video_segments?.length ?? 0;
|
||||
return ["专业创作", productTitle, shots ? `${shots} 镜` : null].filter(Boolean).join(" / ");
|
||||
}
|
||||
const coverStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
||||
|
||||
function EntryIcon({ name }: { name: "wand" | "folder" | "scan" | "replace" }) {
|
||||
if (name === "folder") return <FolderKanban />;
|
||||
if (name === "scan") return <ScanSearch />;
|
||||
if (name === "replace") return <Replace />;
|
||||
return <WandSparkles />;
|
||||
}
|
||||
|
||||
export function Dashboard({
|
||||
products,
|
||||
@@ -109,82 +124,84 @@ export function Dashboard({
|
||||
|
||||
const listed = useMemo(() => {
|
||||
const rows = tab === "all" ? projects : projects.filter((project) => dashBucket(project) === tab);
|
||||
return rows.slice(0, 3);
|
||||
return rows.slice(0, 7);
|
||||
}, [projects, tab]);
|
||||
|
||||
return (
|
||||
<section className="dashboard-page">
|
||||
<header className="dash-hero">
|
||||
<section className="welcome">
|
||||
<h1>欢迎回来{greetName ? `,${greetName}` : ""}</h1>
|
||||
<p className="dash-hero-sub">
|
||||
{dateLabel} · 你有{running}个项目正在进行中
|
||||
</p>
|
||||
</header>
|
||||
<p>{dateLabel} · 你有{running}个项目正在进行中</p>
|
||||
</section>
|
||||
|
||||
<section className="dash-create" aria-label="开始创作">
|
||||
<h2>开始创作</h2>
|
||||
{CREATE_GROUPS.map((group) => (
|
||||
<div className="dash-create-group" key={group.label}>
|
||||
<div className="dash-create-label">{group.label}</div>
|
||||
<div className="dash-create-row">
|
||||
{group.cards.map((card) => (
|
||||
<button
|
||||
key={card.title}
|
||||
className={`dash-create-card ${card.tone}`}
|
||||
type="button"
|
||||
onClick={() => navigate(card.page)}
|
||||
>
|
||||
<span className="dash-create-ic" aria-hidden="true">
|
||||
<IconKitSvg name={card.icon} size={22} strokeWidth={1.7} />
|
||||
</span>
|
||||
<span className="dash-create-copy">
|
||||
<span className="t">{card.title}</span>
|
||||
<span className="d">{card.desc}</span>
|
||||
</span>
|
||||
<span className="dash-create-arrow" aria-hidden="true">
|
||||
<IconKitSvg name="arrowRight" size={18} strokeWidth={1.8} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<section className="creation" aria-labelledby="creationTitle">
|
||||
<div className="creation-head">
|
||||
<span className="section-label" id="creationTitle">开始创作</span>
|
||||
</div>
|
||||
<div className="creation-groups">
|
||||
{CREATE_GROUPS.map((group) => (
|
||||
<div className="creation-group-block" key={group.label}>
|
||||
<div className="creation-group-label">
|
||||
<strong>{group.label}</strong>
|
||||
<span>{group.hint}</span>
|
||||
</div>
|
||||
<div className="creation-entry-pair">
|
||||
{group.cards.map((card) => (
|
||||
<button
|
||||
key={card.title}
|
||||
className={`entry entry-${card.tone}`}
|
||||
type="button"
|
||||
onClick={() => navigate(card.page)}
|
||||
>
|
||||
<EntryIcon name={card.icon} />
|
||||
<span className="entry-copy">
|
||||
<strong>{card.title}</strong>
|
||||
<small>{card.desc}</small>
|
||||
</span>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="dash-overview" aria-label="项目概览">
|
||||
<div className="dash-overview-h">项目概览</div>
|
||||
<div className="dash-overview-bar">
|
||||
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects", { tab: "all" })}>
|
||||
<span className="lbl">总项目</span>
|
||||
<span className="v">{projCount}</span>
|
||||
</button>
|
||||
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects", { tab: "wip" })}>
|
||||
<span className="lbl">进行中</span>
|
||||
<span className="v">{running}</span>
|
||||
</button>
|
||||
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects", { tab: "done" })}>
|
||||
<span className="lbl">成片</span>
|
||||
<span className="v">{completed}</span>
|
||||
</button>
|
||||
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects")}>
|
||||
<span className="lbl">本月</span>
|
||||
<span className="v">+{newThisMonth}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dash-mine" aria-label="我的项目">
|
||||
<div className="dash-mine-head">
|
||||
<div className="workbench-section-divider"><span>项目概览</span></div>
|
||||
|
||||
<section className="status-summary" aria-label="项目概览">
|
||||
<button className="summary-item" type="button" onClick={() => navigate("projects", { tab: "all" })}>
|
||||
<span>总项目</span>
|
||||
<strong>{projCount}</strong>
|
||||
</button>
|
||||
<button className="summary-item" type="button" onClick={() => navigate("projects", { tab: "wip" })}>
|
||||
<span>进行中</span>
|
||||
<strong>{running}</strong>
|
||||
</button>
|
||||
<button className="summary-item" type="button" onClick={() => navigate("projects", { tab: "done" })}>
|
||||
<span>成片</span>
|
||||
<strong>{completed}</strong>
|
||||
</button>
|
||||
<button className="summary-item" type="button" onClick={() => navigate("projects")}>
|
||||
<span>本月</span>
|
||||
<strong className="positive">+{newThisMonth}</strong>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="projects">
|
||||
<div className="projects-head">
|
||||
<h2>我的项目</h2>
|
||||
<button className="dash-mine-all" type="button" onClick={() => navigate("projects")}>
|
||||
查看全部项目 <IconKitSvg name="chevronRight" size={14} />
|
||||
<button className="text-action" type="button" onClick={() => navigate("projects")}>
|
||||
<span>查看全部项目</span>
|
||||
<ChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dash-mine-pills" role="tablist" aria-label="项目筛选">
|
||||
|
||||
<div className="filters" role="tablist" aria-label="项目筛选">
|
||||
{DASH_TABS.map((item) => (
|
||||
<button
|
||||
key={item.filter}
|
||||
className={`dash-mine-pill${tab === item.filter ? " active" : ""}`}
|
||||
className={`filter${tab === item.filter ? " active" : ""}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === item.filter}
|
||||
@@ -194,49 +211,53 @@ export function Dashboard({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dash-mine-list">
|
||||
{listed.length === 0 ? (
|
||||
<div className="dash-mine-empty">还没有项目,从上面开始创作</div>
|
||||
) : listed.map((project) => {
|
||||
const no = dashStageNo(project);
|
||||
const cover = project.cover_preview_url || "";
|
||||
const openProject = () => navigate("pipeline", { projectId: project.id });
|
||||
return (
|
||||
<article
|
||||
key={project.id}
|
||||
className="dash-proj-card"
|
||||
onClick={openProject}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={`placeholder dash-proj-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}>
|
||||
<span className="ph-frame">9:16</span>
|
||||
</div>
|
||||
<div className="dash-proj-main">
|
||||
<div className="dash-proj-title">{project.name}</div>
|
||||
<div className="dash-proj-sub">{dashCardMeta(project, productTitle(project.product))}</div>
|
||||
</div>
|
||||
<div className="dash-proj-stage">
|
||||
<div className="dash-proj-stage-lbl">当前阶段</div>
|
||||
<div className="dash-proj-stage-name">{dashStageLabel(project)}</div>
|
||||
<div className="dash-proj-prog">
|
||||
{Array.from({ length: DASH_STAGE_TOTAL }, (_, k) => k + 1).map((i) => (
|
||||
<span key={i} className={dashProgClass(project, i, no)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="dash-proj-cta"
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); openProject(); }}
|
||||
|
||||
{listed.length === 0 ? (
|
||||
<div className="empty-state">当前筛选下暂无展示项目</div>
|
||||
) : (
|
||||
<div className="project-list">
|
||||
{listed.map((project) => {
|
||||
const no = dashStageNo(project);
|
||||
const cover = project.cover_preview_url || "";
|
||||
const openProject = () => navigate("pipeline", { projectId: project.id });
|
||||
return (
|
||||
<article
|
||||
key={project.id}
|
||||
className="project-card"
|
||||
data-status={dashBucket(project)}
|
||||
onClick={openProject}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{project.status === "completed" ? "查看" : "继续"}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="product-thumb">
|
||||
{cover ? <img src={cover} alt="" /> : <span className="ph-frame">9:16</span>}
|
||||
</div>
|
||||
<div className="project-info">
|
||||
<h3>{project.name}</h3>
|
||||
<p>{dashCardMeta(project, productTitle(project.product))}</p>
|
||||
</div>
|
||||
<div className="stage">
|
||||
<span className="stage-label">当前阶段</span>
|
||||
<strong>{dashStageLabel(project)}</strong>
|
||||
<div className="progress" aria-label="项目进度">
|
||||
{Array.from({ length: DASH_STAGE_TOTAL }, (_, k) => k + 1).map((i) => (
|
||||
<span key={i} className={`segment ${dashProgClass(project, i, no)}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="continue-button"
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); openProject(); }}
|
||||
>
|
||||
{project.status === "completed" ? "查看" : "继续"}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 任务流(滚动加载)+ 底部输入条(全能参考/首尾帧 + @mention)+ 渐进轮询(10/30/60s 打后端
|
||||
// poll 端点,web 进程内查火山,不依赖 worker)+ 平滑进度动画(sessionStorage 续)+ 全屏播放器。
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Images, Users } from "lucide-react";
|
||||
import { ArrowLeft, Clapperboard, Images, Users } from "lucide-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types";
|
||||
import {
|
||||
@@ -49,10 +49,11 @@ function pollDelay(count: number): number {
|
||||
let refKeySeq = 0;
|
||||
const nextRefKey = () => `ref-${Date.now()}-${refKeySeq++}`;
|
||||
|
||||
export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }: {
|
||||
modelConfigs: ModelConfig[];
|
||||
onNotify: (type: "success" | "error" | "info", text: string) => void;
|
||||
onTaskSettled: () => void;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
// App 每次渲染会重建 onNotify 箭头函数;用 ref 稳定身份,否则依赖它的 effect(首屏拉取/轮询)
|
||||
// 会随 App 任意 setState(如 30s 未读轮询)反复重跑 → 任务流无谓全量刷新(实测踩坑)。
|
||||
@@ -78,11 +79,12 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
|
||||
// —— 任务流 ——
|
||||
const [tasks, setTasks] = useState<FreeVideoTask[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [, setTotal] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const loadingMoreRef = useRef(false);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// —— 输入条 ——
|
||||
const [mode, setMode] = useState<FreeMode>("universal");
|
||||
@@ -191,9 +193,10 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
};
|
||||
}, [schedulePoll, notify]);
|
||||
|
||||
// 滚动加载更多(旧任务)
|
||||
// 滚动加载更多(旧任务)· 滚动容器是中间作品区,不是整页
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
const root = stageRef.current;
|
||||
if (!sentinel || !hasMore) return;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (!entries[0].isIntersecting || loadingMoreRef.current) return;
|
||||
@@ -209,7 +212,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => { loadingMoreRef.current = false; });
|
||||
}, { rootMargin: "200px" });
|
||||
}, { root, rootMargin: "200px" });
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore]);
|
||||
@@ -526,31 +529,38 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
|
||||
return (
|
||||
<div className="fc-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>自由创作</h1>
|
||||
<div className="sub">
|
||||
<span className="mono">// {total} 个视频</span> · AI 视频生成 · 全能参考 / 首尾帧
|
||||
<header className="fc-head">
|
||||
<div className="fc-title-row">
|
||||
{onBack ? (
|
||||
<button type="button" className="fc-back" aria-label="返回上一入口页面" onClick={onBack}>
|
||||
<ArrowLeft />
|
||||
</button>
|
||||
) : null}
|
||||
<div>
|
||||
<h1>自由生成</h1>
|
||||
<p>使用提示词和参考素材,自由控制视频画面与参数</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn" onClick={() => openPlatformLibrary()}>
|
||||
<Images size={14} /> 引用平台素材
|
||||
<div className="fc-material">
|
||||
<button type="button" className="fc-ghost" onClick={() => openLibrary()}>
|
||||
<Users />
|
||||
<span>人物素材库</span>
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => openLibrary()}>
|
||||
<Users size={14} /> 人物素材库
|
||||
<button type="button" className="fc-ghost" onClick={() => openPlatformLibrary()}>
|
||||
<Images />
|
||||
<span>引用平台素材</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="fc-feed-wrap">
|
||||
<div className="fc-stage" ref={stageRef}>
|
||||
{loading ? (
|
||||
<SystemLoading title="正在加载作品" description="正在同步最新数据,请稍候。" icon="film" />
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="empty-state show fc-empty">
|
||||
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
|
||||
<h3>还没有作品</h3>
|
||||
<p>// 在下方输入提示词,生成你的第一条视频</p>
|
||||
<div className="fc-empty">
|
||||
<div className="fc-empty-icon"><Clapperboard /></div>
|
||||
<strong>准备生成新视频</strong>
|
||||
<p>在下方输入提示词,或从素材库引用参考素材</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fc-feed">
|
||||
@@ -568,7 +578,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && <div ref={sentinelRef} className="fc-sentinel mono">// 下滑加载更多</div>}
|
||||
{hasMore && <div ref={sentinelRef} className="fc-sentinel">下滑加载更多</div>}
|
||||
</div>
|
||||
|
||||
<FreeInputBar
|
||||
@@ -625,7 +635,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
<ConfirmModal
|
||||
open={deleteTarget !== null}
|
||||
title="删除视频"
|
||||
detail={`确定删除这条视频吗?删除后可在资产库垃圾桶找回素材,任务记录不可恢复。`}
|
||||
detail={`确定删除这条视频吗?删除后可在垃圾桶找回素材,任务记录不可恢复。`}
|
||||
confirmText="确认删除"
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => void confirmDelete()}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Trash2, Upload, X } from "lucide-react";
|
||||
import { Check, ChevronDown, Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Search, Settings2, Trash2, Upload, X } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { SkeletonGrid } from "../components/loading";
|
||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||
@@ -103,6 +103,19 @@ function freeVideoToPack(asset: Asset): VideoPack {
|
||||
};
|
||||
}
|
||||
|
||||
function createdLabel(iso?: string): string {
|
||||
if (!iso) return "";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const now = new Date();
|
||||
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const startThat = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const diffDays = Math.round((startToday.getTime() - startThat.getTime()) / 86400000);
|
||||
if (diffDays === 0) return "今天";
|
||||
if (diffDays === 1) return "昨天";
|
||||
return `${String(date.getMonth() + 1).padStart(2, "0")}.${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function videoPackKey(pack: VideoPack): string {
|
||||
return pack.project_id || pack.clips[0]?.id || pack.project_name;
|
||||
}
|
||||
@@ -266,7 +279,7 @@ function AssetDetailModal({ asset, close, onZoom }: {
|
||||
<span className="ic"><Info size={14} /></span>
|
||||
<span className="t">简介</span>
|
||||
</div>
|
||||
<p className="adx-intro">{intro || <span className="adx-muted">// 暂无简介</span>}</p>
|
||||
<p className="adx-intro">{intro || <span className="adx-muted">暂无简介</span>}</p>
|
||||
{tags.length > 0 && (
|
||||
<div className="adx-tags">
|
||||
{tags.map((t) => <span className="adx-tag" key={t}>{t}</span>)}
|
||||
@@ -316,7 +329,7 @@ function AssetDetailModal({ asset, close, onZoom }: {
|
||||
<Download size={13} /> 下载
|
||||
</a>
|
||||
)}
|
||||
<span className="adx-foot-meta mono">// {catLabel} · {srcLabel} · {createdAt}</span>
|
||||
<span className="adx-foot-meta mono">{catLabel} · {srcLabel} · {createdAt}</span>
|
||||
<button className="btn btn-primary" type="button" onClick={close}>完成</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,7 +389,7 @@ function UploadModal({ open, close, onSubmit }: {
|
||||
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
|
||||
<div className="modal upload-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h"><div className="ic-m"><Upload size={16} /></div><div className="ti">上传资产<span>// UPLOAD</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
||||
<div className="modal-h"><div className="ic-m"><Upload size={16} /></div><div className="ti">上传资产</div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
||||
<form className="upload-modal-form" onSubmit={submit}>
|
||||
<div className="modal-b">
|
||||
<div className="field">
|
||||
@@ -424,7 +437,7 @@ function UploadModal({ open, close, onSubmit }: {
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-f">
|
||||
<span className="upload-foot-meta mono">{file ? <><span className="accent">{file.name}</span> · {(file.size / 1024 / 1024).toFixed(2)} MB</> : "// 选择一个文件"}</span>
|
||||
<span className="upload-foot-meta mono">{file ? <><span className="accent">{file.name}</span> · {(file.size / 1024 / 1024).toFixed(2)} MB</> : "选择一个文件"}</span>
|
||||
<button className="btn" type="button" onClick={close}>取消</button>
|
||||
<button className="btn btn-primary" type="submit" disabled={!file || uploading}>{uploading ? "上传中…" : "上传资产"}</button>
|
||||
</div>
|
||||
@@ -460,10 +473,6 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
// 资产详情弹窗 + 灯箱(详情里点大图再放大)
|
||||
const [detail, setDetail] = useState<Asset | null>(null);
|
||||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video" | "audio"; name: string } | null>(null);
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle("edit-mode", editMode);
|
||||
return () => document.body.classList.remove("edit-mode");
|
||||
}, [editMode]);
|
||||
const toggleSelect = (id: string) => setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
@@ -593,7 +602,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
useEffect(() => {
|
||||
if (!openChip) return;
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
|
||||
if (!(event.target as HTMLElement).closest(".lib-select")) setOpenChip("");
|
||||
};
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
@@ -643,325 +652,350 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
}
|
||||
}
|
||||
|
||||
const tabLabel = LIB_TABS.find((item) => item.key === tab)?.label || "资产";
|
||||
const shownCount = isVideoPackTab ? shownPacks.length : (isBatchTab ? batches.length : shown.length);
|
||||
const shownTotal = isVideoPackTab ? filteredPacks.length : total;
|
||||
const emptyHint = isVideoPackTab
|
||||
? (debouncedQuery ? "没有匹配的视频成品" : "还没有视频成品 · 去视频项目生成片段")
|
||||
: (hasFilter ? "没有找到符合条件的资产,请调整筛选后重试" : "当前分类暂无真实资产");
|
||||
|
||||
return (
|
||||
<section className="library-page">
|
||||
<div className="page-head">
|
||||
<section className={`library-page${editMode ? " manage-mode" : ""}`}>
|
||||
<header className="lib-head">
|
||||
<div>
|
||||
<h1>资产库</h1>
|
||||
<div className="sub"><span className="mono">// 你的成品 · 图片 {imageBatchCount} 批 · 视频 {counts.video_creations} 条 · 视频成品 {counts.videopacks} 包</span></div>
|
||||
<h1>成品库</h1>
|
||||
<p>集中管理图片、视频与场景成品 · 图片 {imageBatchCount} 批 · 视频 {counts.video_creations} 条 · 视频成品 {counts.videopacks} 包</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
|
||||
<span className="lib-manage-label">{editMode ? "完成" : "管理资产"}</span>
|
||||
<div className="lib-actions">
|
||||
<button className={`lib-ghost${editMode ? " is-on" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
||||
<Settings2 />
|
||||
<span>{editMode ? "完成" : "管理资产"}</span>
|
||||
</button>
|
||||
{/* 上传资产入口已隐藏:资产由 AI 生成流水线自动入库,不开放手动上传 */}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="lib-toolbar">
|
||||
<div className="lib-seg" id="asset-tabs" role="tablist" aria-label="资产类型">
|
||||
{LIB_TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
data-tab={t.key}
|
||||
aria-selected={tab === t.key}
|
||||
className={`lib-seg-btn${tab === t.key ? " active" : ""}`}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
<span className="lib-seg-count">{counts[t.key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="lib-toolbar-right">
|
||||
<label className="lib-search">
|
||||
<Search />
|
||||
<input id="search-input" placeholder={isVideoPackTab ? "搜索项目名" : "搜索资产名称、标签"} value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</label>
|
||||
{LIB_CHIPS.filter((chip) => chip.tabs.includes(tab)).map((chip) => {
|
||||
if (chip.key === "source") {
|
||||
return (
|
||||
<div className={`lib-select${openChip === "source" ? " open" : ""}`} data-key="source" key="source">
|
||||
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "source"} onClick={() => setOpenChip((c) => (c === "source" ? "" : "source"))}>
|
||||
<span>{srcFilter ? SOURCE_LABELS[srcFilter] || srcFilter : "来源"}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="lib-select-menu" role="listbox">
|
||||
<button className={`lib-select-option${!srcFilter ? " selected" : ""}`} type="button" onClick={() => { setSrcFilter(""); setOpenChip(""); }}>全部来源</button>
|
||||
{srcOptions.map((src) => (
|
||||
<button className={`lib-select-option${srcFilter === src ? " selected" : ""}`} type="button" key={src} onClick={() => { setSrcFilter(src); setOpenChip(""); }}>{SOURCE_LABELS[src] || src}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (chip.key === "kind") {
|
||||
return (
|
||||
<div className={`lib-select${openChip === "kind" ? " open" : ""}`} data-key="kind" key="kind">
|
||||
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "kind"} onClick={() => setOpenChip((c) => (c === "kind" ? "" : "kind"))}>
|
||||
<span>{kindFilter ? KIND_LABELS[kindFilter] || kindFilter : "资产类型"}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="lib-select-menu" role="listbox">
|
||||
<button className={`lib-select-option${!kindFilter ? " selected" : ""}`} type="button" onClick={() => { setKindFilter(""); setOpenChip(""); }}>全部类型</button>
|
||||
{kindOptions.map((k) => (
|
||||
<button className={`lib-select-option${kindFilter === k ? " selected" : ""}`} type="button" key={k} onClick={() => { setKindFilter(k); setOpenChip(""); }}>{KIND_LABELS[k] || k}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (chip.key === "product") {
|
||||
const curTitle = facets.products.find((p) => p.id === productFilter)?.title || "";
|
||||
return (
|
||||
<div className={`lib-select${openChip === "product" ? " open" : ""}`} data-key="product" key="product">
|
||||
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "product"} onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
|
||||
<span>{productFilter ? (curTitle || "关联商品") : "关联商品"}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="lib-select-menu" role="listbox">
|
||||
<button className={`lib-select-option${!productFilter ? " selected" : ""}`} type="button" onClick={() => { setProductFilter(""); setOpenChip(""); }}>全部商品</button>
|
||||
{facets.products.map((p) => (
|
||||
<button className={`lib-select-option${productFilter === p.id ? " selected" : ""}`} type="button" key={p.id} onClick={() => { setProductFilter(p.id); setOpenChip(""); }}>{p.title}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const opts = metaOptions(chip.key);
|
||||
const cur = metaFilter[chip.key] || "";
|
||||
return (
|
||||
<div className={`lib-select${openChip === chip.key ? " open" : ""}`} data-key={chip.key} key={chip.key}>
|
||||
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === chip.key} onClick={() => setOpenChip((c) => (c === chip.key ? "" : chip.key))}>
|
||||
<span>{cur || chip.label}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="lib-select-menu" role="listbox">
|
||||
<button className={`lib-select-option${!cur ? " selected" : ""}`} type="button" onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: "" })); setOpenChip(""); }}>全部{chip.label}</button>
|
||||
{opts.map((v) => (
|
||||
<button className={`lib-select-option${cur === v ? " selected" : ""}`} type="button" key={v} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: v })); setOpenChip(""); }}>{v}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={`lib-select${openChip === "sort" ? " open" : ""}`} data-key="sort">
|
||||
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "sort"} onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
|
||||
<span>{isVideoPackTab ? (sortDesc ? "片段最多" : "片段最少") : (sortDesc ? "最近添加" : "最早添加")}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="lib-select-menu" role="listbox">
|
||||
{isVideoPackTab ? (
|
||||
<>
|
||||
<button className={`lib-select-option${sortDesc ? " selected" : ""}`} type="button" onClick={() => { setSortDesc(true); setOpenChip(""); }}>片段最多</button>
|
||||
<button className={`lib-select-option${!sortDesc ? " selected" : ""}`} type="button" onClick={() => { setSortDesc(false); setOpenChip(""); }}>片段最少</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className={`lib-select-option${sortDesc ? " selected" : ""}`} type="button" onClick={() => { setSortDesc(true); setOpenChip(""); }}>最近添加</button>
|
||||
<button className={`lib-select-option${!sortDesc ? " selected" : ""}`} type="button" onClick={() => { setSortDesc(false); setOpenChip(""); }}>最早添加</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs" id="asset-tabs">
|
||||
{LIB_TABS.map((t) => (
|
||||
<div className={`tab${tab === t.key ? " active" : ""}`} key={t.key} data-tab={t.key} onClick={() => setTab(t.key)}>{t.label} <span className="count">{counts[t.key]}</span></div>
|
||||
))}
|
||||
<div className="lib-section">
|
||||
<h2>{tabLabel}</h2>
|
||||
<p>按创作任务类型归档的最终资产 · 显示 {shownCount} / {shownTotal} {resultUnit}{hasFilter || (isVideoPackTab && debouncedQuery) ? "(已筛选)" : ""}{loading && tab !== "videopacks" ? " · 加载中…" : ""}</p>
|
||||
</div>
|
||||
|
||||
{tab === "videopacks" ? (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<div className="search-inline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
<input className="input" placeholder="搜索项目名" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
<span className="spacer"></span>
|
||||
<div className={`chip-wrap${openChip === "sort" ? " open" : ""}`} data-key="sort">
|
||||
<button className="chip" type="button" onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
|
||||
<span className="chip-label">{sortDesc ? "片段最多" : "片段最少"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu align-right">
|
||||
<div className={`mi${sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(true); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>片段最多
|
||||
</div>
|
||||
<div className={`mi${!sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(false); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>片段最少
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="result-meta">// 显示 <span className="count">{shownPacks.length}</span> / {filteredPacks.length} 个素材包{debouncedQuery ? "(已筛选)" : ""}</div>
|
||||
|
||||
<div className="packs-grid">
|
||||
{filteredPacks.length === 0 ? (
|
||||
<div className="empty-filter">// {debouncedQuery ? "没有匹配的视频成品" : "还没有视频成品 · 去视频项目生成片段"}</div>
|
||||
) : (
|
||||
shownPacks.map((pack) => {
|
||||
const first = pack.clips[0];
|
||||
// 视频成品的所有片段 id:批量删除整个素材包
|
||||
const packId = videoPackKey(pack);
|
||||
const isSelected = selectedVideoPacks.has(packId);
|
||||
const onPackClick = editMode ? () => toggleVideoPack(packId) : () => setOpenPack(pack);
|
||||
return (
|
||||
<article className={`pack-card batch-card${editMode && isSelected ? " selected" : ""}`} key={packId} onClick={onPackClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPackClick(); } }}>
|
||||
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
|
||||
<div className="placeholder asset-thumb pack-thumb">
|
||||
{first?.url ? (
|
||||
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
|
||||
) : pack.product_cover ? (
|
||||
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
|
||||
) : (
|
||||
<span className="ph-frame">无片段</span>
|
||||
)}
|
||||
<span className="pack-count mono">{pack.clips.length} 段</span>
|
||||
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
|
||||
</div>
|
||||
<div className="asset-body"><div className="asset-name">{pack.project_name}</div><div className="asset-meta mono">视频素材包</div></div>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Pager page={packCurPage} total={filteredPacks.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<div className="search-inline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
<input className="input" id="search-input" placeholder="搜索资产名称、标签" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
{LIB_CHIPS.filter((chip) => chip.tabs.includes(tab)).map((chip) => {
|
||||
// 仅「来源 / 资产类型」有真实字段可筛;其余(性别/年龄/角色/场景类型/关联商品/关联项目/时长)Asset 无对应字段,保持静态
|
||||
if (chip.key === "source") {
|
||||
return (
|
||||
<div className={`chip-wrap${openChip === "source" ? " open" : ""}`} data-key="source" key="source">
|
||||
<button className={`chip${srcFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "source" ? "" : "source"))}>
|
||||
<span className="chip-label">{srcFilter ? SOURCE_LABELS[srcFilter] || srcFilter : "来源"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className={`mi${!srcFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSrcFilter(""); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部来源
|
||||
</div>
|
||||
{srcOptions.length > 0 && <div className="mi-sep" />}
|
||||
{srcOptions.map((src) => (
|
||||
<div className={`mi${srcFilter === src ? " selected" : ""}`} key={src} role="button" tabIndex={0} onClick={() => { setSrcFilter(src); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{SOURCE_LABELS[src] || src}
|
||||
filteredPacks.length === 0 ? (
|
||||
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span>视频项目生成片段后会自动进入视频成品</span></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="lib-grid" id="pack-grid">
|
||||
{shownPacks.map((pack) => {
|
||||
const first = pack.clips[0];
|
||||
const packId = videoPackKey(pack);
|
||||
const isSelected = selectedVideoPacks.has(packId);
|
||||
const onPackClick = editMode ? () => toggleVideoPack(packId) : () => setOpenPack(pack);
|
||||
return (
|
||||
<article className={`lib-card pack-card batch-card${editMode && isSelected ? " selected" : ""}`} key={packId} onClick={onPackClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPackClick(); } }}>
|
||||
<span className="lib-check" aria-hidden="true"><Check /></span>
|
||||
<div className="lib-thumb portrait">
|
||||
{first?.url ? (
|
||||
<video src={first.url} muted playsInline preload="metadata" />
|
||||
) : pack.product_cover ? (
|
||||
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
|
||||
) : (
|
||||
<span className="ph-frame">无片段</span>
|
||||
)}
|
||||
<span className="lib-count">{pack.clips.length} 段</span>
|
||||
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (chip.key === "kind") {
|
||||
return (
|
||||
<div className={`chip-wrap${openChip === "kind" ? " open" : ""}`} data-key="kind" key="kind">
|
||||
<button className={`chip${kindFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "kind" ? "" : "kind"))}>
|
||||
<span className="chip-label">{kindFilter ? KIND_LABELS[kindFilter] || kindFilter : "资产类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className={`mi${!kindFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setKindFilter(""); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部类型
|
||||
</div>
|
||||
{kindOptions.length > 0 && <div className="mi-sep" />}
|
||||
{kindOptions.map((k) => (
|
||||
<div className={`mi${kindFilter === k ? " selected" : ""}`} key={k} role="button" tabIndex={0} onClick={() => { setKindFilter(k); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{KIND_LABELS[k] || k}
|
||||
<div className="lib-meta">
|
||||
<h3>{pack.project_name}</h3>
|
||||
<p>视频成品 · {pack.clips.length} 段</p>
|
||||
<div className="lib-line">
|
||||
<span className="lib-tag blue">视频素材包</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (chip.key === "product") {
|
||||
// 关联商品:选项走 facets.products(本 tab 下真实关联到的商品),选中后 product=<id> 进列表/批次请求
|
||||
const curTitle = facets.products.find((p) => p.id === productFilter)?.title || "";
|
||||
return (
|
||||
<div className={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product" key="product">
|
||||
<button className={`chip${productFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
|
||||
<span className="chip-label">{productFilter ? (curTitle || "关联商品") : "关联商品"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className={`mi${!productFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setProductFilter(""); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部商品
|
||||
</div>
|
||||
{facets.products.length > 0 && <div className="mi-sep" />}
|
||||
{facets.products.map((p) => (
|
||||
<div className={`mi${productFilter === p.id ? " selected" : ""}`} key={p.id} role="button" tabIndex={0} onClick={() => { setProductFilter(p.id); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{p.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 其余维度走 asset.metadata 真实派生:有标记数据才出可选项,否则只「全部」
|
||||
const opts = metaOptions(chip.key);
|
||||
const cur = metaFilter[chip.key] || "";
|
||||
return (
|
||||
<div className={`chip-wrap${openChip === chip.key ? " open" : ""}`} data-key={chip.key} key={chip.key}>
|
||||
<button className={`chip${cur ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === chip.key ? "" : chip.key))}>
|
||||
<span className="chip-label">{cur || chip.label}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className={`mi${!cur ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: "" })); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部{chip.label}
|
||||
</div>
|
||||
{opts.length > 0 && <div className="mi-sep" />}
|
||||
{opts.map((v) => (
|
||||
<div className={`mi${cur === v ? " selected" : ""}`} key={v} role="button" tabIndex={0} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: v })); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{v}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<span className="spacer"></span>
|
||||
<div className={`chip-wrap${openChip === "sort" ? " open" : ""}`} data-key="sort">
|
||||
<button className="chip" type="button" onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
|
||||
<span className="chip-label">{sortDesc ? "最近添加" : "最早添加"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu align-right">
|
||||
<div className={`mi${sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(true); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>最近添加
|
||||
</div>
|
||||
<div className={`mi${!sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(false); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>最早添加
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="result-meta" id="result-meta">// 显示 <span className="count">{isBatchTab ? batches.length : shown.length}</span> / {total} {resultUnit}{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
|
||||
|
||||
{isBatchTab ? (
|
||||
<Pager page={packCurPage} total={filteredPacks.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
)
|
||||
) : isBatchTab ? (
|
||||
batches.length ? (
|
||||
<div className="packs-grid" id="batch-grid">
|
||||
{batches.map((batch) => {
|
||||
const isSel = selectedBatches.has(batch.batch_id);
|
||||
// 编辑态:整卡点击 = 选中/取消该批次(走 lib-bulk-bar 批量删);非编辑态:点开批次弹窗
|
||||
const onBatchClick = editMode ? () => toggleBatch(batch.batch_id) : () => setOpenBatch(batch);
|
||||
return (
|
||||
<article className={`pack-card batch-card${editMode && isSel ? " selected" : ""}`} key={batch.batch_id} onClick={onBatchClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onBatchClick(); } }}>
|
||||
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
|
||||
<div className="placeholder asset-thumb pack-thumb">
|
||||
{batch.cover ? <img src={batch.cover} alt={batch.name} loading="lazy" /> : <span className="ph-frame">无图</span>}
|
||||
<span className="pack-count mono">{batch.count} 张</span>
|
||||
</div>
|
||||
<div className="asset-body"><div className="asset-name">{batch.name}</div><div className="asset-meta mono">生成批次</div></div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<>
|
||||
<div className="lib-grid" id="batch-grid">
|
||||
{batches.map((batch) => {
|
||||
const isSel = selectedBatches.has(batch.batch_id);
|
||||
const onBatchClick = editMode ? () => toggleBatch(batch.batch_id) : () => setOpenBatch(batch);
|
||||
return (
|
||||
<article className={`lib-card pack-card batch-card${editMode && isSel ? " selected" : ""}`} key={batch.batch_id} onClick={onBatchClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onBatchClick(); } }}>
|
||||
<span className="lib-check" aria-hidden="true"><Check /></span>
|
||||
<div className="lib-thumb">
|
||||
{batch.cover ? <img src={batch.cover} alt={batch.name} loading="lazy" /> : <span className="ph-frame">无图</span>}
|
||||
<span className="lib-count">{batch.count} 张</span>
|
||||
</div>
|
||||
<div className="lib-meta">
|
||||
<h3>{batch.name}</h3>
|
||||
<p>{tabLabel} · {batch.count} 张</p>
|
||||
<div className="lib-line">
|
||||
<span className="lib-tag blue">生成批次</span>
|
||||
<span className="lib-tag">{createdLabel(batch.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
) : loading ? (
|
||||
<SkeletonGrid count={8} />
|
||||
) : (
|
||||
<div className="empty-filter">// 当前分类暂无真实资产</div>
|
||||
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span>对应创作流程生成后会自动入库</span></div>
|
||||
)
|
||||
) : isVideoCreationTab ? (
|
||||
shown.length ? (
|
||||
<div className="packs-grid" id="free-video-grid">
|
||||
<>
|
||||
<div className="lib-grid" id="free-video-grid">
|
||||
{shown.map((asset) => {
|
||||
const pack = freeVideoToPack(asset);
|
||||
const first = pack.clips[0];
|
||||
const isSelected = selected.has(asset.id);
|
||||
const onVideoClick = editMode ? () => toggleSelect(asset.id) : () => setOpenPack(pack);
|
||||
return (
|
||||
<article className={`lib-card asset-card pack-card batch-card${isSelected ? " selected" : ""}`} key={asset.id} onClick={onVideoClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onVideoClick(); } }}>
|
||||
<span className="lib-check" aria-hidden="true"><Check /></span>
|
||||
{onDeleteMany && (
|
||||
<button className="card-del-btn" type="button" title="删除视频" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.id]); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="lib-thumb portrait">
|
||||
{pack.product_cover ? (
|
||||
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
|
||||
) : first?.url ? (
|
||||
<video src={first.url} muted playsInline preload="metadata" />
|
||||
) : (
|
||||
<span className="ph-frame">无视频</span>
|
||||
)}
|
||||
<span className="lib-count">1 条</span>
|
||||
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
|
||||
</div>
|
||||
<div className="lib-meta">
|
||||
<h3>{pack.project_name}</h3>
|
||||
<p>视频自由创作 · 1 条</p>
|
||||
<div className="lib-line">
|
||||
<span className="lib-tag blue">视频</span>
|
||||
<span className="lib-tag">{createdLabel(asset.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
) : loading ? (
|
||||
<SkeletonGrid count={8} />
|
||||
) : (
|
||||
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span>对应创作流程生成后会自动入库</span></div>
|
||||
)
|
||||
) : shown.length ? (
|
||||
<>
|
||||
<div className="lib-grid" id="asset-grid">
|
||||
{shown.map((asset) => {
|
||||
const pack = freeVideoToPack(asset);
|
||||
const first = pack.clips[0];
|
||||
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
|
||||
const isVideo = asset.asset_type === "video";
|
||||
const isAudio = asset.asset_type === "audio";
|
||||
const isSelected = selected.has(asset.id);
|
||||
const onVideoClick = editMode ? () => toggleSelect(asset.id) : () => setOpenPack(pack);
|
||||
const catLabel = (asset.category === "upload" ? KIND_LABELS[asset.asset_type] : CATEGORY_LABELS[asset.category || ""]) || asset.category;
|
||||
const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source;
|
||||
const missingTri = personMissingTri(asset);
|
||||
const onCardClick = editMode ? () => toggleSelect(asset.id) : () => setDetail(asset);
|
||||
return (
|
||||
<article className={`pack-card batch-card${isSelected ? " selected" : ""}`} key={asset.id} onClick={onVideoClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onVideoClick(); } }}>
|
||||
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
|
||||
{editMode && onDeleteMany && (
|
||||
<button className="card-del-btn" type="button" title="删除视频" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.id]); }}>
|
||||
<article className={`lib-card asset-card ${asset.asset_type}${isSelected ? " selected" : ""}`} key={asset.id} onClick={onCardClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCardClick(); } }}>
|
||||
<span className="lib-check" aria-hidden="true"><Check /></span>
|
||||
{onDeleteMany && (
|
||||
<button className="card-del-btn" type="button" title="删除资产" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.id]); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="placeholder asset-thumb pack-thumb">
|
||||
{pack.product_cover ? (
|
||||
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
|
||||
) : first?.url ? (
|
||||
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
|
||||
) : (
|
||||
<span className="ph-frame">无视频</span>
|
||||
<div className={`lib-thumb${isVideo ? " portrait" : ""}`} style={{ position: "relative" }}>
|
||||
{missingTri && (
|
||||
<span className="tri-missing-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="ico" aria-hidden="true"></span>
|
||||
<span className="lbl-mono">缺三视图</span>
|
||||
<span className="tri-missing-pop" role="tooltip">
|
||||
<span className="pop-h">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
|
||||
MISSING TRI-VIEW
|
||||
</span>
|
||||
<span className="pop-body">手动上传的人物未生成 <b>正 / 侧 / 背</b> 三视图。直接进入图片或视频生成,人脸/服饰一致性可能下降。</span>
|
||||
<span className="pop-tip">建议:前往 <b>图片生成</b> 先补齐三视图,再发起后续生成。</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="pack-count mono">1 条</span>
|
||||
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
|
||||
{cover && isVideo && (
|
||||
<>
|
||||
<video src={cover} muted playsInline preload="metadata" />
|
||||
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
|
||||
</>
|
||||
)}
|
||||
{isAudio && (
|
||||
<span className="lib-audio-ph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg>
|
||||
<span className="mono">AUDIO</span>
|
||||
</span>
|
||||
)}
|
||||
{cover && !isVideo && !isAudio && <img src={cover} alt={asset.name} loading="lazy" />}
|
||||
{!cover && !isAudio && <span className="ph-frame">{KIND_LABELS[asset.asset_type] || asset.asset_type}</span>}
|
||||
</div>
|
||||
<div className="lib-meta">
|
||||
<h3>{asset.name}</h3>
|
||||
<p>{catLabel} · {srcLabel}</p>
|
||||
<div className="lib-line">
|
||||
<span className="lib-tag blue">{KIND_LABELS[asset.asset_type] || asset.asset_type}</span>
|
||||
<span className="lib-tag">{createdLabel(asset.created_at) || (asset.category === "final_video" ? (asset.created_at || "").slice(0, 16).replace("T", " ") : srcLabel)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-body"><div className="asset-name">{pack.project_name}</div><div className="asset-meta mono">视频自由创作</div></div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<SkeletonGrid count={8} />
|
||||
) : (
|
||||
<div className="empty-filter">// 当前分类暂无真实资产</div>
|
||||
)
|
||||
) : shown.length ? (
|
||||
<div className="asset-grid" id="asset-grid">
|
||||
{shown.map((asset) => {
|
||||
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
|
||||
const isVideo = asset.asset_type === "video";
|
||||
const isAudio = asset.asset_type === "audio";
|
||||
const isSelected = selected.has(asset.id);
|
||||
// 卡片 meta:upload 分类下「上传 · 上传」无信息量,改显资产类型;其余显分类中文
|
||||
const catLabel = (asset.category === "upload" ? KIND_LABELS[asset.asset_type] : CATEGORY_LABELS[asset.category || ""]) || asset.category;
|
||||
const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source;
|
||||
// 成片卡补导出时间(created_at = 导出入库时刻),格式 2026-06-09 18:21
|
||||
const exportedAt = asset.category === "final_video" ? (asset.created_at || "").slice(0, 16).replace("T", " ") : "";
|
||||
const missingTri = personMissingTri(asset);
|
||||
// 编辑态:整卡点击 = 多选;非编辑态:整卡点击 = 开详情
|
||||
const onCardClick = editMode ? () => toggleSelect(asset.id) : () => setDetail(asset);
|
||||
return (
|
||||
<article className={`asset-card ${asset.asset_type}${isSelected ? " selected" : ""}`} key={asset.id} onClick={onCardClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCardClick(); } }}>
|
||||
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
|
||||
{editMode && onDeleteMany && (
|
||||
<button className="card-del-btn" type="button" title="删除资产" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.id]); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="placeholder asset-thumb" style={{ position: "relative" }}>
|
||||
{missingTri && (
|
||||
<span className="tri-missing-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="ico" aria-hidden="true"></span>
|
||||
<span className="lbl-mono">缺三视图</span>
|
||||
<span className="tri-missing-pop" role="tooltip">
|
||||
<span className="pop-h">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
|
||||
MISSING TRI-VIEW
|
||||
</span>
|
||||
<span className="pop-body">手动上传的人物未生成 <b>正 / 侧 / 背</b> 三视图。直接进入图片或视频生成,人脸/服饰一致性可能下降。</span>
|
||||
<span className="pop-tip">建议:前往 <b>图片生成</b> 先补齐三视图,再发起后续生成。</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{cover && isVideo && (
|
||||
<>
|
||||
<video src={cover} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
|
||||
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
|
||||
</>
|
||||
)}
|
||||
{isAudio && (
|
||||
// 音频没有可视封面,preview_url 是音频文件本身,塞 <img> 必裂图 → 用默认音符占位
|
||||
<span className="lib-audio-ph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg>
|
||||
<span className="mono">AUDIO</span>
|
||||
</span>
|
||||
)}
|
||||
{cover && !isVideo && !isAudio && <img src={cover} alt={asset.name} loading="lazy" />}
|
||||
{!cover && !isAudio && <span className="ph-frame">{KIND_LABELS[asset.asset_type] || asset.asset_type}</span>}
|
||||
</div>
|
||||
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{catLabel} · {srcLabel}{exportedAt && <span className="asset-meta-time"> · {exportedAt}</span>}</div></div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
) : loading ? (
|
||||
<SkeletonGrid count={8} />
|
||||
) : (
|
||||
<div className="empty-filter">// 当前分类暂无真实资产</div>
|
||||
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span>对应创作流程生成后会自动入库</span></div>
|
||||
)}
|
||||
|
||||
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
<div className={`lib-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
|
||||
<div className="lib-sel-copy">
|
||||
<strong>已选择 {selCount} 个资产</strong>
|
||||
<span>确认后移入垃圾桶,可在垃圾桶中恢复</span>
|
||||
</div>
|
||||
<div className="lib-sel-actions">
|
||||
<button className="lib-sel-cancel" type="button" onClick={exitEdit}>取消</button>
|
||||
<button
|
||||
className="lib-sel-confirm"
|
||||
type="button"
|
||||
disabled={selCount === 0}
|
||||
onClick={() => { const ids = bulkDeleteIds(); if (ids.length) setConfirmIds(ids); }}
|
||||
>
|
||||
确认删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视频素材包弹窗:看该项目所有视频片段(可播) */}
|
||||
{openPack && createPortal(
|
||||
@@ -970,7 +1004,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
<div className="pack-modal-h">
|
||||
<div>
|
||||
<h2>{openPack.project_name}</h2>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {openPackIsFreeVideo ? "视频自由创作" : "视频素材包"} · {openPack.clips.length} {openPackIsFreeVideo ? "条" : "段"}</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>{openPackIsFreeVideo ? "视频自由创作" : "视频素材包"} · {openPack.clips.length} {openPackIsFreeVideo ? "条" : "段"}</span>
|
||||
</div>
|
||||
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenPack(null)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
@@ -997,7 +1031,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
<div className="pack-modal-h">
|
||||
<div>
|
||||
<h2>{openBatch.name}</h2>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 生成批次 · {openBatch.count} 张</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>生成批次 · {openBatch.count} 张</span>
|
||||
</div>
|
||||
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(null)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
@@ -1033,18 +1067,6 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
|
||||
<div className={`lib-bulk-bar${selCount > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
|
||||
<span className="ct">已选 <b>{selCount}</b> {isBatchTab || isVideoPackTab ? "批" : "项"}</span>
|
||||
<button className="clear-sel" type="button" onClick={clearSelection}>清空</button>
|
||||
<span className="sep" />
|
||||
<button className="danger" type="button" disabled={selCount === 0} onClick={() => { const ids = bulkDeleteIds(); if (ids.length) setConfirmIds(ids); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||
删除选中
|
||||
</button>
|
||||
<button type="button" onClick={exitEdit}>完成</button>
|
||||
</div>
|
||||
|
||||
<AssetDetailModal
|
||||
asset={detail}
|
||||
close={() => setDetail(null)}
|
||||
@@ -1056,7 +1078,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
<ConfirmModal
|
||||
open={Boolean(confirmIds && confirmIds.length)}
|
||||
title="删除资产"
|
||||
subtitle="// DELETE ASSET"
|
||||
|
||||
icon={<Trash2 size={16} />}
|
||||
detail={`已选中 ${confirmIds?.length || 0} 个资产。删除的资产将回收到垃圾桶,是否需要删除?`}
|
||||
confirmText="删除"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Bell, BellOff, Clapperboard, CreditCard, Info, Search, Users } from "lucide-react";
|
||||
import { ArrowLeft, Bell, BellOff, CheckCheck, Clapperboard, CreditCard, Info, Search, Settings2, Users } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import type { Notification, NotificationTypeCounts } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
@@ -10,22 +10,46 @@ type TabKey = "all" | "unread" | "task" | "team" | "billing" | "system";
|
||||
const PAGE_SIZE = 10; // 每次滚到底加载一批
|
||||
const ZERO_COUNTS: NotificationTypeCounts = { all: 0, unread: 0, task: 0, team: 0, billing: 0, system: 0 };
|
||||
const TYPE_TABS = new Set<TabKey>(["task", "team", "billing", "system"]);
|
||||
|
||||
// tab → 服务端查询参数(tab/未读/搜索全部走后端,滚动逐页拉)
|
||||
function tabParams(tab: TabKey): { type?: string; unread?: boolean } {
|
||||
if (tab === "unread") return { unread: true };
|
||||
if (TYPE_TABS.has(tab)) return { type: tab };
|
||||
return {};
|
||||
}
|
||||
|
||||
const PRI_LABEL: Record<string, string> = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" };
|
||||
const ZH_TYPE: Record<string, string> = { all: "全部", unread: "未读", task: "任务", team: "团队", billing: "计费", system: "系统" };
|
||||
|
||||
const MUTE_TYPES = ["task", "team", "billing", "system"] as const;
|
||||
|
||||
function mutePrefKey(type: string): string {
|
||||
return `mute-${type}`;
|
||||
}
|
||||
|
||||
function isMutedFlag(value: unknown): boolean {
|
||||
return value === true || value === "true" || value === 1 || value === "1";
|
||||
}
|
||||
|
||||
function mutedFromNotify(notify: Record<string, boolean> | undefined): Set<string> {
|
||||
const next = new Set<string>();
|
||||
for (const type of MUTE_TYPES) {
|
||||
if (isMutedFlag(notify?.[mutePrefKey(type)])) next.add(type);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function notifyFromMuted(types: Set<string>): Record<string, boolean> {
|
||||
const notify: Record<string, boolean> = {};
|
||||
for (const type of MUTE_TYPES) notify[mutePrefKey(type)] = types.has(type);
|
||||
return notify;
|
||||
}
|
||||
|
||||
// tab → 服务端查询参数(tab/未读/搜索全部走后端,滚动逐页拉)
|
||||
// 静音只从「全部 / 未读」里藏掉;点进该类仍可查看,不会因此取消静音
|
||||
function tabParams(tab: TabKey, mutedTypes: Set<string>): { type?: string; unread?: boolean; excludeTypes?: string[] } {
|
||||
const excludeTypes = [...mutedTypes].filter((type) => type !== tab);
|
||||
if (tab === "unread") return { unread: true, excludeTypes };
|
||||
if (TYPE_TABS.has(tab)) return { type: tab, excludeTypes };
|
||||
return { excludeTypes };
|
||||
}
|
||||
|
||||
function typeIcon(type: string): ReactNode {
|
||||
if (type === "task") return <Clapperboard size={14} />;
|
||||
if (type === "team") return <Users size={14} />;
|
||||
if (type === "billing") return <CreditCard size={14} />;
|
||||
return <Info size={14} />;
|
||||
if (type === "task") return <Clapperboard size={16} />;
|
||||
if (type === "team") return <Users size={16} />;
|
||||
if (type === "billing") return <CreditCard size={16} />;
|
||||
return <Info size={16} />;
|
||||
}
|
||||
|
||||
// 通知 related_url(.html 风格)→ 应用内 Page
|
||||
@@ -120,8 +144,11 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
const [query, setQuery] = useState("");
|
||||
const [debounced, setDebounced] = useState("");
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
// 静音同类:纯前端本地态(记被静音的 notification_type),命中的消息从列表里隐藏;不落后端
|
||||
// 静音同类:记被静音的 notification_type,请求带 exclude_type,服务端不再返回该类
|
||||
const [mutedTypes, setMutedTypes] = useState<Set<string>>(() => new Set());
|
||||
const [prefsReady, setPrefsReady] = useState(false);
|
||||
const [lastMutedType, setLastMutedType] = useState("");
|
||||
const persistQueue = useRef(Promise.resolve());
|
||||
// 查看日志:记当前展开日志的消息 id(单条展开),切换其它消息自动收起
|
||||
const [showLogId, setShowLogId] = useState<string>("");
|
||||
// 轻量本地 toast(复用 design-restraint 的 .toast 组件;本页没有全局 toast 注入,故就地渲染)
|
||||
@@ -146,6 +173,30 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
|
||||
useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void api.preferences()
|
||||
.then((pref) => {
|
||||
if (cancelled) return;
|
||||
const next = mutedFromNotify(pref.notify);
|
||||
setMutedTypes(next);
|
||||
setLastMutedType([...next][0] || "");
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => { if (!cancelled) setPrefsReady(true); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
function persistMutedTypes(types: Set<string>) {
|
||||
const notify = notifyFromMuted(types);
|
||||
persistQueue.current = persistQueue.current
|
||||
.then(() => api.updatePreferences({ notify }))
|
||||
.then(() => undefined)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
const mutedKey = [...mutedTypes].sort().join(",");
|
||||
|
||||
function showToast(title: string, sub: string) {
|
||||
setToast({ title, sub });
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||
@@ -161,10 +212,18 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api
|
||||
.listNotifications({ ...tabParams(tab), search: debounced || undefined, page: pageToLoad, pageSize: PAGE_SIZE })
|
||||
.listNotifications({
|
||||
...tabParams(tab, new Set(mutedKey ? mutedKey.split(",") : [])),
|
||||
search: debounced || undefined,
|
||||
page: pageToLoad,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
.catch(() => null);
|
||||
if (gen !== genRef.current) return; // tab/搜索已切换,丢弃过期结果
|
||||
if (!res) return;
|
||||
if (!res) {
|
||||
if (!replace) setHasMore(false);
|
||||
return;
|
||||
}
|
||||
setItems((prev) => (replace ? res.results : [...prev, ...res.results]));
|
||||
setHasMore(Boolean(res.next));
|
||||
setPage(pageToLoad + 1);
|
||||
@@ -177,16 +236,17 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
}
|
||||
}
|
||||
},
|
||||
[tab, debounced]
|
||||
[tab, debounced, mutedKey]
|
||||
);
|
||||
|
||||
// tab / 搜索变化 → 清空重拉第 1 页
|
||||
// tab / 搜索 / 静音变化 → 清空重拉第 1 页(等偏好就绪,避免先拉全量再排除)
|
||||
useEffect(() => {
|
||||
if (!prefsReady) return;
|
||||
setItems([]);
|
||||
setSelectedId("");
|
||||
setHasMore(false);
|
||||
void load(1, true);
|
||||
}, [load]);
|
||||
}, [load, prefsReady]);
|
||||
|
||||
// 滚到接近底部就拉下一批
|
||||
const onScroll = useCallback(() => {
|
||||
@@ -201,9 +261,7 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
if (el && hasMore && !loadingRef.current && el.scrollHeight <= el.clientHeight) void load(page, false);
|
||||
}, [items, hasMore, page, load]);
|
||||
|
||||
// 静音同类只在前端隐藏(不打后端);其余照常分页/搜索
|
||||
const visibleItems = mutedTypes.size === 0 ? items : items.filter((n) => !mutedTypes.has(n.notification_type));
|
||||
const selected = visibleItems.find((n) => n.id === selectedId) || visibleItems[0] || null;
|
||||
const selected = items.find((n) => n.id === selectedId) || items[0] || null;
|
||||
|
||||
// 标记单条已读:同步后端/侧边栏徽标 + 本地乐观更新(列表与未读计数)
|
||||
function markOne(id: string) {
|
||||
@@ -240,15 +298,33 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
showToast("已归档", n.title);
|
||||
}
|
||||
|
||||
// 静音同类:把该 notification_type 记进本地静音集,列表即时隐藏同类;再点对应 chip / 此处可恢复
|
||||
// 静音同类:写入 mute-* 偏好;再点同一按钮或对应分类即可恢复
|
||||
function unmuteType(type: string) {
|
||||
if (!type || !mutedTypes.has(type)) return;
|
||||
const next = new Set(mutedTypes);
|
||||
next.delete(type);
|
||||
setMutedTypes(next);
|
||||
setLastMutedType((cur) => (cur === type ? "" : cur));
|
||||
persistMutedTypes(next);
|
||||
showToast("已取消静音", `${ZH_TYPE[type] || type} 类提醒已恢复显示`);
|
||||
}
|
||||
|
||||
function muteType(n: Notification) {
|
||||
setMutedTypes((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(n.notification_type);
|
||||
return next;
|
||||
});
|
||||
setSelectedId(""); // 当前条会被隐藏,落回首条
|
||||
showToast("已静音同类", `${ZH_TYPE[n.notification_type] || n.notification_type} 类提醒已隐藏 · 可在通知设置恢复`);
|
||||
const type = n.notification_type;
|
||||
if (!type) return;
|
||||
if (mutedTypes.has(type) || (lastMutedType && mutedTypes.has(lastMutedType))) {
|
||||
unmuteType(mutedTypes.has(type) ? type : lastMutedType);
|
||||
return;
|
||||
}
|
||||
const next = new Set(mutedTypes);
|
||||
next.add(type);
|
||||
setMutedTypes(next);
|
||||
setLastMutedType(type);
|
||||
persistMutedTypes(next);
|
||||
setSelectedId("");
|
||||
if (tab === type) setTab("all");
|
||||
const label = ZH_TYPE[type] || type;
|
||||
showToast("已静音同类", `${label} 类已从「全部」隐藏 · 点「${label}」仍可查看,点取消静音才恢复`);
|
||||
}
|
||||
|
||||
// 详情底栏派生动作的统一分发
|
||||
@@ -279,91 +355,136 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
const detailActions = selected ? inferActions(selected) : [];
|
||||
// 「标为已读」与派生的「我已了解」(ack)同义,二者择一,避免底栏重复
|
||||
const hasAck = detailActions.some((a) => a.kind === "ack");
|
||||
const bootLoading = !prefsReady || (loading && items.length === 0);
|
||||
const tabMuted = TYPE_TABS.has(tab) && mutedTypes.has(tab);
|
||||
const unmuteTarget = (selected && mutedTypes.has(selected.notification_type) && selected.notification_type)
|
||||
|| (tabMuted ? tab : "")
|
||||
|| (lastMutedType && mutedTypes.has(lastMutedType) ? lastMutedType : "");
|
||||
|
||||
return (
|
||||
<div className="msg-page">
|
||||
<div className="page-head">
|
||||
<button className="msg-back" type="button" onClick={() => navigate("dashboard")} aria-label="返回">
|
||||
<ArrowLeft size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
<div>
|
||||
<h1>消息中心</h1>
|
||||
<div className="sub"><span className="mono">// {counts.unread} 条未读 · {counts.all} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
|
||||
<div className="sub">{counts.unread} 条未读 · {counts.all} 条总计 · 任务提醒、团队协作、计费与系统公告</div>
|
||||
</div>
|
||||
<div className="msg-head-actions">
|
||||
<button className="btn" type="button" onClick={() => void markAll()} disabled={counts.unread === 0}>全部标已读</button>
|
||||
<button className="btn" type="button" onClick={() => navigate("settingsNotify")}>通知设置</button>
|
||||
<button className="msg-ghost" type="button" onClick={() => void markAll()} disabled={counts.unread === 0}>
|
||||
<CheckCheck size={18} />全部标已读
|
||||
</button>
|
||||
<button className="msg-ghost" type="button" onClick={() => navigate("settingsNotify")}>
|
||||
<Settings2 size={18} />通知设置
|
||||
</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">// 显示 {visibleItems.length} 条{total > visibleItems.length ? ` / ${total}` : ""}</span></div>
|
||||
<div className="msg-filters">
|
||||
{filters.map(([id, label, ct]) => (
|
||||
<button key={id} className={`msg-filter ${tab === id ? "active" : ""}`} type="button" onClick={() => setTab(id)}>
|
||||
{label}<span className="ct">{ct}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="msg-search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
<input type="text" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
|
||||
<section className="msg-inbox">
|
||||
<div className="msg-toolbar">
|
||||
<div className="msg-filters">
|
||||
{filters.map(([id, label, ct]) => {
|
||||
const muted = TYPE_TABS.has(id) && mutedTypes.has(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
className={`msg-filter${tab === id ? " active" : ""}${muted ? " is-muted" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{label} {ct}{muted ? " · 静音" : ""}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="msg-search">
|
||||
<Search size={16} />
|
||||
<input type="text" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源或内容" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="msg-list" ref={listRef} onScroll={onScroll}>
|
||||
{visibleItems.length === 0 && !loading ? (
|
||||
<div className="msg-empty"><Search /><span>没有符合条件的消息</span></div>
|
||||
{bootLoading ? (
|
||||
<div className="msg-skel" aria-busy="true" aria-live="polite">
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<div className="msg-skel-row" key={i}>
|
||||
<span className="msg-skel-ico" />
|
||||
<div className="msg-skel-copy">
|
||||
<span className="msg-skel-bar" />
|
||||
<span className="msg-skel-bar msg-skel-bar-sm" />
|
||||
</div>
|
||||
<span className="msg-skel-time" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{visibleItems.map((n) => (
|
||||
<button key={n.id} className={`msg-item ${selected?.id === n.id ? "active" : ""} ${n.is_read ? "read" : ""}`} type="button" onClick={() => selectItem(n)}>
|
||||
<span className={`msg-type-ic ${n.notification_type}`}>{typeIcon(n.notification_type)}</span>
|
||||
<span className="msg-item-main">
|
||||
<span className="msg-item-row">
|
||||
<span className="msg-dot"></span>
|
||||
<span className="msg-item-title">{n.title}</span>
|
||||
{tabMuted && (
|
||||
<div className="msg-mute-note">
|
||||
<span>已从「全部」隐藏 · 仅在此分类查看</span>
|
||||
<button className="msg-ghost msg-ghost-sm" type="button" onClick={() => unmuteType(tab)}>取消静音</button>
|
||||
</div>
|
||||
)}
|
||||
{items.length === 0 ? (
|
||||
<div className="msg-empty"><Search /><span>没有符合条件的消息</span></div>
|
||||
) : (
|
||||
<>
|
||||
{items.map((n) => (
|
||||
<button key={n.id} className={`msg-item${selected?.id === n.id ? " active" : ""}${n.is_read ? " read" : ""}`} type="button" onClick={() => selectItem(n)}>
|
||||
<div className={`msg-item-icon ${n.notification_type}`}>{typeIcon(n.notification_type)}</div>
|
||||
<div>
|
||||
<strong>{n.title}</strong>
|
||||
<p>{n.brief}</p>
|
||||
</div>
|
||||
<span className="msg-time">{fmtTime(n.created_at)}</span>
|
||||
</span>
|
||||
<span className="msg-brief">{n.brief}</span>
|
||||
<span className="msg-item-foot">
|
||||
<span className={`msg-priority ${n.priority}`}>{PRI_LABEL[n.priority] || "更新"}</span>
|
||||
{n.project_name ? <span className="msg-priority">{n.project_name}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{(loading || hasMore) && <div className="msg-load-more mono">{loading ? "// 加载中…" : "// 滚动加载更多"}</div>}
|
||||
{!loading && !hasMore && visibleItems.length > 0 && <div className="msg-load-more mono">// 已全部加载</div>}
|
||||
</button>
|
||||
))}
|
||||
{(loading || hasMore) && (
|
||||
<div className="msg-load-more">
|
||||
{loading ? <span className="msg-skel-more" aria-label="加载中"><span /><span /><span /></span> : "滚动加载更多"}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !hasMore && items.length > 0 && <div className="msg-load-more">已全部加载</div>}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="msg-panel msg-detail">
|
||||
{selected ? (
|
||||
<section className="msg-detail">
|
||||
{bootLoading ? (
|
||||
<div className="msg-detail-skel" aria-busy="true">
|
||||
<span className="msg-skel-bar msg-skel-title" />
|
||||
<span className="msg-skel-bar msg-skel-lead" />
|
||||
<span className="msg-skel-bar msg-skel-lead-2" />
|
||||
<div className="msg-skel-summary">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div className="msg-skel-kv" key={i}>
|
||||
<span className="msg-skel-bar msg-skel-k" />
|
||||
<span className="msg-skel-bar msg-skel-v" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : selected ? (
|
||||
<>
|
||||
<div className="msg-detail-body">
|
||||
<div className="msg-detail-top">
|
||||
<span className={`msg-type-ic ${selected.notification_type}`}>{typeIcon(selected.notification_type)}</span>
|
||||
<div className="msg-detail-title">
|
||||
<h2>{selected.title}</h2>
|
||||
<div className="meta"><span>{selected.source || ZH_TYPE[selected.notification_type]}</span><span>// {ZH_TYPE[selected.notification_type]}</span><span>{fmtFull(selected.created_at)}</span></div>
|
||||
</div>
|
||||
<span className={`msg-priority ${selected.priority}`}>{PRI_LABEL[selected.priority] || "更新"}</span>
|
||||
</div>
|
||||
<p className="msg-body-text">{selected.body || selected.brief}</p>
|
||||
<div className="msg-props">
|
||||
{([
|
||||
["来源", selected.source || "-"],
|
||||
["类别", ZH_TYPE[selected.notification_type] || selected.notification_type],
|
||||
["项目", selected.project_name || "-"],
|
||||
["阶段", selected.stage || "-"],
|
||||
["负责人", selected.owner_label || "-"],
|
||||
["费用", selected.cost_label || "-"],
|
||||
["时间", fmtFull(selected.created_at)]
|
||||
] as Array<[string, string]>).flatMap(([k, v]) => [
|
||||
<span className="k" key={`${k}-k`}>{k}</span>,
|
||||
<span className="v" key={`${k}-v`}>{v}</span>
|
||||
])}
|
||||
<span className="k">关联资源</span>
|
||||
<span className="v"><a onClick={() => navigate(target)}>{routeLabels[target]} →</a></span>
|
||||
<h2>{selected.title}</h2>
|
||||
<p>{selected.body || selected.brief}</p>
|
||||
<div className="msg-summary">
|
||||
<span>来源</span><strong>{selected.source || ZH_TYPE[selected.notification_type] || "—"}</strong>
|
||||
<span>类别</span><strong>{ZH_TYPE[selected.notification_type] || "—"}</strong>
|
||||
<span>项目</span><strong>{selected.project_name || selected.project || "—"}</strong>
|
||||
<span>阶段</span><strong>{selected.stage || "—"}</strong>
|
||||
<span>负责人</span><strong>{selected.owner_label || "—"}</strong>
|
||||
<span>费用</span><strong>{selected.cost_label || "—"}</strong>
|
||||
<span>时间</span><strong>{fmtFull(selected.created_at)}</strong>
|
||||
<span>关联资源</span>
|
||||
{selected.related_url
|
||||
? <button className="msg-related" type="button" onClick={() => navigate(target)}>{routeLabels[target]} →</button>
|
||||
: <strong>无</strong>}
|
||||
</div>
|
||||
{(() => {
|
||||
const timeline = readTimeline(selected.metadata);
|
||||
@@ -379,47 +500,48 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
|
||||
})()}
|
||||
{showLogId === selected.id && (() => {
|
||||
const log = readLog(selected.metadata);
|
||||
// 后端暂未下发 log 字段 → 壳照出,内容缺位时给占位,不造假日志
|
||||
return log
|
||||
? <pre className="msg-log">{log}</pre>
|
||||
: <pre className="msg-log msg-log-empty">// 暂无日志详情</pre>;
|
||||
: <pre className="msg-log msg-log-empty">暂无日志详情</pre>;
|
||||
})()}
|
||||
</div>
|
||||
<div className="msg-detail-f">
|
||||
<button className="btn btn-ghost" type="button" onClick={() => archiveOne(selected)}>归档</button>
|
||||
<button className="btn btn-ghost" type="button" onClick={() => muteType(selected)}>
|
||||
<BellOff size={14} />静音同类
|
||||
<button className="msg-ghost msg-ghost-sm" type="button" onClick={() => archiveOne(selected)}>归档</button>
|
||||
<button className="msg-ghost msg-ghost-sm" type="button" onClick={() => (unmuteTarget ? unmuteType(unmuteTarget) : muteType(selected))}>
|
||||
<BellOff size={16} />{unmuteTarget ? "取消静音" : "静音同类"}
|
||||
</button>
|
||||
{!selected.is_read && !hasAck && <button className="btn btn-ghost" type="button" onClick={() => markOne(selected.id)}>标为已读</button>}
|
||||
<span className="spacer"></span>
|
||||
{!selected.is_read && !hasAck && <button className="msg-ghost msg-ghost-sm" type="button" onClick={() => markOne(selected.id)}>标为已读</button>}
|
||||
<span className="spacer" />
|
||||
{detailActions.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
className={`btn ${a.primary ? "btn-primary" : ""} ${a.kind === "log" && showLogId === selected.id ? "is-active" : ""}`}
|
||||
className={`${a.primary ? "msg-primary" : "msg-ghost msg-ghost-sm"}${a.kind === "log" && showLogId === selected.id ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => runAction(selected, a)}
|
||||
>
|
||||
{/* nav 主操作未带文案时,用解析出的目标页名兜底 */}
|
||||
{a.label || `进入${routeLabels[target]}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="msg-detail-empty"><div className="ic"><Bell /></div><div>暂无消息</div></div>
|
||||
<div className="msg-detail-empty">
|
||||
<div className="msg-detail-empty-inner">
|
||||
<div className="ic">{tabMuted ? <BellOff /> : <Bell />}</div>
|
||||
<div>{tabMuted ? `已静音${ZH_TYPE[tab] || ""}类` : "暂无消息"}</div>
|
||||
{tabMuted && (
|
||||
<button className="msg-ghost msg-ghost-sm" type="button" onClick={() => unmuteType(tab)}>取消静音</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="msg-foot-note">
|
||||
<span>// 消息保留 90 天 · 高风险任务会同时进入工作台队列</span>
|
||||
<a onClick={() => navigate("settingsNotify")}>管理通知策略 →</a>
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="toast show" role="status" aria-live="polite">
|
||||
<div className="ic-t"><Info size={13} aria-hidden="true" /></div>
|
||||
<div className="txt">{toast.title}<span className="mono">// {toast.sub}</span></div>
|
||||
<div className="txt">{toast.title}<span className="mono">{toast.sub}</span></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { RefreshCw, Upload, User, X } from "lucide-react";
|
||||
import { Check, RefreshCw, Settings2, Trash2, Upload, User, UserPlus, X } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { generationErrorText } from "../generation-error";
|
||||
import type { ModelEntity } from "../types";
|
||||
import { SystemLoading } from "../components/loading";
|
||||
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
||||
import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
||||
import "../models-page.css";
|
||||
|
||||
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
||||
|
||||
type Tab = "all" | "official" | "mine";
|
||||
const TABS: { k: Tab; label: string }[] = [
|
||||
{ k: "all", label: "全部" },
|
||||
{ k: "official", label: "官方模板" },
|
||||
{ k: "mine", label: "我的模特" }
|
||||
const TABS: { k: Tab; label: string; title: string; note: string }[] = [
|
||||
{ k: "all", label: "全部", title: "全部模特", note: "官方模板与团队自建模特都可以在这里复用" },
|
||||
{ k: "official", label: "官方模板", title: "官方模特", note: "官方模特均为平台导入的授权素材" },
|
||||
{ k: "mine", label: "我的模特", title: "我的模特", note: "维护可复用的品牌模特与人物参考资产" }
|
||||
];
|
||||
|
||||
type Preview = { src: string; kind: "image"; name: string };
|
||||
@@ -193,7 +193,7 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
|
||||
<div className="model-detail-section-h">
|
||||
<strong>形象图</strong>
|
||||
<div className="model-detail-section-actions">
|
||||
<span className="mono">// PORTRAIT</span>
|
||||
<span className="mono">PORTRAIT</span>
|
||||
{!model.is_official && (
|
||||
<button className="btn btn-sm" type="button" disabled={saving} onClick={() => portraitFileRef.current?.click()}>
|
||||
<Upload size={13} />{pendingPortrait ? "重新选择" : model.portrait ? "替换形象图" : "上传形象图"}
|
||||
@@ -223,7 +223,7 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
|
||||
<div className="model-detail-section-h">
|
||||
<strong>三视图</strong>
|
||||
<div className="model-detail-section-actions">
|
||||
<span className="mono">// 可选资产</span>
|
||||
<span className="mono">可选资产</span>
|
||||
{!model.is_official && (
|
||||
<button className="btn btn-sm" type="button" disabled={!canGenerateTriview} title={triviewTitle} onClick={() => void generateTriview()}>
|
||||
{triviewBusy ? <span className="spinner btn-spin" aria-hidden="true" /> : <RefreshCw size={13} />}
|
||||
@@ -242,9 +242,9 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
|
||||
>
|
||||
{!model.triview && (triviewBusy
|
||||
? <span className="ph-frame mono"><span className="spinner" aria-hidden="true" /> 三视图生成中…</span>
|
||||
: <span className="ph-frame mono">// 暂无三视图 · 模特仍可正常使用</span>)}
|
||||
: <span className="ph-frame mono">暂无三视图 · 模特仍可正常使用</span>)}
|
||||
</div>
|
||||
{triviewStatus && <div className="field-hint mono">// {triviewStatus}</div>}
|
||||
{triviewStatus && <div className="field-hint mono">{triviewStatus}</div>}
|
||||
</div>
|
||||
<div className="model-detail-form">
|
||||
<div className="field">
|
||||
@@ -255,12 +255,12 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
|
||||
<label className="field-label" htmlFor="model-edit-description">模特描述 / 提示词</label>
|
||||
<textarea id="model-edit-description" className="textarea" rows={4} disabled={model.is_official || saving} placeholder="补充模特特征、风格或生成提示词" value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
</div>
|
||||
<div className="field-hint mono">// 编辑模特资料不会回写已存在的视频项目角色</div>
|
||||
<div className="field-hint mono">编辑模特资料不会回写已存在的视频项目角色</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-f">
|
||||
{model.is_official && <span className="model-detail-readonly mono">// 官方模板仅供查看</span>}
|
||||
{model.is_official && <span className="model-detail-readonly mono">官方模板仅供查看</span>}
|
||||
<button className="btn" type="button" disabled={saving} onClick={dismiss}>关闭</button>
|
||||
{!model.is_official && <button className="btn btn-primary" type="button" disabled={saving || !name.trim() || !isDirty} onClick={() => void save()}>{saving ? "保存中…" : "保存修改"}</button>}
|
||||
</div>
|
||||
@@ -280,10 +280,19 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
||||
const [items, setItems] = useState<ModelEntity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [confirmIds, setConfirmIds] = useState<string[] | null>(null);
|
||||
const [detail, setDetail] = useState<ModelEntity | null>(null);
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const tabCopy = TABS.find((item) => item.k === tab) || TABS[0];
|
||||
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
|
||||
const toggleSelect = (id: string) => setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -296,6 +305,8 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
||||
return () => { alive = false; };
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => { setSelected(new Set()); }, [tab]);
|
||||
|
||||
async function onPick(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
@@ -312,98 +323,162 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
await api.deleteModel(id);
|
||||
setItems((list) => list.filter((m) => m.id !== id));
|
||||
onNotify?.("success", "已移至垃圾桶");
|
||||
} catch (error) {
|
||||
onNotify?.("error", error instanceof Error ? error.message : "删除失败");
|
||||
} finally {
|
||||
setConfirmId(null);
|
||||
async function remove(ids: string[]) {
|
||||
const failed = new Set<string>();
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await api.deleteModel(id);
|
||||
} catch {
|
||||
failed.add(id);
|
||||
}
|
||||
}
|
||||
const removed = ids.filter((id) => !failed.has(id));
|
||||
if (removed.length) {
|
||||
setItems((list) => list.filter((m) => !removed.includes(m.id)));
|
||||
setSelected((prev) => new Set([...prev].filter((id) => !removed.includes(id))));
|
||||
onNotify?.("success", removed.length > 1 ? `已移至垃圾桶 · ${removed.length} 个` : "已移至垃圾桶");
|
||||
}
|
||||
if (failed.size) onNotify?.("error", "部分模特删除失败");
|
||||
setConfirmIds(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="models-page">
|
||||
<div className="page-head">
|
||||
<section className={`models-page${editMode ? " manage-mode" : ""}`}>
|
||||
<header className="ml-head">
|
||||
<div>
|
||||
<h1>模特库</h1>
|
||||
<div className="sub">
|
||||
<span className="mono">// 团队级可复用 · 形象图 + 三视图</span>
|
||||
<span>·</span>
|
||||
<span>图片创作与视频项目都能引用</span>
|
||||
</div>
|
||||
<p>维护可复用的品牌模特与人物参考资产</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? "上传中…" : "真人上传"}
|
||||
<div className="ml-actions">
|
||||
<button className={`ml-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
||||
<Settings2 />
|
||||
<span>{editMode ? "完成" : "管理模特"}</span>
|
||||
</button>
|
||||
<button className="ml-primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||
<UserPlus />
|
||||
<span>{uploading ? "上传中…" : "添加模特"}</span>
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPick} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="ml-toolbar">
|
||||
<div className="ml-seg" role="tablist" aria-label="模特来源">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.k} type="button" role="tab" aria-selected={tab === t.k} className={`ml-seg-btn${tab === t.k ? " active" : ""}`} onClick={() => setTab(t.k)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="ml-note">官方模特均为平台导入的授权素材</span>
|
||||
</div>
|
||||
|
||||
<div className="tabs">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.k} type="button" className={`tab${tab === t.k ? " active" : ""}`} onClick={() => setTab(t.k)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-section">
|
||||
<h2>{tabCopy.title}</h2>
|
||||
<p>{tabCopy.note}</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="model" />
|
||||
) : items.length === 0 ? (
|
||||
<div className="placeholder models-empty">
|
||||
<span className="ph-frame">// 还没有模特</span>
|
||||
<span className="models-empty-hint mono">点右上「真人上传」加一个,或在图片/视频流程里生成</span>
|
||||
<div className="ml-empty" role="status">
|
||||
<UserPlus />
|
||||
<strong>还没有模特</strong>
|
||||
<span>点右上「添加模特」上传一张形象图,或在图片/视频流程里生成</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="models-grid">
|
||||
{items.map((m) => (
|
||||
<div
|
||||
className="model-card"
|
||||
key={m.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setDetail(m)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setDetail(m); } }}
|
||||
>
|
||||
<div
|
||||
className={`placeholder model-portrait${m.portrait ? " has-mock-media" : ""}`}
|
||||
style={m.portrait ? mediaStyle(m.portrait) : undefined}
|
||||
<div className="ml-grid">
|
||||
{items.map((m) => {
|
||||
const selectable = !m.is_official;
|
||||
const isSelected = selected.has(m.id);
|
||||
const onCardClick = () => {
|
||||
if (editMode) {
|
||||
if (selectable) toggleSelect(m.id);
|
||||
return;
|
||||
}
|
||||
setDetail(m);
|
||||
};
|
||||
return (
|
||||
<article
|
||||
className={`ml-card model-card${selectable ? " selectable" : ""}${isSelected ? " selected" : ""}`}
|
||||
key={m.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={editMode && selectable ? isSelected : undefined}
|
||||
onClick={onCardClick}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCardClick(); } }}
|
||||
>
|
||||
{!m.portrait && <span className="ph-frame">无图</span>}
|
||||
{m.is_official && <span className="pill info model-official">官方模板</span>}
|
||||
{!m.is_official &&
|
||||
(confirmId === m.id ? (
|
||||
<div className="model-confirm" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="btn btn-sm model-del" type="button" onClick={() => void remove(m.id)}>删除</button>
|
||||
<button className="btn btn-sm" type="button" onClick={() => setConfirmId(null)}>取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="model-del-btn" type="button" title="删除模特" onClick={(e) => { e.stopPropagation(); setConfirmId(m.id); }}>×</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="model-body">
|
||||
<div className="model-name" title={m.name}>{m.name}</div>
|
||||
{selectable && <span className="ml-check" aria-hidden="true"><Check /></span>}
|
||||
{selectable && (
|
||||
<button
|
||||
className="card-del-btn"
|
||||
type="button"
|
||||
title="删除模特"
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmIds([m.id]); }}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={`placeholder model-triview${m.triview ? " has-mock-media" : ""}`}
|
||||
style={m.triview ? mediaStyle(m.triview) : undefined}
|
||||
className={`placeholder ml-thumb${m.portrait ? " has-mock-media" : ""}`}
|
||||
style={m.portrait ? mediaStyle(m.portrait) : undefined}
|
||||
>
|
||||
{!m.triview && <span className="ph-frame mono">// 无三视图</span>}
|
||||
{!m.portrait && <span className="ph-frame">无图</span>}
|
||||
</div>
|
||||
<div className="model-tags mono">
|
||||
<span>{m.source === "upload" ? "真人上传" : "AI 生成"}</span>
|
||||
<span>·</span>
|
||||
<span>{m.triview ? "三视图 ✓" : "三视图 —"}</span>
|
||||
<div className="ml-meta">
|
||||
<h3 title={m.name}>{m.name}</h3>
|
||||
<p>{m.description?.trim() || (m.source === "upload" ? "真人上传的人物参考" : "AI 生成的人物参考")}</p>
|
||||
<div
|
||||
className={`placeholder ml-triview${m.triview ? " has-mock-media" : ""}`}
|
||||
style={m.triview ? mediaStyle(m.triview) : undefined}
|
||||
>
|
||||
{!m.triview && <span className="ph-frame">待补三视图</span>}
|
||||
</div>
|
||||
<div className="ml-line">
|
||||
{m.is_official ? (
|
||||
<>
|
||||
<span className="ml-tag green">平台授权</span>
|
||||
<span className="ml-tag">官方</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="ml-tag blue">我添加的</span>
|
||||
<span className={`ml-tag${m.triview ? " green" : ""}`}>{m.triview ? "资料完整" : "待补三视图"}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-tag">{m.source === "upload" ? "真人上传" : "AI 生成"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`ml-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
|
||||
<div className="ml-sel-copy">
|
||||
<strong>已选择 {selected.size} 个模特</strong>
|
||||
<span>仅我的模特可删除,确认后移入垃圾桶</span>
|
||||
</div>
|
||||
<div className="ml-sel-actions">
|
||||
<button className="ml-sel-cancel" type="button" onClick={exitEdit}>取消</button>
|
||||
<button className="ml-sel-confirm" type="button" disabled={selected.size === 0} onClick={() => selected.size && setConfirmIds(Array.from(selected))}>
|
||||
确认删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(confirmIds && confirmIds.length)}
|
||||
title="删除模特"
|
||||
|
||||
icon={<Trash2 size={16} />}
|
||||
detail={`确定删除选中的 ${confirmIds?.length || 0} 个模特?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
|
||||
confirmText="删除"
|
||||
onCancel={() => setConfirmIds(null)}
|
||||
onConfirm={() => void remove(confirmIds || [])}
|
||||
/>
|
||||
|
||||
<ModelDetailModal
|
||||
model={detail}
|
||||
close={() => setDetail(null)}
|
||||
@@ -416,6 +491,6 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
||||
onBillingChanged={onBillingChanged}
|
||||
/>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
|
||||
import { ArrowLeft, Trash2, X } from "lucide-react";
|
||||
import { ArrowLeft, Check, ChevronDown, Grid2X2, List, PackagePlus, PackageX, Search, Settings2, Trash2, X } from "lucide-react";
|
||||
import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays";
|
||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||
import { ProductCreateDrawer, PC_CAT_OPTIONS } from "../components/product-create-drawer";
|
||||
@@ -68,6 +68,21 @@ function resolveCoverUrl(product: Product): string {
|
||||
return product.cover_preview_url || firstImage?.preview_url || "";
|
||||
}
|
||||
|
||||
function createdLabel(iso?: string): string {
|
||||
if (!iso) return "未知日期";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return iso.slice(0, 10);
|
||||
const now = new Date();
|
||||
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const startThat = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const diffDays = Math.round((startToday.getTime() - startThat.getTime()) / 86400000);
|
||||
if (diffDays === 0) return "今天创建";
|
||||
if (diffDays === 1) return "昨天创建";
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
return `${mm}.${dd} 创建`;
|
||||
}
|
||||
|
||||
export function ProductsPage({ products, projects = [], loading = false, navigate, openProduct, onCreate, onUploadImage, onDelete, onDeleteMany, autoOpenCreate = false }: {
|
||||
products: Product[];
|
||||
projects?: Project[];
|
||||
@@ -147,17 +162,24 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
{ value: "90", label: "近 90 天" }
|
||||
];
|
||||
const dateLabel = DATE_OPTS.find((o) => o.value === dateFilter)?.label || "创建时间";
|
||||
const typeLabel = typeFilter === "all" ? "全部类目" : BUSINESS_TYPES[typeFilter];
|
||||
const catLabel = catFilter.size === 0
|
||||
? "全部分类"
|
||||
: catFilter.size === 1
|
||||
? Array.from(catFilter)[0]
|
||||
: `已选 ${catFilter.size} 类`;
|
||||
const hasFilters = Boolean(query || catFilter.size > 0 || dateFilter !== "all" || typeFilter !== "all");
|
||||
|
||||
// /products/new 进入时自动打开新建商品 drawer
|
||||
useEffect(() => {
|
||||
if (autoOpenCreate) setDrawer(true);
|
||||
}, [autoOpenCreate]);
|
||||
|
||||
// 点击 chip 外部关闭下拉
|
||||
// 点击下拉外部关闭
|
||||
useEffect(() => {
|
||||
if (!openChip) return;
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
|
||||
if (!(event.target as HTMLElement).closest(".pl-select")) setOpenChip("");
|
||||
};
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
@@ -184,143 +206,141 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<section className="products-page">
|
||||
<div className="page-head">
|
||||
<section className={`products-page${editMode ? " manage-mode" : ""}`}>
|
||||
<header className="pl-head">
|
||||
<div>
|
||||
<h1>商品库</h1>
|
||||
<div className="sub"><span className="mono">// <span id="sku-count">{products.length}</span> SKU</span> · 商品信息会作为脚本和资产生成的素材</div>
|
||||
<p>统一管理商品信息、卖点与多角度参考图</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="edit-toggle-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
|
||||
<span className="btn-edit-label">{editMode ? "完成" : "管理商品"}</span>
|
||||
<div className="pl-actions">
|
||||
<button className={`pl-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
||||
<Settings2 />
|
||||
<span>{editMode ? "完成" : "管理商品"}</span>
|
||||
</button>
|
||||
<button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => setDrawer(true)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22V12" /><path d="M16 17h6" /><path d="M19 14v6" /><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="m7.5 4.3 9 5.1" /></svg>
|
||||
新建商品
|
||||
<button className="pl-primary" type="button" onClick={() => setDrawer(true)}>
|
||||
<PackagePlus />
|
||||
<span>新建商品</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="products-main">
|
||||
<div className="toolbar">
|
||||
<div className="search-inline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
<input className="input" id="search-input" placeholder="搜索商品名称、品牌" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
|
||||
<button className={`chip${typeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
|
||||
<span className="chip-label">{typeFilter === "all" ? "类目" : BUSINESS_TYPES[typeFilter]}</span>
|
||||
<svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
<div className="pl-toolbar">
|
||||
<label className="pl-search">
|
||||
<Search />
|
||||
<input id="search-input" placeholder="搜索商品名称" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</label>
|
||||
<div className="pl-toolbar-right">
|
||||
<div className={`pl-select${openChip === "type" ? " open" : ""}`} data-key="type">
|
||||
<button className="pl-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "type"} onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
|
||||
<span>{typeLabel}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className={`mi${typeFilter === "all" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter("all"); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部类目
|
||||
</div>
|
||||
<div className="mi-sep" />
|
||||
<div className="pl-select-menu" role="listbox">
|
||||
<button className={`pl-select-option${typeFilter === "all" ? " selected" : ""}`} type="button" onClick={() => { setTypeFilter("all"); setOpenChip(""); }}>全部类目</button>
|
||||
{BUSINESS_TYPE_KEYS.map((key) => (
|
||||
<div className={`mi${typeFilter === key ? " selected" : ""}`} key={key} role="button" tabIndex={0} onClick={() => { setTypeFilter(key); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{BUSINESS_TYPES[key]}
|
||||
</div>
|
||||
<button className={`pl-select-option${typeFilter === key ? " selected" : ""}`} type="button" key={key} onClick={() => { setTypeFilter(key); setOpenChip(""); }}>{BUSINESS_TYPES[key]}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`chip-wrap${openChip === "cat" ? " open" : ""}`} data-key="cat">
|
||||
<button className={`chip${catFilter.size ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "cat" ? "" : "cat"))}>
|
||||
<span className="chip-label">商品分类</span>
|
||||
{catFilter.size > 0 && <span className="chip-count">{catFilter.size}</span>}
|
||||
<svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
<div className={`pl-select${openChip === "cat" ? " open" : ""}`} data-key="cat">
|
||||
<button className="pl-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "cat"} onClick={() => setOpenChip((c) => (c === "cat" ? "" : "cat"))}>
|
||||
<span>{catLabel}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
{/* 全部分类 = 清空多选(不关菜单,方便连续多选) */}
|
||||
<div className={`mi mi-all${catFilter.size === 0 ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => setCatFilter(new Set())}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部分类
|
||||
</div>
|
||||
{categories.length > 0 && <div className="mi-sep" />}
|
||||
<div className="pl-select-menu" role="listbox">
|
||||
<button className={`pl-select-option${catFilter.size === 0 ? " selected" : ""}`} type="button" onClick={() => setCatFilter(new Set())}>全部分类</button>
|
||||
{categories.map((cat) => (
|
||||
<div className={`mi${catFilter.has(cat) ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => toggleCat(cat)}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{cat}
|
||||
<span className="cat-count">{catCounts[cat] || 0}</span>
|
||||
</div>
|
||||
<button className={`pl-select-option${catFilter.has(cat) ? " selected" : ""}`} type="button" key={cat} onClick={() => toggleCat(cat)}>
|
||||
{cat}
|
||||
<span className="pl-cat-count">{catCounts[cat] || 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`chip-wrap${openChip === "date" ? " open" : ""}`} data-key="date">
|
||||
<button className={`chip${dateFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "date" ? "" : "date"))}>
|
||||
<span className="chip-label">{dateFilter === "all" ? "创建时间" : dateLabel}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
<div className={`pl-select${openChip === "date" ? " open" : ""}`} data-key="date">
|
||||
<button className="pl-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "date"} onClick={() => setOpenChip((c) => (c === "date" ? "" : "date"))}>
|
||||
<span>{dateFilter === "all" ? "创建时间" : dateLabel}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className="pl-select-menu" role="listbox">
|
||||
{DATE_OPTS.map((opt) => (
|
||||
<div className={`mi${dateFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setDateFilter(opt.value); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
|
||||
</div>
|
||||
<button className={`pl-select-option${dateFilter === opt.value ? " selected" : ""}`} type="button" key={opt.value} onClick={() => { setDateFilter(opt.value); setOpenChip(""); }}>{opt.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(query || catFilter.size > 0 || dateFilter !== "all" || typeFilter !== "all") && (
|
||||
<button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(new Set()); setDateFilter("all"); setTypeFilter("all"); setOpenChip(""); }}>
|
||||
{hasFilters && (
|
||||
<button className="pl-clear" type="button" onClick={() => { setQuery(""); setCatFilter(new Set()); setDateFilter("all"); setTypeFilter("all"); setOpenChip(""); }}>
|
||||
<X size={13} /> 清空筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="result-meta" id="result-meta">
|
||||
<span>// {loading && products.length === 0 ? "加载中…" : <>显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个商品</>}</span>
|
||||
<div className="view-tog" style={{ marginLeft: "auto" }} id="view-tog" role="group" aria-label="视图切换">
|
||||
<button type="button" className={view === "grid" ? "active" : ""} aria-pressed={view === "grid"} title="网格视图" onClick={() => setView("grid")}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></svg>
|
||||
<div className="pl-view" role="group" aria-label="视图切换">
|
||||
<button type="button" className={`pl-view-btn${view === "grid" ? " active" : ""}`} aria-pressed={view === "grid"} title="网格视图" onClick={() => setView("grid")}>
|
||||
<Grid2X2 />
|
||||
</button>
|
||||
<button type="button" className={view === "list" ? "active" : ""} aria-pressed={view === "list"} title="列表视图" onClick={() => setView("list")}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 6h18M3 12h18M3 18h18" /></svg>
|
||||
<button type="button" className={`pl-view-btn${view === "list" ? " active" : ""}`} aria-pressed={view === "list"} title="列表视图" onClick={() => setView("list")}>
|
||||
<List />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && products.length === 0 ? (
|
||||
<SkeletonGrid count={8} />
|
||||
) : (
|
||||
<div className="product-grid-wrap">
|
||||
<div className={`product-grid${view === "list" ? " list-view" : ""}`} id="product-grid">
|
||||
{pageItems.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
coverUrl={resolveCoverUrl(product)}
|
||||
videoCount={projects.filter((p) => p.product === product.id).length}
|
||||
editMode={editMode}
|
||||
selected={selected.has(product.id)}
|
||||
onOpen={() => (editMode ? toggleSelect(product.id) : openProduct(product.id))}
|
||||
onOpenVideos={() => openProduct(product.id, "videos")}
|
||||
onDelete={onDelete ? () => setConfirmIds([product.id]) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Pager page={curPage} total={filtered.length} pageSize={PROD_PAGE_SIZE} onChange={setPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 编辑模式浮动操作条 */}
|
||||
<div className={`bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
|
||||
<span className="ct">已选 <b>{selected.size}</b> 项</span>
|
||||
<button className="clear-sel" type="button" onClick={() => setSelected(new Set())}>清空</button>
|
||||
<span className="sep" />
|
||||
<button
|
||||
className="danger"
|
||||
type="button"
|
||||
disabled={selected.size === 0}
|
||||
onClick={() => selected.size && setConfirmIds(Array.from(selected))}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||
删除选中
|
||||
</button>
|
||||
<button type="button" onClick={exitEdit}>退出</button>
|
||||
<div className="pl-section">
|
||||
<div>
|
||||
<h2>全部商品</h2>
|
||||
<p>商品信息将参与脚本推荐与视频生成</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && products.length === 0 ? (
|
||||
<SkeletonGrid count={8} />
|
||||
) : pageItems.length === 0 ? (
|
||||
<div className="pl-empty" role="status">
|
||||
<PackageX />
|
||||
<strong>{hasFilters || query ? "没有找到符合条件的商品" : "还没有商品"}</strong>
|
||||
<span>{hasFilters || query ? "请调整商品名称、分类或创建时间后重试" : "点击右上角新建商品,开始整理卖点与参考图"}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={`pl-grid${view === "list" ? " list" : ""}`} id="product-grid">
|
||||
{pageItems.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
coverUrl={resolveCoverUrl(product)}
|
||||
videoCount={projects.filter((p) => p.product === product.id).length}
|
||||
editMode={editMode}
|
||||
selected={selected.has(product.id)}
|
||||
onOpen={() => (editMode ? toggleSelect(product.id) : openProduct(product.id))}
|
||||
onOpenVideos={() => openProduct(product.id, "videos")}
|
||||
onDelete={onDelete ? () => setConfirmIds([product.id]) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Pager page={curPage} total={filtered.length} pageSize={PROD_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={`pl-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
|
||||
<div className="pl-sel-copy">
|
||||
<strong>已选择 {selected.size} 个商品</strong>
|
||||
<span>删除后可在垃圾桶中恢复</span>
|
||||
</div>
|
||||
<div className="pl-sel-actions">
|
||||
<button className="pl-sel-cancel" type="button" onClick={exitEdit}>取消</button>
|
||||
<button
|
||||
className="pl-sel-confirm"
|
||||
type="button"
|
||||
disabled={selected.size === 0}
|
||||
onClick={() => selected.size && setConfirmIds(Array.from(selected))}
|
||||
>
|
||||
确认删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(confirmIds && confirmIds.length)}
|
||||
title="删除商品"
|
||||
subtitle="// DELETE ITEM"
|
||||
icon={<Trash2 size={16} />}
|
||||
detail={`确定删除选中的 ${confirmIds?.length || 0} 个商品?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
|
||||
confirmText="删除"
|
||||
@@ -355,21 +375,28 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
}
|
||||
|
||||
export function ProductCard({ product, coverUrl = "", videoCount = 0, onOpen, onOpenVideos, editMode = false, selected = false, onDelete }: { product: Product; coverUrl?: string; videoCount?: number; onOpen: () => void; onOpenVideos?: () => void; editMode?: boolean; selected?: boolean; onDelete?: () => void }) {
|
||||
// 优先显示真实上传/采用的主图;无真图时回退到按标题匹配的 mock 占位
|
||||
const mock = productCover(product.title);
|
||||
const assetCount = product.images?.length || 0;
|
||||
// 缺三视图:采用三视图后端会把它落成 product.cover_asset(见 App.tsx onAdoptTriView)。
|
||||
// 列表数据里无法逐条反查 tri_view 素材,只能用真实字段:既无采用封面(cover_asset)、又无上传主图(images)
|
||||
// → 判定该商品尚未补三视图(对齐 V1「新建商品卡显缺三视图」);一旦有封面/图则判不准,不显示(别瞎标)。
|
||||
const missingTri = !isLocalLife(product) && !product.cover_asset && assetCount === 0;
|
||||
const sellingCount = product.selling_points?.length || 0;
|
||||
const category = product.category || "未分类";
|
||||
const projectTag = videoCount <= 0
|
||||
? { label: "暂无项目", tone: "" }
|
||||
: videoCount === 1
|
||||
? { label: "1 个项目", tone: "blue" }
|
||||
: { label: `${videoCount} 个项目`, tone: "green" };
|
||||
return (
|
||||
<div className={`product-card${selected ? " selected" : ""}`} data-cat={product.category} data-name={product.title} role="button" tabIndex={0} aria-pressed={editMode ? selected : undefined} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
|
||||
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
|
||||
<button className="card-del-btn" type="button" title="删除商品" onClick={(event) => { event.stopPropagation(); onDelete?.(); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></button>
|
||||
<article className={`pl-card product-card${selected ? " selected" : ""}`} data-cat={product.category} data-name={product.title} role="button" tabIndex={0} aria-pressed={editMode ? selected : undefined} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
|
||||
<span className="pl-check" aria-hidden="true"><Check /></span>
|
||||
{onDelete && (
|
||||
<button className="card-del-btn" type="button" title="删除商品" onClick={(event) => { event.stopPropagation(); onDelete(); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||
</button>
|
||||
)}
|
||||
{coverUrl ? (
|
||||
<div className="product-thumb has-real-media"><img src={coverUrl} alt={product.title} loading="lazy" /></div>
|
||||
<div className="pl-thumb"><img src={coverUrl} alt={product.title} loading="lazy" /></div>
|
||||
) : (
|
||||
<div className={`placeholder product-thumb${mock ? " has-mock-media" : ""}`} style={mock ? prodMock(mock) : undefined}>
|
||||
<div className={`placeholder pl-thumb${mock ? " has-mock-media" : ""}`} style={mock ? prodMock(mock) : undefined}>
|
||||
{missingTri && (
|
||||
<span className="tri-missing-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明" onClick={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") event.stopPropagation(); }}>
|
||||
<span className="ico" aria-hidden="true"></span>
|
||||
@@ -387,26 +414,22 @@ export function ProductCard({ product, coverUrl = "", videoCount = 0, onOpen, on
|
||||
<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">
|
||||
{isLocalLife(product) && <span className="pill neutral">本地生活</span>}
|
||||
<div className="product-cat">{product.category || "未分类"}</div>
|
||||
<div className="pl-meta">
|
||||
<h3>{product.title}</h3>
|
||||
<p>{isLocalLife(product) ? `本地生活 · ${category}` : category} · {sellingCount} 个核心卖点</p>
|
||||
<div className="pl-meta-line">
|
||||
<button
|
||||
className={`pl-tag${projectTag.tone ? ` ${projectTag.tone}` : ""}`}
|
||||
type="button"
|
||||
title="查看视频项目"
|
||||
onClick={(event) => { event.stopPropagation(); onOpenVideos?.(); }}
|
||||
>
|
||||
{projectTag.label}
|
||||
</button>
|
||||
<span className="pl-tag">{createdLabel(product.created_at)}</span>
|
||||
</div>
|
||||
<div className="product-date">{(product.created_at || "").slice(0, 10)} 创建</div>
|
||||
</div>
|
||||
<div className="product-footer">
|
||||
<span className="stat">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="10" r="2" /><path d="M21 17l-5-5-9 9" /></svg>
|
||||
素材 <b>{assetCount}</b>
|
||||
</span>
|
||||
<span className="sep">·</span>
|
||||
<button className="stat stat-link" type="button" title="查看视频项目" onClick={(event) => { event.stopPropagation(); onOpenVideos?.(); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg>
|
||||
视频 <b>{videoCount}</b>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -453,7 +476,7 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>新建商品</h1>
|
||||
<div className="sub"><span className="mono">// 上传原图 + 填写基本信息</span> · 保存后可在工作台逐步丰富素材</div>
|
||||
<div className="sub"><span className="mono">上传原图 + 填写基本信息</span> · 保存后可在工作台逐步丰富素材</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-ghost" type="button" onClick={onBack}><ArrowLeft size={14} />返回商品库</button>
|
||||
@@ -469,7 +492,7 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
|
||||
<h3>商品原图</h3>
|
||||
<span className="req-tag">必填</span>
|
||||
</div>
|
||||
<div className="card-sub">// 1-5 张 · 这是后续所有 AI 生成的源材料</div>
|
||||
<div className="card-sub">1-5 张 · 这是后续所有 AI 生成的源材料</div>
|
||||
<div className="photo-grid">
|
||||
<div className="photo-slot photo-slot-add" role="button" tabIndex={0} title="上传图片">
|
||||
<span className="plus">
|
||||
@@ -552,8 +575,8 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
|
||||
<div className="form-foot">
|
||||
<span className="req-info">
|
||||
{ready
|
||||
? <>// 必填检查:<span className="ok">已全部完成 ✓</span> · 可进入工作台</>
|
||||
: <>// 必填检查:<span className="miss">{missing.join(" / ")}</span> 未完成</>}
|
||||
? <>必填检查:<span className="ok">已全部完成 ✓</span> · 可进入工作台</>
|
||||
: <>必填检查:<span className="miss">{missing.join(" / ")}</span> 未完成</>}
|
||||
</span>
|
||||
<div className="foot-actions">
|
||||
<button className="btn" type="button" onClick={onBack}>取消</button>
|
||||
@@ -953,10 +976,10 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
<button className="ov-tri-close" type="button" id="ov-tri-close" aria-label="关闭" onClick={() => setTriOpen(false)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
<div className="prod-preview-h">// 三视图预览 · <span id="ov-tri-status">{triGenerating ? "生成中…" : triAdoptedId && triViewing?.id === triAdoptedId ? "已采用" : triUrl ? "已生成" : "待生成"}</span></div>
|
||||
<div className="prod-preview-h">三视图预览 · <span id="ov-tri-status">{triGenerating ? "生成中…" : triAdoptedId && triViewing?.id === triAdoptedId ? "已采用" : triUrl ? "已生成" : "待生成"}</span></div>
|
||||
{/* 单张 16:9 三视图(.prod-preview-img 已 aspect-ratio:16/9) */}
|
||||
<div className="placeholder prod-preview-img" id="ov-tri-img" role={triUrl ? "button" : undefined} tabIndex={triUrl ? 0 : undefined} title={triUrl ? "点击放大" : undefined} style={triUrl ? { cursor: "zoom-in" } : undefined} onClick={triUrl ? () => setPreview({ src: triUrl, name: "三视图" }) : undefined} onKeyDown={triUrl ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: triUrl, name: "三视图" }); } } : undefined}>
|
||||
{triUrl ? <img src={triUrl} alt="三视图" loading="lazy" /> : <span className="ph-frame">{triGenerating ? "// 生成中,请稍候…" : "// 尚未生成 · 点击下方按钮开始"}</span>}
|
||||
{triUrl ? <img src={triUrl} alt="三视图" loading="lazy" /> : <span className="ph-frame">{triGenerating ? "生成中,请稍候…" : "尚未生成 · 点击下方按钮开始"}</span>}
|
||||
</div>
|
||||
<div className="prod-preview-foot" id="ov-tri-foot">
|
||||
<button className="ov-edit primary" type="button" id="ov-tri-start" style={{ height: "28px" }} onClick={generateTriView} disabled={triGenerating || !onGenerateImages}>
|
||||
@@ -975,7 +998,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
</div>
|
||||
{/* 版本历史:点缩略图切换查看;已采用版本主橙描边 + 徽标 */}
|
||||
<div className={`prod-preview-history${triVersions.length ? " show" : ""}`} id="ov-tri-history">
|
||||
<div className="h-lbl">// 历史版本 · <span className="ct" id="ov-tri-history-count">{triVersions.length}</span> 版</div>
|
||||
<div className="h-lbl">历史版本 · <span className="ct" id="ov-tri-history-count">{triVersions.length}</span> 版</div>
|
||||
<div className="h-row" id="ov-tri-history-row">
|
||||
{triVersions.map((version, index) => {
|
||||
const isViewing = version.id === triViewing?.id;
|
||||
@@ -1160,7 +1183,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
<div className="ov-card ov-actions" ref={actionsRef} style={editing && actionsLockH ? { height: `${actionsLockH}px` } : undefined}>
|
||||
<div className="ov-h"><span className="ti">快速操作</span></div>
|
||||
<div className="qa-section">
|
||||
<div className="qa-section-h">// 图片生成</div>
|
||||
<div className="qa-section-h">图片生成</div>
|
||||
<div className={isLocal ? "qa-row-2" : "qa-row-3"}>
|
||||
{!isLocal && (
|
||||
<div className="qa-item" data-go="model-photo" role="button" tabIndex={0} onClick={() => navigate("modelPhoto", { productId: product.id })}>
|
||||
@@ -1179,7 +1202,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
</div>
|
||||
</div>
|
||||
<div className="qa-section">
|
||||
<div className="qa-section-h">// 视频生成</div>
|
||||
<div className="qa-section-h">视频生成</div>
|
||||
<div className="qa-row-1">
|
||||
<div className="qa-item primary" data-go="projects-new" role="button" tabIndex={0} onClick={() => navigate("projectWizard", { productId: product.id })}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg></span>
|
||||
@@ -1213,7 +1236,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></svg>
|
||||
</div>
|
||||
<h3>还没有 AI 素材</h3>
|
||||
<p>// 用右侧「快速操作」生成{isLocal ? "平台套图 / 图片创作" : "模特上身图 / 平台套图 / 商品三视图"}</p>
|
||||
<p>用右侧「快速操作」生成{isLocal ? "平台套图 / 图片创作" : "模特上身图 / 平台套图 / 商品三视图"}</p>
|
||||
<button className="btn btn-primary" type="button" onClick={() => navigate("imageOptimize", { productId: product.id })}>去生成素材</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1233,7 +1256,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
<span className="type-pill">角色</span>
|
||||
{role.triview && <span className="pd-role-tri mono">含三视图</span>}
|
||||
</div>
|
||||
<div className="meta pd-role-foot"><span className="mono">{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "// 暂未引用"}</span></div>
|
||||
<div className="meta pd-role-foot"><span className="mono">{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "暂未引用"}</span></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1323,7 +1346,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
<div className="pack-modal-h">
|
||||
<div>
|
||||
<h2>{openDetail.title}</h2>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {openDetail.sub}</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>{openDetail.sub}</span>
|
||||
</div>
|
||||
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenDetail(null)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties, FormEvent } from "react";
|
||||
import { ArrowUpRight, Grid2X2, List, Search } from "lucide-react";
|
||||
import type { FormEvent } from "react";
|
||||
import { ArrowLeft, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, Grid2X2, Info, List, PackagePlus, Play, Route, Search, Settings2, Trash2, VideoOff } from "lucide-react";
|
||||
import type { Asset, Product, Project, ScriptTemplate } from "../types";
|
||||
import { api } from "../api";
|
||||
import type { Page } from "./route-config";
|
||||
import { ConfirmModal, EmptyPanel, MediaLightbox } from "../components/overlays";
|
||||
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
||||
import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer";
|
||||
import { isLocalLife } from "../product-business";
|
||||
import { Pager } from "../components/pager";
|
||||
@@ -16,6 +16,13 @@ const PROJ_PAGE_SIZE = 8; // 4 列网格两行
|
||||
|
||||
// 创建页只保留商品与项目名;时长在脚本阶段统一落定,避免前置参数脱钩。
|
||||
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
|
||||
const WIZ_RAIL = [
|
||||
{ n: "01", title: "选择商品", desc: "确定本次内容生产的商品主体" },
|
||||
{ n: "02", title: "脚本创建", desc: "生成并确认镜头脚本" },
|
||||
{ n: "03", title: "资产选择", desc: "准备商品、角色与场景资产" },
|
||||
{ n: "04", title: "故事板", desc: "生成并确认视频分镜画面" },
|
||||
{ n: "05", title: "视频生成", desc: "按故事板生成视频片段" },
|
||||
];
|
||||
|
||||
export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct, onUploadImage }: {
|
||||
products: Product[];
|
||||
@@ -44,7 +51,6 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
||||
const [pickSearch, setPickSearch] = useState("");
|
||||
const [pickCat, setPickCat] = useState("全部");
|
||||
const [catOpen, setCatOpen] = useState(false);
|
||||
const [pickView, setPickView] = useState<"grid" | "list">("grid");
|
||||
const [pickPage, setPickPage] = useState(1);
|
||||
|
||||
// 新建商品 · 右侧抽屉开关(抽屉实现与商品库共用 ProductCreateDrawer,保证同一流程)
|
||||
@@ -90,6 +96,14 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
||||
() => ["全部", ...Array.from(new Set(products.map((p) => p.category || "未分类")))],
|
||||
[products]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!catOpen) return;
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!(event.target as HTMLElement).closest(".nw-select")) setCatOpen(false);
|
||||
};
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
}, [catOpen]);
|
||||
|
||||
// 筛选 + 排序后的商品
|
||||
const filtered = useMemo(() => {
|
||||
@@ -157,198 +171,201 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
||||
}
|
||||
}
|
||||
|
||||
// 商品封面图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
|
||||
const productCoverUrl = (p: Product): string => {
|
||||
const sorted = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const primary = p.images?.find((img) => img.is_primary) || sorted[0];
|
||||
return p.cover_preview_url || primary?.preview_url || "";
|
||||
};
|
||||
const imageCount = (p: Product) => p.images?.length || (p.cover_preview_url ? 1 : 0);
|
||||
const pointCount = (p: Product) => p.selling_points?.length || 0;
|
||||
const createdLabel = (iso: string) => {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso.slice(0, 10);
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
if (d.toDateString() === now.toDateString()) return "今天创建";
|
||||
const yest = new Date(now);
|
||||
yest.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yest.toDateString()) return "昨天创建";
|
||||
return `${pad(d.getMonth() + 1)}.${pad(d.getDate())} 创建`;
|
||||
};
|
||||
const selectedCover = product ? productCoverUrl(product) : "";
|
||||
const selectedMeta = product
|
||||
? `${isLocalLife(product) ? "本地生活" : (product.category || "未分类")} · ${imageCount(product)} 张参考图`
|
||||
: "尚未选择商品";
|
||||
|
||||
return (
|
||||
<section className="project-wizard-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>新建项目</h1>
|
||||
<div className="sub"><span className="mono">// 商品 → 配置 · 2 步开始生成</span></div>
|
||||
<header className="nw-head">
|
||||
<div className="nw-title">
|
||||
<button className="nw-back" type="button" onClick={onBack} aria-label="返回视频创作">
|
||||
<ArrowLeft />
|
||||
</button>
|
||||
<div>
|
||||
<h1>新建项目</h1>
|
||||
<p>先选择本次项目使用的商品,下一步再配置内容方向与生成规格</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-ghost" type="button" onClick={onBack}>退出</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="nw-status"><strong>选择商品</strong><span>· 新建项目</span></div>
|
||||
</header>
|
||||
|
||||
<form className="wizard" onSubmit={submit}>
|
||||
{/* ── 左侧步骤轨 ── */}
|
||||
<nav className="steps" aria-label="新建项目步骤">
|
||||
<div className={`step ${product1Done ? "done" : "active"}`}>
|
||||
<div className="num">{product1Done ? (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>
|
||||
) : "1"}</div>
|
||||
<form className="nw-shell" onSubmit={submit}>
|
||||
<aside className="nw-rail" aria-label="项目进度">
|
||||
<header className="nw-rail-head">
|
||||
<span className="nw-rail-icon"><Route /></span>
|
||||
<div>
|
||||
<div className="label">选择商品</div>
|
||||
<div className="desc">{product?.title || "未选择"}</div>
|
||||
<h2>项目进度</h2>
|
||||
<p>按步骤完成项目创建流程</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="nw-rail-summary">
|
||||
<span>已解锁 1 / 5 · 当前第 1 步</span>
|
||||
<div className="nw-rail-track"><span style={{ width: "20%" }} /></div>
|
||||
</div>
|
||||
<div className="nw-rail-steps">
|
||||
{WIZ_RAIL.map((step, index) => (
|
||||
<div className={`nw-step${index === 0 ? " current" : ""}`} key={step.n} hidden={index > 0}>
|
||||
<span className="nw-step-mark">{index === 0 ? <Check /> : step.n}</span>
|
||||
<span className="nw-step-copy">
|
||||
<strong>{step.title}</strong>
|
||||
<span>{step.desc}</span>
|
||||
</span>
|
||||
<ChevronRight className="nw-step-chev" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="nw-picked">
|
||||
<span>本项目商品</span>
|
||||
<div className="nw-picked-card">
|
||||
{selectedCover ? <img src={selectedCover} alt="" /> : <span className="nw-picked-ph" />}
|
||||
<div>
|
||||
<strong>{product?.title || "未选择"}</strong>
|
||||
<small>{selectedMeta}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`step ${config2Done ? "done" : product1Done ? "active" : ""}`}>
|
||||
<div className="num">{config2Done ? (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>
|
||||
) : "2"}</div>
|
||||
</aside>
|
||||
|
||||
<section className="nw-surface" aria-labelledby="nwSelectTitle">
|
||||
<div className="nw-select-head">
|
||||
<div>
|
||||
<div className="label">项目配置</div>
|
||||
<div className="desc">{name.trim() || "未命名"}</div>
|
||||
<h2 id="nwSelectTitle">选择商品</h2>
|
||||
<p>从商品库选择一个 SKU。商品主图、卖点和参考资产将用于后续脚本、分镜与视觉生成。</p>
|
||||
</div>
|
||||
<span className="nw-count">{total} 个商品可选</span>
|
||||
</div>
|
||||
|
||||
<div className="nw-toolbar">
|
||||
<label className="nw-search">
|
||||
<Search size={16} />
|
||||
<input placeholder="搜索商品名称、分类或标签" value={pickSearch} onChange={(event) => { setPickSearch(event.target.value); setPickPage(1); }} onKeyDown={(event) => { if (event.key === "Enter") event.preventDefault(); }} />
|
||||
</label>
|
||||
<div className={`nw-select${catOpen ? " open" : ""}`}>
|
||||
<button className="nw-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={catOpen} onClick={() => setCatOpen((open) => !open)}>
|
||||
<span>{pickCat === "全部" ? "全部分类" : pickCat}</span>
|
||||
<ChevronDown size={15} />
|
||||
</button>
|
||||
<div className="nw-select-menu" role="listbox">
|
||||
{cats.map((cat) => (
|
||||
<button className={`nw-select-option${pickCat === cat ? " selected" : ""}`} type="button" key={cat} onClick={() => { setPickCat(cat); setPickPage(1); setCatOpen(false); }}>
|
||||
{cat === "全部" ? "全部分类" : cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── 主体 ── */}
|
||||
<div className="wiz-body">
|
||||
{/* Step 1 · 商品选择 */}
|
||||
<section className="step-pane-wrap">
|
||||
<div className="wiz-pane">
|
||||
<div className="wiz-step-h">
|
||||
<h2>第 1 步 · 选择商品</h2>
|
||||
<p>从商品库选一个 SKU。它的主图与卖点会被 LLM 作为脚本/资产生成的素材。</p>
|
||||
<div className="nw-grid">
|
||||
<button className="nw-card nw-create" type="button" onClick={openCreateProduct}>
|
||||
<span className="nw-create-icon"><PackagePlus /></span>
|
||||
<strong>创建新商品</strong>
|
||||
<span>商品库中没有合适商品时,在这里补充一个新 SKU</span>
|
||||
</button>
|
||||
{total === 0 ? (
|
||||
<div className="nw-empty" role="status">
|
||||
<strong>{products.length === 0 ? "还没有商品" : "没有符合条件的商品"}</strong>
|
||||
<span>{products.length === 0 ? "先创建一个商品,再回来开项目" : "请调整分类或搜索关键词后重试"}</span>
|
||||
{hasFilter && <button className="nw-empty-reset" type="button" onClick={clearPickFilters}>清空筛选</button>}
|
||||
{products.length === 0 && <button className="nw-empty-reset" type="button" onClick={openCreateProduct}>去创建商品</button>}
|
||||
</div>
|
||||
) : pageList.map((item) => {
|
||||
const cover = productCoverUrl(item);
|
||||
const cat = isLocalLife(item) ? `本地生活${item.category ? " · " + item.category : ""}` : (item.category || "未分类");
|
||||
return (
|
||||
<button className={`nw-card${productId === item.id ? " active" : ""}`} type="button" key={item.id} onClick={() => selectProduct(item.id)}>
|
||||
<span className="nw-check"><Check /></span>
|
||||
<span className="nw-cover">{cover ? <img src={cover} alt="" loading="lazy" /> : null}</span>
|
||||
<span className="nw-meta">
|
||||
<h3>{item.title}</h3>
|
||||
<p>{cat} · {pointCount(item)} 个核心卖点</p>
|
||||
<span className="nw-tags">
|
||||
<span>{imageCount(item)} 张参考图</span>
|
||||
<span>{createdLabel(item.created_at)}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pp-toolbar">
|
||||
<div className="search-inline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
<input type="text" placeholder="搜索商品名称、标签" value={pickSearch} onChange={(event) => { setPickSearch(event.target.value); setPickPage(1); }} />
|
||||
</div>
|
||||
<div className={`pp-chip-wrap${catOpen ? " open" : ""}`}>
|
||||
<button className={`pp-chip${pickCat !== "全部" ? " active" : ""}`} type="button" onClick={() => setCatOpen((open) => !open)}>
|
||||
<span>{pickCat === "全部" ? "全部分类" : pickCat}</span>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="pp-menu">
|
||||
{cats.map((c) => (
|
||||
<div className={`mi${pickCat === c ? " selected" : ""}`} key={c} onClick={() => { setPickCat(c); setPickPage(1); setCatOpen(false); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6"><polyline points="3 8 7 12 13 4" /></svg>
|
||||
<span>{c}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{hasFilter && (
|
||||
<button className="pp-clear" type="button" onClick={clearPickFilters}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||
清空筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{total > WIZ_PAGE_SIZE && (
|
||||
<Pager page={cur} total={total} pageSize={WIZ_PAGE_SIZE} onChange={setPickPage} />
|
||||
)}
|
||||
|
||||
<div className="pp-result-meta">// 显示 {pageList.length} / {total} 个商品{hasFilter ? " (已筛选)" : ""}</div>
|
||||
|
||||
<div className={`pp-grid${pickView === "list" ? " list-view" : ""}`}>
|
||||
<div className="pp-create-card" onClick={openCreateProduct}>
|
||||
<div className="pc-plus"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg></div>
|
||||
<div className="pc-t">创建新商品</div>
|
||||
<div className="pc-d">// 在此添加一个新商品</div>
|
||||
</div>
|
||||
{total === 0 ? (
|
||||
<div className="pp-empty">// NO MATCH<br />没有符合筛选条件的商品 <span className="reset" onClick={clearPickFilters}>[ 清空筛选 ]</span></div>
|
||||
) : (
|
||||
pageList.map((p) => {
|
||||
const coverUrl = productCoverUrl(p);
|
||||
return (
|
||||
<div className={`product-card${productId === p.id ? " selected" : ""}`} key={p.id} onClick={() => selectProduct(p.id)}>
|
||||
{coverUrl ? (
|
||||
<div className="product-thumb has-real-media"><img src={coverUrl} alt={p.title} loading="lazy" /></div>
|
||||
) : (
|
||||
<div className="placeholder product-thumb"><span className="ph-frame">{p.title} · 1200×800</span></div>
|
||||
)}
|
||||
<div className="product-body">
|
||||
<div className="product-name">{p.title}</div>
|
||||
<div className="product-cat">{isLocalLife(p) ? `本地生活${p.category ? " · " + p.category : ""}` : (p.category || "未分类")}</div>
|
||||
<div className="product-date">{(p.created_at || "").slice(0, 10)} 创建</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{total > WIZ_PAGE_SIZE && (
|
||||
<div className="pp-pager">
|
||||
<span className="total">共 {total} 条</span>
|
||||
<div className="pages">
|
||||
<button type="button" disabled={cur === 1} onClick={() => setPickPage(cur - 1)}>‹</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
||||
<button type="button" key={n} className={n === cur ? "active" : ""} onClick={() => setPickPage(n)}>{n}</button>
|
||||
))}
|
||||
<button type="button" disabled={cur === totalPages} onClick={() => setPickPage(cur + 1)}>›</button>
|
||||
</div>
|
||||
<span className="page-size">每页 {WIZ_PAGE_SIZE} 条</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pp-bottom-tip">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v5M12 16h.01" /></svg>
|
||||
<span>找不到想要的商品?可<a onClick={openCreateProduct}>创建新商品</a>,或前往 <a onClick={onBack}>商品库 · 管理商品</a></span>
|
||||
</div>
|
||||
|
||||
{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={openCreateProduct} />}
|
||||
<div className="nw-config">
|
||||
<div className="nw-config-h">
|
||||
<h2>项目配置</h2>
|
||||
<p>基础配置只保留项目名与可选套路模板;脚本来源、风格和时长在下一步由脚本助手落定。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Step 2 · 项目配置 */}
|
||||
<section className="step-pane-wrap">
|
||||
<div className="wiz-pane">
|
||||
<div className="wiz-step-h">
|
||||
<h2>第 2 步 · 项目配置</h2>
|
||||
<p>基础配置只保留项目名与可选的套路模板;脚本来源、风格、人物设定和最终时长都在流水线第 1 步由脚本助手统一落定。</p>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">项目名称<span className="req">*</span></label>
|
||||
<input className="input" value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
|
||||
</div>
|
||||
|
||||
{/* 5.2 套路模板 · 没存过模板就整块不出现,不给空下拉占位 */}
|
||||
{templates.length > 0 && (
|
||||
<div className="field">
|
||||
<label className="field-label">套路模板(可选)</label>
|
||||
<select className="input" value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
|
||||
<option value="">不套用 · 让 AI 按这个商品从头设计</option>
|
||||
{/* 默认模板名已含镜数,选项里只补时长与使用次数,避免「4 镜 · 4 镜」 */}
|
||||
{templates.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name} · {t.total_duration}s{t.usage_count ? ` · 用过 ${t.usage_count} 次` : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* 5.3 预期对齐:先讲清「同套路重出 ≠ 旧片换个商品名」 */}
|
||||
{template && (
|
||||
<div className="tpl-expect">
|
||||
<div className="te-head"><span className="mono">// 同套路重出</span>沿用「{template.name}」的镜数、每镜作用和节奏</div>
|
||||
<div className="te-outline">{template.outline_text}</div>
|
||||
<div className="te-note">文案、画面、模特与故事板都会按 {product?.title || "所选商品"} 重新生成 —— 不是把上一条片子换个商品名。生成后仍可在脚本页逐镜改。</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(points).length > 0 && (
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label">关键卖点(可勾选要重点突出的)</label>
|
||||
<div className="theme-pill-row">
|
||||
{Object.entries(points).map(([k, v]) => (
|
||||
<button className={`theme-pill${v ? " active" : ""}`} type="button" key={k} aria-pressed={v} onClick={() => setPoints((prev) => ({ ...prev, [k]: !prev[k] }))}>
|
||||
{v && <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>}
|
||||
<span>{k}</span>
|
||||
</button>
|
||||
))}
|
||||
<label className="nw-field">
|
||||
<span>项目名称<small>必填</small></span>
|
||||
<input value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
|
||||
</label>
|
||||
{templates.length > 0 && (
|
||||
<label className="nw-field">
|
||||
<span>套路模板<small>可选</small></span>
|
||||
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
|
||||
<option value="">不套用 · 让 AI 按这个商品从头设计</option>
|
||||
{templates.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name} · {item.total_duration}s{item.usage_count ? ` · 用过 ${item.usage_count} 次` : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
{template && (
|
||||
<div className="nw-tpl">
|
||||
<strong>同套路重出</strong>
|
||||
<p>沿用「{template.name}」的镜数、每镜作用和节奏。文案、画面、模特与故事板都会按 {product?.title || "所选商品"} 重新生成。</p>
|
||||
{template.outline_text ? <pre>{template.outline_text}</pre> : null}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
{Object.keys(points).length > 0 && (
|
||||
<div className="nw-field">
|
||||
<span>关键卖点<small>可勾选要重点突出的</small></span>
|
||||
<div className="nw-pills">
|
||||
{Object.entries(points).map(([key, on]) => (
|
||||
<button className={`nw-pill${on ? " on" : ""}`} type="button" key={key} aria-pressed={on} onClick={() => setPoints((prev) => ({ ...prev, [key]: !prev[key] }))}>
|
||||
{on && <Check size={13} />}
|
||||
<span>{key}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 底部「开始」CTA ── */}
|
||||
<div className="wiz-start-bar">
|
||||
<button className={`btn-start${canStart && !starting ? "" : " disabled"}`} type="submit" disabled={!canStart || starting}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 3l14 9-14 9V3z" /></svg>
|
||||
<span>{starting ? "创建中…" : "开始"}</span>
|
||||
<div className="nw-actions">
|
||||
<span className="nw-tip">
|
||||
<Info />
|
||||
<span>找不到需要的商品?可先创建新商品,完善后会自动返回本流程。</span>
|
||||
</span>
|
||||
<button className="nw-next" type="submit" disabled={!canStart || starting}>
|
||||
<span>{starting ? "创建中…" : "确认商品并创建项目"}</span>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
{/* 新建商品 · 右侧抽屉 · 与商品库新建商品共用同一组件 → 落库到商品库,同一流程 */}
|
||||
@@ -372,27 +389,12 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
||||
const PROJ_STAGE_TOTAL = 4;
|
||||
const PROJ_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 4 };
|
||||
function projStageNo(project: Project) { return project.status === "completed" ? PROJ_STAGE_TOTAL : (PROJ_STAGE_NO[project.current_stage] || 1); }
|
||||
// 进度格 class:
|
||||
// · 已完成 → 含末格在内全部 done(绿),末格不再用闪烁的 cur
|
||||
// · 失败 → 停在第 no 格标红(fail),之前的 done
|
||||
// · 进行中 → 已过的格 done,当前格 cur(一闪一闪),未到的格留空
|
||||
function projProgClass(project: Project, i: number, no: number): string {
|
||||
if (project.status === "completed") return i <= no ? "done" : "";
|
||||
if (project.status === "failed") return i === no ? "fail" : i < no ? "done" : "";
|
||||
return i < no ? "done" : i === no ? "cur" : "";
|
||||
}
|
||||
function projBucket(project: Project) {
|
||||
if (project.status === "completed") return "done";
|
||||
if (project.status === "failed") return "fail";
|
||||
if (project.status === "draft") return "draft";
|
||||
return "wip";
|
||||
}
|
||||
function projStatusLabel(project: Project) {
|
||||
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
|
||||
}
|
||||
// 失败态用现成 .pill.err(crimson),不存在 .pill.fail → 退化无色
|
||||
function projPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; }
|
||||
function projDate(project: Project) { return (project.updated_at || "").slice(0, 10); }
|
||||
// 当前脚本版本:优先已采用,否则取第一版(轻量列表序列化可能不含 segments,故全程可选链)
|
||||
function projScript(project: Project) {
|
||||
const list = project.script_versions || [];
|
||||
@@ -420,8 +422,6 @@ function projShotMeta(project: Project): string {
|
||||
}
|
||||
// 项目卡封面 = 该项目商品的主图(后端 cover_preview_url,取商品 cover_asset)。无图 → 占位。
|
||||
// (原先按项目名关键词映射静态 mock 假图,与真实数据脱节,已废弃。)
|
||||
const coverStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
||||
|
||||
const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; label: string }> = [
|
||||
{ filter: "all", label: "全部" },
|
||||
{ filter: "draft", label: "草稿" },
|
||||
@@ -431,26 +431,24 @@ const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; labe
|
||||
];
|
||||
type ProjTab = (typeof PROJ_TABS)[number]["filter"];
|
||||
|
||||
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string }> = [
|
||||
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频。", page: "projectWizard", image: "/exact/assets/mock/person-linxi.png" },
|
||||
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成。", page: "projectWizard", image: "/exact/assets/mock/scene-cafe.png" },
|
||||
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string; primary?: boolean }> = [
|
||||
{ title: "极速成片", desc: "输入商品名称并上传图片,自动完成脚本、场景、故事板和视频生成。", page: "projectWizard", image: "/assets/yz/video-quick.jpg", primary: true },
|
||||
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产、故事板和最终视频。", page: "projectWizard", image: "/assets/yz/video-pro.jpg", primary: true },
|
||||
];
|
||||
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [
|
||||
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词。", page: "freeCreate", image: "/exact/assets/mock/scene-night-street.png" },
|
||||
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品。", page: "freeCreate", image: "/exact/assets/mock/cover-coffee.png" },
|
||||
{ title: "自由生成", desc: "用提示词与参考视频自由生成镜头。", page: "freeCreate", image: "/exact/assets/mock/scene-bedroom.png" },
|
||||
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" },
|
||||
{ title: "视频复刻", desc: "上传参考视频,拆解镜头、动作和节奏,生成可编辑复刻提示词。", page: "freeCreate", image: "/assets/yz/video-remix.jpg" },
|
||||
{ title: "商品替换", desc: "保留参考视频的人物、场景与节奏,将原商品替换为自己的商品。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
|
||||
];
|
||||
|
||||
function projCardSub(project: Project): string {
|
||||
function projCardSub(project: Project, productTitle: string): string {
|
||||
if (project.status === "failed") return project.failure_reason || "生成失败,可重新拍摄";
|
||||
return projStageLabel(project);
|
||||
const shots = projShotMeta(project);
|
||||
const stage = projStageLabel(project);
|
||||
if (project.status === "completed") return `专业创作 · ${productTitle} · ${shots}`;
|
||||
const no = projStageNo(project);
|
||||
return `专业创作 · ${stage} · ${no} / ${PROJ_STAGE_TOTAL}`;
|
||||
}
|
||||
function projCardCta(project: Project): string {
|
||||
if (project.status === "failed") return "重新拍摄";
|
||||
if (project.status === "completed") return "查看";
|
||||
return "继续";
|
||||
}
|
||||
// 项目卡「当前阶段」文案(对齐 YZ 稿:资产创建 / 视频生成)
|
||||
function projStageLabel(project: Project): string {
|
||||
if (project.status === "failed") return "失败";
|
||||
const map: Record<string, string> = {
|
||||
@@ -462,13 +460,6 @@ function projStageLabel(project: Project): string {
|
||||
};
|
||||
return map[project.current_stage] || "视频生成";
|
||||
}
|
||||
function projCardMeta(project: Project, productTitle: string): string {
|
||||
const shots = projShots(project);
|
||||
return ["专业创作", productTitle, shots ? `${shots} 镜` : null].filter(Boolean).join(" / ");
|
||||
}
|
||||
function projRowCta(project: Project): string {
|
||||
return project.status === "completed" ? "查看" : "继续";
|
||||
}
|
||||
|
||||
export function ProjectsPage({ products, projects, loading = false, navigate, openPipeline, onDelete, initialTab }: {
|
||||
products: Product[];
|
||||
@@ -496,11 +487,8 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||
const [bulkConfirm, setBulkConfirm] = useState(false);
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle("edit-mode", editMode);
|
||||
return () => document.body.classList.remove("edit-mode");
|
||||
}, [editMode]);
|
||||
useEffect(() => { if (!editMode) setSelected(new Set()); }, [editMode]);
|
||||
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
|
||||
function toggleSelect(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -529,7 +517,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
||||
useEffect(() => {
|
||||
if (!openChip) return;
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
|
||||
if (!(event.target as HTMLElement).closest(".vc-select")) setOpenChip("");
|
||||
};
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
@@ -577,25 +565,35 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="projects-page">
|
||||
<section className={`projects-page${editMode ? " manage-mode" : ""}`}>
|
||||
<header className="vc-head">
|
||||
<h1>视频创作</h1>
|
||||
<p>从商品出发,一站完成电商短视频生产</p>
|
||||
<div>
|
||||
<h1>视频创作</h1>
|
||||
<p>从商品或参考视频出发,选择适合当前任务的生产方式</p>
|
||||
</div>
|
||||
<div className="vc-actions">
|
||||
<button className={`vc-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
||||
<Settings2 />
|
||||
<span>{editMode ? "完成" : "管理项目"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="vc-sec">
|
||||
<div className="vc-sec-h">
|
||||
<h2>商品视频生产</h2>
|
||||
<p>上传商品即可生成带货短视频</p>
|
||||
<p>以商品卖点为核心,快速生成完整带货视频</p>
|
||||
</div>
|
||||
<div className="vc-row cols-2">
|
||||
<div className="vc-tools two">
|
||||
{VIDEO_MAKE.map((card) => (
|
||||
<button className="vc-card" type="button" key={card.title} onClick={() => navigate(card.page)}>
|
||||
<div className="vc-visual"><img src={card.image} alt="" /></div>
|
||||
<div className="vc-copy">
|
||||
<span className="vc-arrow" aria-hidden="true"><ArrowUpRight size={16} /></span>
|
||||
<span className="t">{card.title}</span>
|
||||
<span className="d">{card.desc}</span>
|
||||
<button className={`vc-tool${card.primary ? " primary" : ""}`} type="button" key={card.title} onClick={() => navigate(card.page)}>
|
||||
<div className="vc-tool-cover"><img src={card.image} alt="" /></div>
|
||||
<div className="vc-tool-body">
|
||||
<div className="vc-tool-title">
|
||||
<h3>{card.title}</h3>
|
||||
<ArrowUpRight size={21} />
|
||||
</div>
|
||||
<p>{card.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -605,204 +603,187 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
||||
<section className="vc-sec">
|
||||
<div className="vc-sec-h">
|
||||
<h2>AI 视频工具</h2>
|
||||
<p>复刻参考片、替换商品或自由生成</p>
|
||||
<p>自由生成,或复用参考视频中已经验证过的内容表达</p>
|
||||
</div>
|
||||
<div className="vc-row cols-3">
|
||||
<div className="vc-tools three">
|
||||
{VIDEO_TOOLS.map((card) => (
|
||||
<button className="vc-card" type="button" key={card.title} onClick={() => navigate(card.page)}>
|
||||
<div className="vc-visual"><img src={card.image} alt="" /></div>
|
||||
<div className="vc-copy">
|
||||
<span className="vc-arrow" aria-hidden="true"><ArrowUpRight size={16} /></span>
|
||||
<span className="t">{card.title}</span>
|
||||
<span className="d">{card.desc}</span>
|
||||
<button className="vc-tool" type="button" key={card.title} onClick={() => navigate(card.page)}>
|
||||
<div className="vc-tool-cover"><img src={card.image} alt="" /></div>
|
||||
<div className="vc-tool-body">
|
||||
<div className="vc-tool-title">
|
||||
<h3>{card.title}</h3>
|
||||
<ArrowUpRight size={21} />
|
||||
</div>
|
||||
<p>{card.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="vc-projects">
|
||||
<div className="vc-sec-h">
|
||||
<h2>视频项目</h2>
|
||||
<p>查看视频任务状态与生成结果</p>
|
||||
</div>
|
||||
<div className="vc-sec-h vc-list-h">
|
||||
<h2>视频项目</h2>
|
||||
<p>统一查看各类视频任务的状态与生成结果</p>
|
||||
</div>
|
||||
|
||||
<div className="vc-toolbar">
|
||||
<div className="vc-pills" role="tablist" aria-label="项目状态">
|
||||
{PROJ_TABS.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.filter}
|
||||
className={`vc-pill${tab === t.filter ? " active" : ""}`}
|
||||
key={t.filter}
|
||||
onClick={() => setTab(t.filter)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="vc-toolbar">
|
||||
<div className="vc-seg" role="tablist" aria-label="项目状态">
|
||||
{PROJ_TABS.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.filter}
|
||||
className={`vc-seg-btn${tab === t.filter ? " active" : ""}`}
|
||||
key={t.filter}
|
||||
onClick={() => setTab(t.filter)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="vc-toolbar-right">
|
||||
<label className="vc-search">
|
||||
<Search size={16} />
|
||||
<input placeholder="搜索视频项目" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</label>
|
||||
<div className={`vc-select${openChip === "time" ? " open" : ""}`} data-key="time">
|
||||
<button className="vc-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "time"} onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
|
||||
<span>{timeLabel}</span>
|
||||
<ChevronDown size={15} />
|
||||
</button>
|
||||
<div className="vc-select-menu" role="listbox">
|
||||
{TIME_OPTS.map((opt) => (
|
||||
<button className={`vc-select-option${timeFilter === opt.value ? " selected" : ""}`} type="button" key={opt.value} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>{opt.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vc-toolbar-right">
|
||||
<div className="vc-search">
|
||||
<Search size={14} />
|
||||
<input placeholder="搜索项目名称" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
|
||||
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
|
||||
<span className="chip-label">{timeLabel}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
{TIME_OPTS.map((opt) => (
|
||||
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product">
|
||||
<button className={`chip${catFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
|
||||
<span className="chip-label">{catFilter || "全部类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className={`mi${!catFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setCatFilter(""); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部类型
|
||||
</div>
|
||||
{projectCategories.length > 0 && <div className="mi-sep" />}
|
||||
{projectCategories.map((cat) => (
|
||||
<div className={`mi${catFilter === cat ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => { setCatFilter(cat); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{cat}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vc-view">
|
||||
<button type="button" className={view === "grid" ? "active" : ""} aria-label="网格" onClick={() => setView("grid")}>
|
||||
<Grid2X2 size={14} />
|
||||
</button>
|
||||
<button type="button" className={view === "list" ? "active" : ""} aria-label="列表" onClick={() => setView("list")}>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<div className={`vc-select${openChip === "product" ? " open" : ""}`} data-key="product">
|
||||
<button className="vc-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "product"} onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
|
||||
<span>{catFilter || "商品分类"}</span>
|
||||
<ChevronDown size={15} />
|
||||
</button>
|
||||
<div className="vc-select-menu" role="listbox">
|
||||
<button className={`vc-select-option${!catFilter ? " selected" : ""}`} type="button" onClick={() => { setCatFilter(""); setOpenChip(""); }}>全部分类</button>
|
||||
{projectCategories.map((cat) => (
|
||||
<button className={`vc-select-option${catFilter === cat ? " selected" : ""}`} type="button" key={cat} onClick={() => { setCatFilter(cat); setOpenChip(""); }}>{cat}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vc-view">
|
||||
<button type="button" className={`vc-view-btn${view === "grid" ? " active" : ""}`} aria-label="网格显示" onClick={() => setView("grid")}>
|
||||
<Grid2X2 size={16} />
|
||||
</button>
|
||||
<button type="button" className={`vc-view-btn${view === "list" ? " active" : ""}`} aria-label="列表显示" onClick={() => setView("list")}>
|
||||
<List size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && projects.length === 0 ? (
|
||||
<SkeletonRows count={6} />
|
||||
) : view === "grid" ? (
|
||||
<div className="vc-grid">
|
||||
{pageItems.map((project) => {
|
||||
const cover = project.cover_preview_url || "";
|
||||
const isSel = selected.has(project.id);
|
||||
const bucket = projBucket(project);
|
||||
const openProject = () => {
|
||||
if (editMode) toggleSelect(project.id);
|
||||
else if (project.final_video_url) setPlaying(project);
|
||||
else openPipeline(project.id);
|
||||
};
|
||||
return (
|
||||
<article
|
||||
className={`vc-proj${editMode && isSel ? " selected" : ""}`}
|
||||
key={project.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openProject}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
|
||||
>
|
||||
<div className={`vc-proj-visual${cover ? " has-img" : ""}`}>
|
||||
{cover ? <img src={cover} alt="" loading="lazy" decoding="async" /> : null}
|
||||
</div>
|
||||
<div className="vc-proj-body">
|
||||
<div className="vc-proj-name">{project.name}</div>
|
||||
<div className="vc-proj-sub">{projCardSub(project)}</div>
|
||||
<div className="vc-proj-foot">
|
||||
<span className={`vc-tag ${bucket}`}>{bucket === "fail" ? "失败" : bucket === "done" ? "已完成" : bucket === "draft" ? "草稿" : "生成中"}</span>
|
||||
<button
|
||||
className="vc-proj-cta"
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); openProject(); }}
|
||||
>
|
||||
{projCardCta(project)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="vc-empty" role="status">
|
||||
<VideoOff />
|
||||
<strong>{projects.length === 0 ? "还没有视频项目" : "没有符合条件的视频项目"}</strong>
|
||||
<span>{projects.length === 0 ? "从上方入口开始生成" : "请调整状态、时间、分类或搜索关键词后重试"}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="proj-card-list" id="list-view">
|
||||
{pageItems.map((project) => {
|
||||
const no = projStageNo(project);
|
||||
const cover = project.cover_preview_url || "";
|
||||
const isSel = selected.has(project.id);
|
||||
const openProject = () => {
|
||||
if (editMode) toggleSelect(project.id);
|
||||
else if (project.final_video_url) setPlaying(project);
|
||||
else openPipeline(project.id);
|
||||
};
|
||||
return (
|
||||
<article
|
||||
key={project.id}
|
||||
className={`proj-card-row${editMode && isSel ? " selected" : ""}`}
|
||||
data-status={projBucket(project)}
|
||||
data-name={project.name}
|
||||
onClick={openProject}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{editMode && (
|
||||
<span className={`row-check${isSel ? " on" : ""}`} aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
|
||||
)}
|
||||
<div className={`placeholder proj-card-row-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}><span className="ph-frame">9:16</span></div>
|
||||
<div className="proj-card-row-main">
|
||||
<div className="proj-card-row-title">{project.name}</div>
|
||||
<div className="proj-card-row-sub">{projCardMeta(project, productTitle(project.product))}</div>
|
||||
</div>
|
||||
<div className="proj-card-row-stage">
|
||||
<div className="proj-card-row-stage-lbl">当前阶段</div>
|
||||
<div className="proj-card-row-stage-name">{projStageLabel(project)}</div>
|
||||
<div className="prog proj-prog-yz">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
|
||||
</div>
|
||||
{!editMode && (
|
||||
<>
|
||||
<div className={`vc-grid${view === "list" ? " list" : ""}`}>
|
||||
{pageItems.map((project) => {
|
||||
const cover = project.cover_preview_url || "";
|
||||
const isSel = selected.has(project.id);
|
||||
const bucket = projBucket(project);
|
||||
const no = projStageNo(project);
|
||||
const openProject = () => {
|
||||
if (editMode) toggleSelect(project.id);
|
||||
else openPipeline(project.id);
|
||||
};
|
||||
const statusLabel = bucket === "fail" ? "失败" : bucket === "done" ? "已完成" : bucket === "draft" ? "草稿" : "生成中";
|
||||
return (
|
||||
<article
|
||||
className={`vc-card project-card${isSel ? " selected" : ""}`}
|
||||
key={project.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openProject}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
|
||||
>
|
||||
<span className="vc-check" aria-hidden="true"><Check /></span>
|
||||
<button
|
||||
className="proj-card-row-cta"
|
||||
className="card-del-btn"
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); openProject(); }}
|
||||
title="删除项目"
|
||||
onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}
|
||||
>
|
||||
{projRowCta(project)}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||
</button>
|
||||
)}
|
||||
{!editMode && (
|
||||
<span className="row-more proj-card-row-more" onClick={(event) => event.stopPropagation()}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg>
|
||||
<div className="row-more-tip"><button className="mi" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>删除项目</button></div>
|
||||
</span>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="vc-thumb">
|
||||
{cover ? <img src={cover} alt="" loading="lazy" decoding="async" /> : null}
|
||||
{!editMode && project.final_video_url ? (
|
||||
<button
|
||||
className="vc-play"
|
||||
type="button"
|
||||
aria-label={`播放「${project.name}」成片`}
|
||||
onClick={(event) => { event.stopPropagation(); setPlaying(project); }}
|
||||
>
|
||||
<Play />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="vc-meta">
|
||||
<h3>{project.name}</h3>
|
||||
<p>{projCardSub(project, productTitle(project.product))}</p>
|
||||
<div className="vc-line">
|
||||
<span className={`vc-tag ${bucket}`}>{statusLabel}</span>
|
||||
<span className="vc-tag type">专业创作</span>
|
||||
</div>
|
||||
{bucket === "wip" && (
|
||||
<div className="vc-progress" aria-hidden="true"><span style={{ width: `${Math.round((no / PROJ_STAGE_TOTAL) * 100)}%` }} /></div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
|
||||
{filtered.length === 0 && !loading && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
|
||||
</section>
|
||||
|
||||
<div className={`bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量管理">
|
||||
<span className="ct">已选 <b>{selected.size}</b> 项</span>
|
||||
<button className="clear-sel" type="button" disabled={selected.size === 0} onClick={() => setSelected(new Set())}>清空</button>
|
||||
<span className="sep" />
|
||||
<button className="danger" type="button" disabled={selected.size === 0 || bulkDeleting} onClick={() => setBulkConfirm(true)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||
{bulkDeleting ? "删除中…" : "删除所选"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setEditMode(false)}>完成</button>
|
||||
<div className={`vc-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
|
||||
<div className="vc-sel-copy">
|
||||
<strong>已选择 {selected.size} 个项目</strong>
|
||||
<span>删除后可在垃圾桶中恢复</span>
|
||||
</div>
|
||||
<div className="vc-sel-actions">
|
||||
<button className="vc-sel-cancel" type="button" onClick={exitEdit}>取消</button>
|
||||
<button className="vc-sel-confirm" type="button" disabled={selected.size === 0 || bulkDeleting} onClick={() => selected.size && setBulkConfirm(true)}>
|
||||
{bulkDeleting ? "删除中…" : "确认删除"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaLightbox open={Boolean(playing)} src={playing?.final_video_url || ""} kind="video" name={`${playing?.name || ""} · 成片`} close={() => setPlaying(null)} />
|
||||
|
||||
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}。`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
|
||||
<ConfirmModal open={bulkConfirm} title="确认批量删除" detail={`即将删除选中的 ${selected.size} 个项目。`} confirmText="删除" onCancel={() => setBulkConfirm(false)} onConfirm={confirmBulkDelete} />
|
||||
<ConfirmModal
|
||||
open={Boolean(deleteTarget)}
|
||||
title="删除项目"
|
||||
icon={<Trash2 size={16} />}
|
||||
detail={`确定删除「${deleteTarget?.name || ""}」?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
|
||||
confirmText="删除"
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
<ConfirmModal
|
||||
open={bulkConfirm}
|
||||
title="删除项目"
|
||||
icon={<Trash2 size={16} />}
|
||||
detail={`确定删除选中的 ${selected.size} 个项目?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
|
||||
confirmText="删除"
|
||||
onCancel={() => setBulkConfirm(false)}
|
||||
onConfirm={confirmBulkDelete}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,9 +72,9 @@ export const mainNav: NavItem[] = [
|
||||
{ page: "dashboard", label: "工作台", icon: Home },
|
||||
{ page: "products", label: "商品库", icon: Package },
|
||||
{ page: "models", label: "模特库", icon: UserRound },
|
||||
{ page: "projects", label: "视频项目", icon: FolderKanban },
|
||||
{ page: "projects", label: "视频创作", icon: FolderKanban },
|
||||
{ page: "pipeline", label: "生产管线", icon: WandSparkles },
|
||||
{ page: "library", label: "资产库", icon: Library },
|
||||
{ page: "library", label: "成品库", icon: Library },
|
||||
{ page: "account", label: "账户", icon: Wallet }
|
||||
];
|
||||
|
||||
@@ -92,10 +92,10 @@ export const routeLabels: Record<Page, string> = {
|
||||
productDetail: "商品详情",
|
||||
productCreateUpload: "上传创建商品",
|
||||
models: "模特库",
|
||||
projects: "视频项目",
|
||||
projects: "视频创作",
|
||||
projectWizard: "新建视频项目",
|
||||
pipeline: "生产管线",
|
||||
library: "资产库",
|
||||
library: "成品库",
|
||||
account: "账户",
|
||||
team: "团队",
|
||||
messages: "消息",
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Bell,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
Shield,
|
||||
Upload,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
@@ -16,32 +16,38 @@ type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
|
||||
// 可导航(UI 可见)的分区。pref(创作默认)/ display(显示)已隐藏入口,故不在此列。
|
||||
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify"];
|
||||
|
||||
const NAV: Array<{ group: string; items: Array<{ key: SectionKey; label: string; icon: ReactNode }> }> = [
|
||||
{
|
||||
group: "个人",
|
||||
items: [
|
||||
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
|
||||
{ key: "security", label: "安全", icon: <ShieldCheck /> },
|
||||
{ key: "notify", label: "通知", icon: <Bell /> },
|
||||
],
|
||||
},
|
||||
// 「偏好」组(创作默认 / 显示)已隐藏 —— 入口移除,对应面板因导航不可达而不再显示。
|
||||
const NAV: Array<{ key: SectionKey; label: string; icon: ReactNode }> = [
|
||||
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
|
||||
{ key: "security", label: "安全", icon: <Shield /> },
|
||||
{ key: "notify", label: "通知", icon: <Bell /> },
|
||||
];
|
||||
|
||||
function SettingRow({ title, hint, stack, children }: { title: string; hint?: string; stack?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div className={`st-row${stack ? " st-row-stack" : ""}`}>
|
||||
<div className="st-copy">
|
||||
<strong>{title}</strong>
|
||||
{hint ? <span>{hint}</span> : null}
|
||||
</div>
|
||||
<div className="st-val">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TEMPLATE_CHOICES = [
|
||||
{ v: "pain", t: "痛点种草", d: "// 30s 默认档" },
|
||||
{ v: "unbox", t: "开箱测评", d: "// 45s 默认档" },
|
||||
{ v: "compare", t: "对比展示", d: "// 45s 默认档" },
|
||||
{ v: "howto", t: "教程演示", d: "// 60s 默认档" },
|
||||
{ v: "drama", t: "剧情带货", d: "// 60s 默认档" },
|
||||
{ v: "pain", t: "痛点种草", d: "30s 默认档" },
|
||||
{ v: "unbox", t: "开箱测评", d: "45s 默认档" },
|
||||
{ v: "compare", t: "对比展示", d: "45s 默认档" },
|
||||
{ v: "howto", t: "教程演示", d: "60s 默认档" },
|
||||
{ v: "drama", t: "剧情带货", d: "60s 默认档" },
|
||||
];
|
||||
|
||||
const SUBTITLE_CHOICES = [
|
||||
{ v: "big-variety", t: "大字综艺", d: "// 抖音热门" },
|
||||
{ v: "clean-ec", t: "简洁电商", d: "// 信息清晰" },
|
||||
{ v: "premium", t: "高级排版", d: "// 居中衬线" },
|
||||
{ v: "bullet", t: "弹幕轻量", d: "// 滚动出现" },
|
||||
{ v: "emphasis", t: "强调爆款", d: "// 高对比" },
|
||||
{ v: "big-variety", t: "大字综艺", d: "抖音热门" },
|
||||
{ v: "clean-ec", t: "简洁电商", d: "信息清晰" },
|
||||
{ v: "premium", t: "高级排版", d: "居中衬线" },
|
||||
{ v: "bullet", t: "弹幕轻量", d: "滚动出现" },
|
||||
{ v: "emphasis", t: "强调爆款", d: "高对比" },
|
||||
];
|
||||
|
||||
const DURATIONS = ["30", "45", "60"];
|
||||
@@ -56,12 +62,19 @@ function deviceName(ua: string): string {
|
||||
|
||||
// 仅保留已真实接入「站内通知」的行;邮箱/短信/异地登录告警渠道未接入,隐藏对应行。
|
||||
const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: string }> = [
|
||||
{ key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内" },
|
||||
{ key: "n-export", title: "项目完成通知", sub: "视频导出后", channels: "站内" },
|
||||
{ key: "n-fail", title: "任务失败告警", channels: "站内" },
|
||||
{ key: "n-quota", title: "额度不足提醒", sub: "// 团队或个人剩余 < 20%", channels: "站内" },
|
||||
{ key: "n-quota", title: "额度不足提醒", sub: "团队或个人剩余 < 20%", channels: "站内" },
|
||||
// n-login(异地登录告警·短信渠道)未接入,已移除。
|
||||
];
|
||||
|
||||
const MUTE_TYPE_ROWS: Array<{ key: string; title: string; hint: string }> = [
|
||||
{ key: "task", title: "任务提醒", hint: "关闭后消息中心不再显示此类" },
|
||||
{ key: "team", title: "团队协作", hint: "关闭后消息中心不再显示此类" },
|
||||
{ key: "billing", title: "计费通知", hint: "关闭后消息中心不再显示此类" },
|
||||
{ key: "system", title: "系统公告", hint: "关闭后消息中心不再显示此类" },
|
||||
];
|
||||
|
||||
// ─── 偏好默认值 · 与后端 UserPreference 默认一致(后端到达前的占位) ───
|
||||
const DEFAULT_PREFS = {
|
||||
template: "pain",
|
||||
@@ -117,7 +130,9 @@ const FIELD_SECTION: Record<keyof TrackedState, SectionKey> = {
|
||||
};
|
||||
|
||||
function notifyEqual(a: Record<string, boolean>, b: Record<string, boolean>): boolean {
|
||||
return NOTIFY_ROWS.every((row) => !!a[row.key] === !!b[row.key]);
|
||||
const eventsMatch = NOTIFY_ROWS.every((row) => !!a[row.key] === !!b[row.key]);
|
||||
const mutesMatch = MUTE_TYPE_ROWS.every((row) => !!a[`mute-${row.key}`] === !!b[`mute-${row.key}`]);
|
||||
return eventsMatch && mutesMatch;
|
||||
}
|
||||
|
||||
function Switch({ checked, disabled, onChange }: { checked: boolean; disabled?: boolean; onChange?: (next: boolean) => void }) {
|
||||
@@ -243,7 +258,7 @@ export function SettingsPage({
|
||||
|
||||
const avatarChar = useMemo(() => (name || user.username || "李").slice(0, 1).toUpperCase(), [name, user.username]);
|
||||
|
||||
// ─── nav badge · 由 state 计算(通知开启数 / 设备数),不写死 ───
|
||||
// ─── nav badge · 由真实会话 / 通知开关计算,接口已有,保留 ───
|
||||
const notifyOnCount = useMemo(() => NOTIFY_ROWS.filter((row) => !!draft.notify[row.key]).length, [draft.notify]);
|
||||
const navBadge = useCallback(
|
||||
(key: SectionKey): string | null => {
|
||||
@@ -388,174 +403,147 @@ export function SettingsPage({
|
||||
</div>
|
||||
|
||||
<div className="settings-grid">
|
||||
{/* 左侧 nav */}
|
||||
<aside className="settings-nav" role="tablist" aria-label="设置分区">
|
||||
{NAV.map((group, gi) => (
|
||||
<div key={group.group}>
|
||||
<div className="nav-h" style={gi > 0 ? { marginTop: 16 } : undefined}>{group.group}</div>
|
||||
{group.items.map((item) => {
|
||||
const badge = navBadge(item.key);
|
||||
const dirty = dirtySections.has(item.key);
|
||||
return (
|
||||
<a
|
||||
key={item.key}
|
||||
href={`#sec-${item.key}`}
|
||||
className={`${section === item.key ? "active" : ""}${dirty ? " has-changes" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={section === item.key}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setSection(item.key);
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
{badge ? <span className="nav-badge">{badge}</span> : null}
|
||||
<span className="nav-dot" aria-hidden="true" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div className="nav-h" style={{ marginTop: 16 }}>账号</div>
|
||||
{NAV.map((item) => {
|
||||
const badge = navBadge(item.key);
|
||||
const dirty = dirtySections.has(item.key);
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`st-tab${section === item.key ? " active" : ""}${dirty ? " has-changes" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={section === item.key}
|
||||
onClick={() => setSection(item.key)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
{badge ? <span className="nav-badge">{badge}</span> : null}
|
||||
<span className="nav-dot" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="nav-h">账号</div>
|
||||
<button className="logout-pill" type="button" onClick={() => setModal("logout")}>
|
||||
<LogOut />
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* 右侧内容 */}
|
||||
<main>
|
||||
<main className="settings-content">
|
||||
{section === "profile" && (
|
||||
<section className="pane" aria-label="个人信息">
|
||||
<h3>个人信息</h3>
|
||||
<div className="pane-desc">// 头像、姓名、联系方式</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="lbl">头像</div>
|
||||
<div className="val">
|
||||
<div className="avatar-edit">
|
||||
<div className="av-big">{user.avatar_url ? <img src={user.avatar_url} alt="头像" /> : avatarChar}</div>
|
||||
<div className="av-actions">
|
||||
<button className="btn btn-sm" type="button" onClick={openAvatarModal}>上传新头像</button>
|
||||
<button className="btn btn-ghost btn-sm" type="button" onClick={() => onResetAvatar?.()}>恢复默认</button>
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow title="头像" hint="支持上传 JPG、PNG 图片">
|
||||
<div className="avatar-edit">
|
||||
<div className="av-big">{user.avatar_url ? <img src={user.avatar_url} alt="头像" /> : avatarChar}</div>
|
||||
<button className="st-ghost" type="button" onClick={openAvatarModal}>上传新头像</button>
|
||||
<button className="st-text" type="button" onClick={() => onResetAvatar?.()}>恢复默认</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">显示名称<span className="req">*</span></div>
|
||||
<div className="val"><input className="input" value={name} onChange={(event) => patchDraft("name", event.target.value)} /></div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">登录邮箱<div className="lbl-sub">// 仅做记录用</div></div>
|
||||
<div className="val">
|
||||
<input className="input" type="email" value={email} onChange={(event) => patchDraft("email", event.target.value)} />
|
||||
{/* 邮件服务未接入,验证功能暂不可用,入口已隐藏 */}
|
||||
<span className="switch-note">// 邮件验证未启用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">手机号</div>
|
||||
<div className="val">
|
||||
<input className="input" value={phone} onChange={(event) => patchDraft("phone", event.target.value)} placeholder="138****8000" />
|
||||
<span className="switch-note">// 在「保存所有更改」中一并提交</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">所属团队<div className="lbl-sub">// 一人一团队</div></div>
|
||||
<div className="val">
|
||||
<span className="static">{team.name}</span>
|
||||
<span className="role-tag"><span className="dot" />超管 · 创建者</span>
|
||||
</SettingRow>
|
||||
<SettingRow title="显示名称" hint="用于团队和项目协作中展示">
|
||||
<input className="st-input" value={name} onChange={(event) => patchDraft("name", event.target.value)} />
|
||||
</SettingRow>
|
||||
<SettingRow title="登录邮箱" hint="仅做记录用,不用于登录">
|
||||
<input
|
||||
className="st-input"
|
||||
type="email"
|
||||
value={email}
|
||||
placeholder="邮箱验证未启用"
|
||||
onChange={(event) => patchDraft("email", event.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow title="手机号" hint="保存所有更改时一并提交">
|
||||
<input
|
||||
className="st-input"
|
||||
value={phone}
|
||||
onChange={(event) => patchDraft("phone", event.target.value)}
|
||||
placeholder="138****8000"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow title="所属团队" hint="一人一团队">
|
||||
<div className="st-team">
|
||||
<strong>{team.name} · 超管 / 创建者</strong>
|
||||
<a
|
||||
href="/team"
|
||||
className="row-link"
|
||||
className="st-text"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
window.history.pushState(null, "", "/team");
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}}
|
||||
>
|
||||
管理团队 →
|
||||
管理团队
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">用户 ID<div className="lbl-sub">// 不可改</div></div>
|
||||
<div className="val"><span className="static mono">{user.id}</span></div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow title="用户 ID" hint="不可修改">
|
||||
<span className="st-muted">{user.id}</span>
|
||||
</SettingRow>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{section === "security" && (
|
||||
<section className="pane" aria-label="安全">
|
||||
<h3>安全</h3>
|
||||
<div className="pane-desc">// 登录密码、双因素、在用设备</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="lbl">登录密码</div>
|
||||
<div className="val">
|
||||
<span className="static mono">●●●●●●●●●●</span>
|
||||
<span className="row-note" style={{ marginLeft: "auto" }}>上次修改 2026-04-12</span>
|
||||
<button className="btn btn-sm" type="button" style={{ marginLeft: 10 }} onClick={openPasswordModal}>修改</button>
|
||||
<SettingRow title="登录密码" hint="上次修改 2026-04-12">
|
||||
<div className="st-password">
|
||||
<span className="st-dots">••••••••••</span>
|
||||
<button className="st-ghost" type="button" onClick={openPasswordModal}>修改</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">两步验证<div className="lbl-sub">// 推荐开启</div></div>
|
||||
<div className="val">
|
||||
<Switch checked={draft.twoFactor} onChange={(v) => patchDraft("twoFactor", v)} />
|
||||
<span className="switch-note">短信 + Authenticator</span>
|
||||
</SettingRow>
|
||||
<SettingRow title="两步验证" hint="推荐开启短信 + Authenticator">
|
||||
<Switch checked={draft.twoFactor} onChange={(v) => patchDraft("twoFactor", v)} />
|
||||
</SettingRow>
|
||||
<SettingRow stack title="在用设备" hint="真实登录会话 · 每次登录记录设备 UA / IP">
|
||||
<div className="device-list">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="device-row"><div className="meta" style={{ padding: "8px 0" }}>暂无其他登录会话记录</div></div>
|
||||
) : (
|
||||
sessions.map((s) => {
|
||||
const isPhone = /iphone|android|mobile/i.test(s.user_agent);
|
||||
return (
|
||||
<div className="device-row" key={s.id}>
|
||||
<div className="ic">
|
||||
{isPhone ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="12" height="20" rx="2" /><path d="M11 18h2" /></svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="14" rx="2" /><path d="M2 20h20" /></svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="nm">{deviceName(s.user_agent)}{s.is_current ? <span className="tag-cur">CURRENT</span> : null}</div>
|
||||
<div className="meta">{s.ip_address || "未知 IP"} · {new Date(s.last_seen_at || s.created_at).toLocaleString("zh-CN")}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
{s.is_current
|
||||
? <span className="row-note">当前会话</span>
|
||||
: <button className="st-ghost st-ghost-sm" type="button" onClick={() => onRevokeSession?.(s.id)}>下线</button>}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="sub-head">在用设备</h3>
|
||||
<div className="pane-desc">// 真实登录会话 · 每次登录记录设备 UA / IP</div>
|
||||
<div className="device-list">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="device-row"><div className="meta" style={{ padding: "8px 0" }}>// 暂无其他登录会话记录</div></div>
|
||||
) : (
|
||||
sessions.map((s) => {
|
||||
const isPhone = /iphone|android|mobile/i.test(s.user_agent);
|
||||
return (
|
||||
<div className="device-row" key={s.id}>
|
||||
<div className="ic">
|
||||
{isPhone ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="12" height="20" rx="2" /><path d="M11 18h2" /></svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="14" rx="2" /><path d="M2 20h20" /></svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="nm">{deviceName(s.user_agent)}{s.is_current ? <span className="tag-cur">CURRENT</span> : null}</div>
|
||||
<div className="meta">// {s.ip_address || "未知 IP"} · {new Date(s.last_seen_at || s.created_at).toLocaleString("zh-CN")}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
{s.is_current
|
||||
? <span className="row-note">当前会话</span>
|
||||
: <button className="btn btn-ghost btn-sm" type="button" onClick={() => onRevokeSession?.(s.id)}>下线</button>}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<button className="btn" type="button" onClick={() => onRevokeOthers?.()} disabled={sessions.length <= 1}>下线所有其他设备</button>
|
||||
</div>
|
||||
<button className="st-ghost" type="button" style={{ marginTop: 16 }} onClick={() => onRevokeOthers?.()} disabled={sessions.length <= 1}>
|
||||
下线所有其他设备
|
||||
</button>
|
||||
</SettingRow>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{section === "notify" && (
|
||||
<section className="pane" aria-label="通知">
|
||||
<h3>通知</h3>
|
||||
<div className="pane-desc">// 站内通知开关 · 邮件/短信渠道未接入</div>
|
||||
{NOTIFY_ROWS.map((row) => (
|
||||
<div className="form-row" key={row.key}>
|
||||
<div className="lbl">{row.title}{row.sub ? <div className="lbl-sub">{row.sub}</div> : null}</div>
|
||||
<div className="val">
|
||||
<Switch checked={!!draft.notify[row.key]} onChange={(next) => patchDraft("notify", { ...draft.notify, [row.key]: next })} />
|
||||
<span className="switch-note">{row.channels}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow key={row.key} title={row.title} hint={row.sub?.replace(/^\/\/\s*/, "")}>
|
||||
<Switch checked={!!draft.notify[row.key]} onChange={(next) => patchDraft("notify", { ...draft.notify, [row.key]: next })} />
|
||||
</SettingRow>
|
||||
))}
|
||||
<div className="pane-desc" style={{ margin: "18px 0 4px" }}>消息中心分类</div>
|
||||
{MUTE_TYPE_ROWS.map((row) => (
|
||||
<SettingRow key={row.key} title={row.title} hint={row.hint}>
|
||||
<Switch
|
||||
checked={!draft.notify[`mute-${row.key}`]}
|
||||
onChange={(show) => patchDraft("notify", { ...draft.notify, [`mute-${row.key}`]: !show })}
|
||||
/>
|
||||
</SettingRow>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
@@ -563,7 +551,7 @@ export function SettingsPage({
|
||||
{section === "pref" && (
|
||||
<section className="pane" aria-label="创作默认">
|
||||
<h3>创作默认</h3>
|
||||
<div className="pane-desc">// 新建项目时的预填值,可在向导中改</div>
|
||||
<div className="pane-desc">新建项目时的预填值,可在向导中改</div>
|
||||
|
||||
<div className="form-row row-top">
|
||||
<div className="lbl">默认模板</div>
|
||||
@@ -600,7 +588,7 @@ export function SettingsPage({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="switch-note" style={{ marginLeft: 10 }}>// 60s = 4 段 × 15s</span>
|
||||
<span className="switch-note" style={{ marginLeft: 10 }}>60s = 4 段 × 15s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row row-top">
|
||||
@@ -645,7 +633,7 @@ export function SettingsPage({
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">导出水印<div className="lbl-sub">// VIP 可关闭</div></div>
|
||||
<div className="lbl">导出水印<div className="lbl-sub">VIP 可关闭</div></div>
|
||||
<div className="val">
|
||||
<Switch checked disabled />
|
||||
<span className="switch-note">右下角 · Airshelf</span>
|
||||
@@ -658,7 +646,7 @@ export function SettingsPage({
|
||||
{section === "display" && (
|
||||
<section className="pane" aria-label="显示">
|
||||
<h3>显示</h3>
|
||||
<div className="pane-desc">// 界面外观与语言</div>
|
||||
<div className="pane-desc">界面外观与语言</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="lbl">外观</div>
|
||||
@@ -692,7 +680,6 @@ export function SettingsPage({
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="settings-foot">// Airshelf · v2.1 · build 20260521</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -700,7 +687,7 @@ export function SettingsPage({
|
||||
<TeamModal
|
||||
open={modal === "avatar"}
|
||||
title="上传头像"
|
||||
subtitle="// 用于个人主页、评论与团队展示"
|
||||
subtitle="用于个人主页、评论与团队展示"
|
||||
icon={<Upload size={16} />}
|
||||
close={() => setModal("")}
|
||||
footer={
|
||||
@@ -716,7 +703,7 @@ export function SettingsPage({
|
||||
</div>
|
||||
<div className="av-up-preview-meta">
|
||||
<div className="t">{avatarFile ? avatarFile.name : "当前头像 · 默认"}</div>
|
||||
<div className="d">{avatarFile ? `// ${(avatarFile.size / 1024).toFixed(0)} KB · 已选择` : "// 系统生成 · 取姓氏首字"}</div>
|
||||
<div className="d">{avatarFile ? `${(avatarFile.size / 1024).toFixed(0)} KB · 已选择` : "系统生成 · 取姓氏首字"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -757,7 +744,7 @@ export function SettingsPage({
|
||||
<TeamModal
|
||||
open={modal === "password"}
|
||||
title="修改登录密码"
|
||||
subtitle="// CHANGE PASSWORD"
|
||||
|
||||
icon={<KeyRound size={16} />}
|
||||
close={() => setModal("")}
|
||||
footer={
|
||||
@@ -793,7 +780,7 @@ export function SettingsPage({
|
||||
/>
|
||||
{pwTooShort || (pwSubmitted && newPassword.length < 8)
|
||||
? <span className="field-hint pw-err">新密码至少 8 位</span>
|
||||
: <span className="field-hint">// 建议混合字母、数字与符号</span>}
|
||||
: <span className="field-hint">建议混合字母、数字与符号</span>}
|
||||
</div>
|
||||
</TeamModal>
|
||||
|
||||
@@ -801,7 +788,7 @@ export function SettingsPage({
|
||||
<TeamModal
|
||||
open={modal === "logout"}
|
||||
title="退出当前账号"
|
||||
subtitle="// LOG OUT CURRENT SESSION"
|
||||
|
||||
icon={<LogOut size={16} />}
|
||||
close={() => setModal("")}
|
||||
footer={
|
||||
|
||||
+270
-189
@@ -1,14 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle2, CircleDollarSign, Copy, KeyRound, RefreshCw, UserPlus } from "lucide-react";
|
||||
import { CheckCircle2, CircleDollarSign, Copy, Gauge, KeyRound, RefreshCw, Search, UserPlus } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import type { BillingSummary, Invitation, Notification, Team, TeamMember, User } from "../types";
|
||||
import type { BillingSummary, BillingTrend, Invitation, Notification, Team, TeamMember, User } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { money } from "./stage-config";
|
||||
import { ConfirmModal, TeamModal } from "../components/overlays";
|
||||
import { Pager } from "../components/pager";
|
||||
|
||||
const MEMBERS_PER_PAGE = 10;
|
||||
type TrendRange = "day" | "week" | "month";
|
||||
const TREND_CHIPS: Array<{ key: TrendRange; label: string }> = [
|
||||
{ key: "day", label: "日" },
|
||||
{ key: "week", label: "周" },
|
||||
{ key: "month", label: "月" },
|
||||
];
|
||||
const STAGE_SPEND: Array<{ key: keyof BillingTrend["by_stage"]; label: string }> = [
|
||||
{ key: "video", label: "视频生成" },
|
||||
{ key: "storyboard", label: "故事板" },
|
||||
{ key: "base", label: "基础资产" },
|
||||
{ key: "script", label: "脚本生成" },
|
||||
];
|
||||
// 登录地址必须是用户当前真实访问的域名(与邀请链接同源),写死的 airshelf.com 不存在 → PMC#16
|
||||
function shortTs(iso?: string) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const LOGIN_URL = `${location.origin}/login`;
|
||||
const LIMIT_PRESETS = [1000, 3000, 5000, 10000, -1];
|
||||
|
||||
@@ -67,15 +87,15 @@ const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: bo
|
||||
{ cap: "团队充值", cells: ["✓", "—", "—"] },
|
||||
{ cap: "设置月限额", cells: ["✓", "—", "—"] },
|
||||
{ cap: "编辑别人项目", cells: ["✓", "✓", "—"] },
|
||||
{ cap: "团队共享资产库管理", cells: ["✓", "✓", "仅自传"] },
|
||||
{ cap: "团队共享成品库管理", cells: ["✓", "✓", "仅自传"] },
|
||||
{ cap: "查看团队消费明细", cells: ["✓", "✓", "仅自己"] },
|
||||
{ cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true }
|
||||
];
|
||||
|
||||
// 角色双卡(创建/编辑共用):成员 / 团管
|
||||
const ROLE_CARDS: Array<{ value: string; title: string; desc: string }> = [
|
||||
{ value: "member", title: "成员", desc: "// 创建项目 + 用资产" },
|
||||
{ value: "admin", title: "团管", desc: "// 管成员 + 改额度" }
|
||||
{ value: "member", title: "成员", desc: "创建项目 + 用资产" },
|
||||
{ value: "admin", title: "团管", desc: "管成员 + 改额度" }
|
||||
];
|
||||
|
||||
export function TeamPage({ team, user, billing, navigate, onCreateMember: onCreateMemberRaw, onUpdateMember: onUpdateMemberRaw, onRemoveMember: onRemoveMemberRaw, onResetPassword, onRecharge: onRechargeRaw }: {
|
||||
@@ -119,6 +139,14 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
void api.billingConfig().then((cfg) => setPointsRate(Number(cfg.points_per_yuan) || 10)).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const [trendRange, setTrendRange] = useState<TrendRange>("day");
|
||||
const [trend, setTrend] = useState<BillingTrend | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.billingTrend(trendRange).then((d) => { if (alive) setTrend(d); }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, [trendRange]);
|
||||
|
||||
// 创建账户表单
|
||||
const [cuUser, setCuUser] = useState("");
|
||||
const [cuPass, setCuPass] = useState("");
|
||||
@@ -221,6 +249,13 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
const memberCurPage = Math.min(memberPage, memberTotalPages);
|
||||
const pagedMembers = list.slice((memberCurPage - 1) * MEMBERS_PER_PAGE, memberCurPage * MEMBERS_PER_PAGE);
|
||||
|
||||
const daily = trend?.daily ?? [];
|
||||
const peak = Number(trend?.peak || 0);
|
||||
const stageSpend = STAGE_SPEND.map((s) => ({ ...s, amount: Number(trend?.by_stage[s.key] || 0) }));
|
||||
const stageSum = stageSpend.reduce((s, x) => s + x.amount, 0);
|
||||
const topStage = stageSpend.reduce((a, b) => (b.amount > a.amount ? b : a), stageSpend[0]);
|
||||
const topPct = stageSum > 0 ? Math.round((topStage.amount / stageSum) * 100) : 0;
|
||||
|
||||
function openCreate() {
|
||||
// 打开时预填随机用户名 + 随机密码,用户可改
|
||||
setCuUser((v) => v || genUsername());
|
||||
@@ -312,199 +347,245 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<section className="team-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>团队管理</h1>
|
||||
<div className="sub"><span className="mono">// 成员 · 角色 · 额度 · 共享资产库</span></div>
|
||||
<h1>团队中心</h1>
|
||||
<div className="sub">在一个页面管理成员协作与额度消费</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary" type="button" id="open-invite" onClick={openCreate}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M19 8v6M22 11h-6" /></svg>
|
||||
创建账户
|
||||
<button className="tc-ghost" type="button" id="open-gen-invite" onClick={() => { setInviteResult(null); setInviteErr(""); setModal("gen-invite"); }}>
|
||||
<UserPlus size={18} />邀请成员
|
||||
</button>
|
||||
<button className="tc-ghost" type="button" id="open-invite" onClick={openCreate}>
|
||||
<UserPlus size={18} />创建账户
|
||||
</button>
|
||||
<button className="tc-primary" type="button" onClick={() => setModal("recharge")}>
|
||||
<CircleDollarSign size={18} />充值
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 顶部行:团队 banner(左)+ 团队动态(右) */}
|
||||
<div className="team-top">
|
||||
|
||||
<div className="team-banner">
|
||||
<span className="corner-tr" aria-hidden></span><span className="corner-bl" aria-hidden></span>
|
||||
|
||||
<div className="banner-head">
|
||||
<div className="banner-id">
|
||||
<div className="lbl">[ TEAM ]</div>
|
||||
<div className="nm">{team.name} <span className="tag">企业</span></div>
|
||||
<div className="meta">// 团队 ID: {team.id} · {rows.length} 名成员</div>
|
||||
</div>
|
||||
<div className="banner-actions">
|
||||
<button className="btn btn-sm" type="button" onClick={() => setModal("recharge")}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4" /></svg>
|
||||
充值
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" type="button" id="open-gen-invite" onClick={() => { setInviteResult(null); setInviteErr(""); setModal("gen-invite"); }}>
|
||||
<UserPlus size={13} /> 邀请成员
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" type="button" id="open-limit" onClick={openLimit}>设置月限额</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="banner-divider"></div>
|
||||
|
||||
<div className="banner-stats">
|
||||
<div className="stat">
|
||||
<div className="lbl">[ 充值余额 ]</div>
|
||||
<div className="v">{money(balance)}</div>
|
||||
<div className="sub">// 团队总池</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="lbl">[ 月限额 ]</div>
|
||||
<div className="v" id="stat-limit">{money(limit)}</div>
|
||||
<div className="sub">// 自然月重置</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="lbl">[ 当月已用 ]</div>
|
||||
<div className="v" id="stat-used">{money(used)}</div>
|
||||
<div className="sub" id="stat-used-sub">// 占月限 {pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="lbl">[ 当月剩余 ]</div>
|
||||
<div className="v warn" id="stat-left">{money(left)}</div>
|
||||
<div className="sub" id="stat-left-sub">// 还可生成约 {Math.max(0, Math.round(left / 10))} 个项目</div>
|
||||
</div>
|
||||
<div className="tc-hero">
|
||||
<div className="tc-identity">
|
||||
<div>
|
||||
<span className="tc-team-tag">TEAM</span>
|
||||
<h2>{team.name}</h2>
|
||||
<p>企业团队 · {rows.length} 名成员</p>
|
||||
</div>
|
||||
<p>团队 ID:{team.id}</p>
|
||||
</div>
|
||||
|
||||
{/* 团队动态(banner 右栏)· 接真实 ops/notifications 事件流 */}
|
||||
<div className="team-feed">
|
||||
<div className="h">
|
||||
<h3>团队动态</h3>
|
||||
<span className="ct">// 最近 {Math.min(feedItems.length, 6)} 条 · 共 {notificationTotal ?? notifications.length}</span>
|
||||
<a className="more" id="open-feed-all" role="button" tabIndex={0} onClick={() => navigate("messages")}>全部 →</a>
|
||||
</div>
|
||||
<div className="feed-list">
|
||||
{feedItems.length === 0 ? (
|
||||
<div className="feed-item">
|
||||
<div className="av">{(team.name || "T").slice(0, 1).toUpperCase()}</div>
|
||||
<div>
|
||||
<div className="txt"><span className="who">{team.name}</span><span className="act">已同步</span><span className="obj">{rows.length} 名成员</span></div>
|
||||
<div className="ts">// 暂无团队动态</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
feedItems.map((n) => (
|
||||
<div className="feed-item" key={n.id}>
|
||||
<div className="av">{(n.owner_label || n.source || team.name || "·").slice(0, 1).toUpperCase()}</div>
|
||||
<div>
|
||||
<div className="txt">
|
||||
<span className="who">{n.owner_label || n.source || "系统"}</span>
|
||||
<span className="act">{n.title}</span>
|
||||
{n.project_name && <span className="obj">{n.project_name}</span>}
|
||||
</div>
|
||||
<div className="ts">{n.created_at ? new Date(n.created_at).toLocaleString("zh-CN") : n.brief}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="tc-metric">
|
||||
<span>团队余额</span>
|
||||
<strong>{money(balance)}</strong>
|
||||
<small>团队总池</small>
|
||||
</div>
|
||||
|
||||
</div>{/* /.team-top */}
|
||||
|
||||
<div className="team-grid">
|
||||
{/* 左:成员表 */}
|
||||
<div>
|
||||
<div className="pane" style={{ padding: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", padding: "16px 20px", borderBottom: "1px solid var(--border-faint)" }}>
|
||||
<h3 style={{ margin: 0 }}>成员列表 <span className="ct">// {list.length} / {rows.length} 人 · 真实团队表</span></h3>
|
||||
<span className="spacer"></span>
|
||||
<input className="input" id="member-search" placeholder="搜索姓名 / 手机号" style={{ height: "32px", fontSize: "12px", width: "220px" }} value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||
</div>
|
||||
<div className="members-table-wrap">
|
||||
<table className="t members-table" style={{ border: 0, borderRadius: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>成员</th>
|
||||
<th>角色</th>
|
||||
<th>每日额度</th>
|
||||
<th>月度额度</th>
|
||||
<th>总额度</th>
|
||||
<th style={{ width: "140px" }}>当月已用</th>
|
||||
<th style={{ textAlign: "right", width: "88px" }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="members-tbody">
|
||||
{pagedMembers.map((member) => {
|
||||
const rawName = (member.user.username || "").trim();
|
||||
const email = (member.user.email || "").trim();
|
||||
// 用户名是邮箱时取 @ 前作为显示名,完整邮箱作副行,避免名字与邮箱重复显示
|
||||
const displayName = rawName && !rawName.includes("@") ? rawName : (email ? email.split("@")[0] : (rawName || "成员"));
|
||||
const showEmail = !!email && email.toLowerCase() !== displayName.toLowerCase();
|
||||
const role = roleUi(member.role);
|
||||
const daily = Number(member.daily_credit_limit || 0);
|
||||
const totalLimit = Number(member.total_credit_limit || 0);
|
||||
const dayUsed = Number(member.day_charged || 0);
|
||||
const totalUsed = Number(member.total_charged || 0);
|
||||
const monthly = Number(member.monthly_credit_limit || 0);
|
||||
const memberUsed = Number(member.month_charged || 0);
|
||||
// 月度不限时按团队月限额/余额作分母给参考进度;有消费即显可见细条,避免「用了钱进度条却空白」
|
||||
const quotaDenom = monthly > 0 ? monthly : limit;
|
||||
const memberPct = quotaDenom > 0 ? Math.min(100, (memberUsed / quotaDenom) * 100) : 0;
|
||||
const barWidth = memberUsed > 0 ? Math.max(memberPct, 3) : 0;
|
||||
const barClass = memberPct >= 80 ? "warn" : "ok";
|
||||
const isOwner = member.role === "owner";
|
||||
return (
|
||||
<tr key={member.id} data-id={member.id}>
|
||||
<td><span className="member-cell"><span className="av">{displayName.slice(0, 1).toUpperCase()}</span><span className="member-meta"><span className="nm">{displayName}</span>{showEmail && <span className="em">{email}</span>}</span></span></td>
|
||||
<td><span className={`role-pill role-${role.key}`}><span className="dot"></span>{role.label}</span></td>
|
||||
<td><span className="quota-cell"><span className="v">{daily > 0 ? money(daily) : "不限"}</span>{daily > 0 && <span className="lbl">今日用 {money(dayUsed)}</span>}</span></td>
|
||||
<td><span className="quota-cell"><span className="v">{monthly > 0 ? money(monthly) : "不限"}</span></span></td>
|
||||
<td><span className="quota-cell"><span className="v">{totalLimit > 0 ? money(totalLimit) : "不限"}</span>{totalLimit > 0 && <span className="lbl">累计用 {money(totalUsed)}</span>}</span></td>
|
||||
<td><div className="quota-cell"><span className="v">{money(memberUsed)}</span> <span className="lbl">/ {monthly > 0 ? `${memberPct.toFixed(0)}%` : "不限"}</span></div><div className="used-bar"><span className={barClass} style={{ width: `${barWidth}%` }}></span></div></td>
|
||||
<td><div className="acts">{isOwner
|
||||
? <span style={{ fontFamily: "var(--font-mono)", fontSize: "12px", color: "var(--black-alpha-32)", alignSelf: "center" }}>不可编辑</span>
|
||||
: <>
|
||||
<button className="icon-btn-sm" type="button" title="编辑" onClick={() => openEdit(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" /></svg></button>
|
||||
<button className="icon-btn-sm" type="button" title="重置密码" onClick={() => { setResetTarget(member); setResetPwd(genPassword()); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></button>
|
||||
<button className="icon-btn-sm danger" type="button" title="移出" onClick={() => setRemoveTarget(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
|
||||
</>}</div></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pager page={memberCurPage} total={list.length} pageSize={MEMBERS_PER_PAGE} onChange={setMemberPage} />
|
||||
</div>
|
||||
<div className="tc-metric">
|
||||
<span>月限额</span>
|
||||
<strong id="stat-limit">{money(limit)}</strong>
|
||||
<small>自然月重置</small>
|
||||
</div>
|
||||
|
||||
{/* 右:权限矩阵 */}
|
||||
<div>
|
||||
<div className="pane">
|
||||
<h3>角色权限</h3>
|
||||
<div style={{ fontSize: "12px", color: "var(--black-alpha-48)", marginTop: "-10px", marginBottom: "12px", fontFamily: "var(--font-mono)", letterSpacing: ".02em" }}>// PRD §10.2 权限矩阵节选</div>
|
||||
<table className="perm-table">
|
||||
<thead>
|
||||
<tr><th>能力</th><th>超管</th><th>团管</th><th>成员</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PERM_ROWS.map((row) => (
|
||||
<tr key={row.cap} style={row.last ? { borderBottom: 0 } : undefined}>
|
||||
<td>{row.cap}</td>
|
||||
{row.cells.map((cell, index) => (
|
||||
<td key={index} className={cell === "✓" ? "yes" : "no"}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="tc-metric">
|
||||
<span>当月已用</span>
|
||||
<strong id="stat-used">{money(used)}</strong>
|
||||
<small>使用率 {pct.toFixed(1)}%</small>
|
||||
</div>
|
||||
<div className="tc-metric">
|
||||
<span>当月剩余</span>
|
||||
<strong id="stat-left">{money(left)}</strong>
|
||||
<small>还可生成约 {Math.max(0, Math.round(left / 10))} 个项目</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="tc-section" aria-labelledby="teamCollaborationTitle">
|
||||
<div className="tc-section-title">
|
||||
<div>
|
||||
<h2 id="teamCollaborationTitle">成员与团队动态</h2>
|
||||
<p>成员权限和最近协作记录</p>
|
||||
</div>
|
||||
<button className="tc-compact tc-compact-klein" type="button" id="viewAllMessages" onClick={() => navigate("messages")}>查看全部消息</button>
|
||||
</div>
|
||||
<div className="tc-grid">
|
||||
<div className="tc-panel">
|
||||
<div className="tc-panel-head">
|
||||
<h2>成员列表</h2>
|
||||
<label className="tc-search">
|
||||
<Search size={16} />
|
||||
<input id="member-search" placeholder="搜索姓名 / 手机号" value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="tc-row tc-row-head">
|
||||
<span>成员</span>
|
||||
<span>角色</span>
|
||||
<span>每日额度</span>
|
||||
<span>月额度</span>
|
||||
<span>总额度</span>
|
||||
<span>当月已用</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{pagedMembers.map((member) => {
|
||||
const rawName = (member.user.username || "").trim();
|
||||
const email = (member.user.email || "").trim();
|
||||
const displayName = rawName && !rawName.includes("@") ? rawName : (email ? email.split("@")[0] : (rawName || "成员"));
|
||||
const showEmail = !!email && email.toLowerCase() !== displayName.toLowerCase();
|
||||
const role = roleUi(member.role);
|
||||
const dailyLimit = Number(member.daily_credit_limit || 0);
|
||||
const totalLimit = Number(member.total_credit_limit || 0);
|
||||
const dayUsed = Number(member.day_charged || 0);
|
||||
const totalUsed = Number(member.total_charged || 0);
|
||||
const monthly = Number(member.monthly_credit_limit || 0);
|
||||
const memberUsed = Number(member.month_charged || 0);
|
||||
const quotaDenom = monthly > 0 ? monthly : limit;
|
||||
const memberPct = quotaDenom > 0 ? Math.min(100, (memberUsed / quotaDenom) * 100) : 0;
|
||||
const barWidth = memberUsed > 0 ? Math.max(memberPct, 3) : 0;
|
||||
const barClass = memberPct >= 80 ? "warn" : "ok";
|
||||
const isOwner = member.role === "owner";
|
||||
return (
|
||||
<div className="tc-row" key={member.id} data-id={member.id}>
|
||||
<div className="tc-person">
|
||||
<div className="tc-avatar">{displayName.slice(0, 1).toUpperCase()}</div>
|
||||
<div>
|
||||
<strong>{displayName}</strong>
|
||||
<span>{showEmail ? email : team.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`tc-tag tc-tag-${role.key}`}>{role.label}</span>
|
||||
<span className="tc-quota">{dailyLimit > 0 ? money(dailyLimit) : "不限"}{dailyLimit > 0 ? ` · 今日 ${money(dayUsed)}` : ""}</span>
|
||||
<span>{monthly > 0 ? money(monthly) : "不限"}</span>
|
||||
<span className="tc-quota">{totalLimit > 0 ? money(totalLimit) : "不限"}{totalLimit > 0 ? ` · 累计 ${money(totalUsed)}` : ""}</span>
|
||||
<div className="tc-used">
|
||||
<strong>{money(memberUsed)}</strong>
|
||||
<div className="used-bar"><span className={barClass} style={{ width: `${barWidth}%` }} /></div>
|
||||
</div>
|
||||
<div className="tc-acts">{isOwner
|
||||
? <span className="tc-muted">不可编辑</span>
|
||||
: <>
|
||||
<button className="tc-icon" type="button" title="编辑" onClick={() => openEdit(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" /></svg></button>
|
||||
<button className="tc-icon" type="button" title="重置密码" onClick={() => { setResetTarget(member); setResetPwd(genPassword()); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></button>
|
||||
<button className="tc-icon danger" type="button" title="移出" onClick={() => setRemoveTarget(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
|
||||
</>}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Pager page={memberCurPage} total={list.length} pageSize={MEMBERS_PER_PAGE} onChange={setMemberPage} />
|
||||
</div>
|
||||
|
||||
<div className="tc-panel">
|
||||
<div className="tc-panel-head">
|
||||
<h2>最近动态</h2>
|
||||
<span className="tc-tag">{notificationTotal || feedItems.length} 条</span>
|
||||
</div>
|
||||
<div className="tc-activity">
|
||||
{feedItems.length === 0 ? (
|
||||
<div className="tc-activity-item">
|
||||
<span className="tc-dot" />
|
||||
<div>
|
||||
<p><strong>{team.name}</strong> 暂无团队动态</p>
|
||||
<span>已同步 {rows.length} 名成员</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
feedItems.map((n) => (
|
||||
<div className="tc-activity-item" key={n.id}>
|
||||
<span className="tc-dot" />
|
||||
<div>
|
||||
<p><strong>{n.owner_label || n.source || "系统"}</strong> {n.title}</p>
|
||||
<span>{shortTs(n.created_at)}{n.cost_label ? ` · ${n.cost_label}` : ""}{n.project_name ? ` · ${n.project_name}` : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="tc-section" aria-labelledby="teamConsumptionTitle">
|
||||
<div className="tc-section-title">
|
||||
<div>
|
||||
<h2 id="teamConsumptionTitle">消费概览</h2>
|
||||
<p>{trendRange === "week" ? "近 8 周" : trendRange === "month" ? "近 6 个月" : "近 14 天"} · 单位:积分</p>
|
||||
</div>
|
||||
<button className="tc-compact" type="button" id="open-limit" onClick={openLimit}>
|
||||
<Gauge size={16} />设置月限额
|
||||
</button>
|
||||
</div>
|
||||
<div className="tc-panel">
|
||||
<div className="tc-panel-head">
|
||||
<h2>消费趋势</h2>
|
||||
<div className="tc-segmented">
|
||||
{TREND_CHIPS.map((chip) => (
|
||||
<button
|
||||
key={chip.key}
|
||||
type="button"
|
||||
className={`tc-seg${trendRange === chip.key ? " active" : ""}`}
|
||||
onClick={() => setTrendRange(chip.key)}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tc-summary">
|
||||
<div>
|
||||
<span>本月已用</span>
|
||||
<strong id="stat-used-sub">{money(used)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>本月剩余</span>
|
||||
<strong id="stat-left-sub">{money(left)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>主要消耗</span>
|
||||
<strong>{stageSum > 0 ? `${topStage.label} ${topPct}%` : "暂无消耗"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tc-chart">
|
||||
{daily.length === 0 ? (
|
||||
<div className="tc-chart-empty">暂无消费记录</div>
|
||||
) : daily.map((d) => {
|
||||
const amt = Number(d.amount);
|
||||
const h = peak > 0 ? Math.max(amt > 0 ? 8 : 4, (amt / peak) * 100) : 8;
|
||||
return (
|
||||
<div className="tc-bar-group" key={d.date} title={`${d.label} · ${money(amt)}`}>
|
||||
<strong className="tc-bar-value">{amt > 0 ? Math.round(amt) : ""}</strong>
|
||||
<div className="tc-bar" style={{ height: `${h}%` }} />
|
||||
<span>{d.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="tc-section" aria-labelledby="teamPermTitle">
|
||||
<div className="tc-section-title">
|
||||
<div>
|
||||
<h2 id="teamPermTitle">角色权限</h2>
|
||||
<p>超管 / 团管 / 成员能力对照</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tc-panel tc-perm-panel">
|
||||
<table className="perm-table">
|
||||
<thead>
|
||||
<tr><th>能力</th><th>超管</th><th>团管</th><th>成员</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PERM_ROWS.map((row) => (
|
||||
<tr key={row.cap}>
|
||||
<td>{row.cap}</td>
|
||||
{row.cells.map((cell, index) => (
|
||||
<td key={index} className={cell === "✓" ? "yes" : "no"}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 设置月限额(团队级)· 预设 pill + 实时剩余 */}
|
||||
<TeamModal
|
||||
open={modal === "gen-invite"}
|
||||
title="邀请成员"
|
||||
subtitle="// 生成邀请码 · 新成员凭码注册即加入本团队"
|
||||
subtitle="生成邀请码 · 新成员凭码注册即加入本团队"
|
||||
icon={<UserPlus size={16} />}
|
||||
close={() => setModal("")}
|
||||
dismissable={false}
|
||||
@@ -546,7 +627,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<div><div className="ck">邀请链接</div><div className="cv">{`${location.origin}${inviteResult.register_url}`}</div></div>
|
||||
<button className="cred-copy" type="button" onClick={() => navigator.clipboard?.writeText(`${location.origin}${inviteResult.register_url}`)}><Copy size={13} /> 复制</button>
|
||||
</div>
|
||||
<div className="cred-rules">// 角色 {invRole === "admin" ? "团队管理员" : "成员"} · 7 天有效 · 凭码注册即加入,不重复发额度</div>
|
||||
<div className="cred-rules">角色 {invRole === "admin" ? "团队管理员" : "成员"} · 7 天有效 · 凭码注册即加入,不重复发额度</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -555,7 +636,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<TeamModal
|
||||
open={modal === "limit"}
|
||||
title="设置月限额"
|
||||
subtitle="// 自然月重置 · 仅超管可改"
|
||||
subtitle="自然月重置 · 仅超管可改"
|
||||
icon={<CircleDollarSign size={16} />}
|
||||
close={() => setModal("")}
|
||||
dismissable={false}
|
||||
@@ -587,7 +668,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<TeamModal
|
||||
open={modal === "invite"}
|
||||
title="创建账户"
|
||||
subtitle="// 直接生成账号 · 分享给成员登录"
|
||||
subtitle="直接生成账号 · 分享给成员登录"
|
||||
icon={<UserPlus size={16} />}
|
||||
close={() => setModal("")}
|
||||
footer={<button className="btn btn-primary" type="button" onClick={submitCreate}>创建账户</button>}
|
||||
@@ -647,7 +728,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<TeamModal
|
||||
open={!!share}
|
||||
title={share?.mode === "reset" ? "密码已重置" : "账户已创建"}
|
||||
subtitle={share?.mode === "reset" ? `// 把新凭据分享给 ${share?.name} · 旧密码已失效` : "// 把下方凭据分享给成员 · 凭据仅展示一次"}
|
||||
subtitle={share?.mode === "reset" ? `把新凭据分享给 ${share?.name} · 旧密码已失效` : "把下方凭据分享给成员 · 凭据仅展示一次"}
|
||||
icon={<CheckCircle2 size={16} />}
|
||||
close={() => setShare(null)}
|
||||
footer={<button className="btn btn-primary" type="button" onClick={() => setShare(null)}>完成</button>}
|
||||
@@ -686,20 +767,20 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<TeamModal
|
||||
open={modal === "recharge"}
|
||||
title="团队充值"
|
||||
subtitle="// 充值后立即到账 · 仅超管可操作"
|
||||
subtitle="充值后立即到账 · 仅超管可操作"
|
||||
icon={<CircleDollarSign size={16} />}
|
||||
close={() => setModal("")}
|
||||
dismissable={false}
|
||||
footer={<button className="btn btn-primary" type="button" onClick={submitRecharge}>确认充值</button>}
|
||||
>
|
||||
<div className="field"><label className="field-label">充值金额 ¥</label><input className="input num-input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /><div className="field-hint">// 到账积分 = 支付金额 × {pointsRate}(当前汇率)</div></div>
|
||||
<div className="field"><label className="field-label">充值金额 ¥</label><input className="input num-input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /><div className="field-hint">到账积分 = 支付金额 × {pointsRate}(当前汇率)</div></div>
|
||||
</TeamModal>
|
||||
|
||||
{/* 编辑成员 · 角色双卡 + 三档额度 */}
|
||||
<TeamModal
|
||||
open={!!editTarget}
|
||||
title="编辑成员"
|
||||
subtitle={editTarget ? `// ${editTarget.user.username || editTarget.user.email}` : ""}
|
||||
subtitle={editTarget ? `${editTarget.user.username || editTarget.user.email}` : ""}
|
||||
icon={<UserPlus size={16} />}
|
||||
close={() => setEditTarget(null)}
|
||||
footer={<button className="btn btn-primary" type="button" onClick={submitEdit}>保存</button>}
|
||||
@@ -740,7 +821,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<TeamModal
|
||||
open={!!resetTarget}
|
||||
title="重置登录密码"
|
||||
subtitle="// 该成员当前会话会被强制下线"
|
||||
subtitle="该成员当前会话会被强制下线"
|
||||
icon={<KeyRound size={16} />}
|
||||
close={() => setResetTarget(null)}
|
||||
footer={<button className="btn btn-primary" type="button" onClick={submitReset}>确认重置</button>}
|
||||
|
||||
@@ -313,7 +313,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
|
||||
<div>
|
||||
<h1>垃圾桶</h1>
|
||||
<div className="sub">
|
||||
<span className="mono">// 已删除内容</span>
|
||||
<span className="mono">已删除内容</span>
|
||||
<span>·</span>
|
||||
<span>可恢复,或彻底删除(不可恢复)</span>
|
||||
</div>
|
||||
@@ -329,13 +329,13 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
|
||||
{loading ? (
|
||||
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="trash" />
|
||||
) : isEmpty ? (
|
||||
<div className="placeholder trash-empty"><span className="ph-frame">// 垃圾桶是空的</span></div>
|
||||
<div className="placeholder trash-empty"><span className="ph-frame">垃圾桶是空的</span></div>
|
||||
) : (
|
||||
<>
|
||||
{errText && <div className="trash-err mono">// {errText}</div>}
|
||||
{errText && <div className="trash-err mono">{errText}</div>}
|
||||
{sections.map((section) => (
|
||||
<div className="trash-section" key={section.key}>
|
||||
<div className="trash-section-title mono">// {section.title} · {section.rows.length}</div>
|
||||
<div className="trash-section-title mono">{section.title} · {section.rows.length}</div>
|
||||
<div className="trash-list">
|
||||
{section.rows.map((row) => {
|
||||
const key = rowKey(row);
|
||||
@@ -347,7 +347,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
|
||||
</div>
|
||||
<div className="trash-meta">
|
||||
<div className="trash-name" title={row.title}>{row.title}</div>
|
||||
<div className="trash-sub mono">// {row.subtitle}</div>
|
||||
<div className="trash-sub mono">{row.subtitle}</div>
|
||||
</div>
|
||||
<div className="trash-actions">
|
||||
{confirmId === key ? (
|
||||
@@ -375,7 +375,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
|
||||
<ConfirmModal
|
||||
open={purgeAllOpen}
|
||||
title="清空垃圾桶"
|
||||
subtitle="// PURGE ALL"
|
||||
|
||||
detail="清空垃圾桶数据无法恢复,是否需要清空?"
|
||||
confirmText="清空"
|
||||
onCancel={() => setPurgeAllOpen(false)}
|
||||
|
||||
Reference in New Issue
Block a user