后端生成闸+多项修复;前端全站更新;QA 审计与报告
后端: - 新增 celery_health 生成前置闸——无 worker 在线时图片/视频生成入口 一律 503,防"提交到 ARK 后无人轮询、结果悬空+额度冻结"的数据丢失 - 拼接导出:帧率跟随源众数、字幕逐句重映射、原声/BGM 混音修复 - 资金核算审计脚本 + genesis 账目回填 + 滞留预留清理命令 - 接入 yunqi provider 与豆包 TTS 模型(catalog/migrations/bootstrap 命令) 前端:全站页面更新(pipeline/library/products/projects/team/account 等), 新增共享 pager 分页组件 QA:刷新 function-audit 全量输出,新增 full-qa 报告 文档:BP 产品介绍资料、design/CLAUDE.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
|
||||
import { money } from "./stage-config";
|
||||
import { pageWindow } from "../components/pager";
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" };
|
||||
const STATUS_LABEL: Record<string, string> = { active: "活跃", invited: "待激活", disabled: "已停用" };
|
||||
@@ -30,18 +31,6 @@ const ledgerTypeLabel = (t: string) => LEDGER_TYPE_LABEL[t] ?? t;
|
||||
const ledgerReasonLabel = (r: string) => LEDGER_REASON_LABEL[r] ?? r;
|
||||
|
||||
// 分页页码窗口:页数多时折叠成 1 … 当前±1 … 末页(≤7 页则全展开)
|
||||
function pageWindow(current: number, total: number): Array<number | "ellipsis"> {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const items: Array<number | "ellipsis"> = [1];
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(total - 1, current + 1);
|
||||
if (start > 2) items.push("ellipsis");
|
||||
for (let p = start; p <= end; p++) items.push(p);
|
||||
if (end < total - 1) items.push("ellipsis");
|
||||
items.push(total);
|
||||
return items;
|
||||
}
|
||||
|
||||
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [
|
||||
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
|
||||
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, bonusAmt: 30, ribbon: "推荐" },
|
||||
@@ -282,6 +271,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
|
||||
</div>
|
||||
|
||||
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
|
||||
<div className="billing-table-wrap">
|
||||
<table className="billing-table">
|
||||
<thead><tr><th>时间</th><th>项目 / 类型</th><th>详情</th><th>成员</th><th>状态</th><th style={{ textAlign: "right" }}>金额</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -299,6 +289,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{ledgerCount > BILLS_PER_PAGE && (
|
||||
<div className="bill-pager">
|
||||
<span className="total">// 共 {ledgerCount} 条 · 第 {safeBillPage} / {billTotalPages} 页</span>
|
||||
@@ -316,6 +307,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
|
||||
</div>
|
||||
|
||||
<div className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
|
||||
<div className="billing-table-wrap">
|
||||
<table className="billing-table">
|
||||
<thead><tr><th>项目</th><th>当前阶段</th><th>状态</th><th style={{ textAlign: "right" }}>消耗</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -327,9 +319,11 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`tab-panel ${tab === "by-member" ? "active" : ""}`}>
|
||||
<div className="billing-table-wrap">
|
||||
<table className="billing-table">
|
||||
<thead><tr><th>成员</th><th>角色</th><th>已用 / 月度额度</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -341,6 +335,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import { Pager } from "../components/pager";
|
||||
import type { Page } from "./route-config";
|
||||
import { statusPill } from "./stage-config";
|
||||
import "../ai-tools-page.css";
|
||||
@@ -133,9 +134,9 @@ export function AssetFactoryPage({ navigate, aiTasks, assets = [] }: { navigate:
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [view, setView] = useState<"grid" | "list">("grid");
|
||||
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
|
||||
// 任务中心分页:每次加载 12 条,「加载更多」递增;筛选/搜索变化时重置
|
||||
const TASKS_PER_LOAD = 12;
|
||||
const [shown, setShown] = useState(TASKS_PER_LOAD);
|
||||
// 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页
|
||||
const TASKS_PER_PAGE = 10;
|
||||
const [taskPage, setTaskPage] = useState(1);
|
||||
useEffect(() => {
|
||||
if (!openChip) return;
|
||||
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); };
|
||||
@@ -163,10 +164,11 @@ export function AssetFactoryPage({ navigate, aiTasks, assets = [] }: { navigate:
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// 筛选条件变化时回到第一屏(12 条)
|
||||
useEffect(() => { setShown(TASKS_PER_LOAD); }, [filter, query, timeFilter, typeFilter]);
|
||||
const paged = visible.slice(0, shown);
|
||||
const hasMore = visible.length > paged.length;
|
||||
// 筛选条件变化时回到第 1 页
|
||||
useEffect(() => { setTaskPage(1); }, [filter, query, timeFilter, typeFilter]);
|
||||
const taskTotalPages = Math.max(1, Math.ceil(visible.length / TASKS_PER_PAGE));
|
||||
const taskCurPage = Math.min(taskPage, taskTotalPages);
|
||||
const paged = visible.slice((taskCurPage - 1) * TASKS_PER_PAGE, taskCurPage * TASKS_PER_PAGE);
|
||||
|
||||
return (
|
||||
<div className="asset-factory">
|
||||
@@ -366,13 +368,7 @@ export function AssetFactoryPage({ navigate, aiTasks, assets = [] }: { navigate:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="task-load-more">
|
||||
<button className="btn" type="button" onClick={() => setShown((n) => n + TASKS_PER_LOAD)}>
|
||||
加载更多 <span className="lm-rest">还有 {visible.length - paged.length} 个</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,8 +30,9 @@ export function AuthScreen({
|
||||
onAuthed: (payload: { token: string; user: User; team: Team }) => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<AuthMode>(initialMode);
|
||||
const [username, setUsername] = useState("li@shop.com");
|
||||
const [password, setPassword] = useState("demo-1234");
|
||||
// 不预填设计稿假账号:真实登录页字段必须从空开始
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [teamName, setTeamName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [registerPassword, setRegisterPassword] = useState("");
|
||||
@@ -66,6 +67,9 @@ export function AuthScreen({
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (mode === "login") {
|
||||
if (!username.trim() || !password) throw new Error("请输入邮箱和密码");
|
||||
}
|
||||
if (mode === "register") {
|
||||
if (registerPassword.length < 8) throw new Error("密码至少 8 位");
|
||||
if (registerPassword !== registerPassword2) throw new Error("两次密码不一致");
|
||||
@@ -82,7 +86,12 @@ export function AuthScreen({
|
||||
});
|
||||
onAuthed(payload);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "登录失败");
|
||||
const raw = err instanceof Error ? err.message : "";
|
||||
// 后端错误是英文原文(invalid credentials 等),映射成用户可读的中文
|
||||
const friendly = /invalid credentials|unable to log in|non_field_errors/i.test(raw)
|
||||
? "邮箱或密码不正确"
|
||||
: raw || (mode === "login" ? "登录失败,请稍后重试" : "注册失败,请稍后重试");
|
||||
setError(friendly);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -141,7 +150,7 @@ export function AuthScreen({
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<label><input type="checkbox" defaultChecked /> 记住我 7 天</label>
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("已发送重置邮件", "请到 li@shop.com 收件箱查看 · 链接 30 分钟有效"); }}>忘记密码?</a>
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("已发送重置邮件", `请到 ${username.trim() || "登录邮箱"} 收件箱查看 · 链接 30 分钟有效`); }}>忘记密码?</a>
|
||||
</div>
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<button className="btn-cta" type="submit" disabled={busy}>
|
||||
|
||||
@@ -2,10 +2,25 @@ import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import type { Asset } from "../types";
|
||||
import { ConfirmModal, Drawer, MediaLightbox } from "../components/overlays";
|
||||
import { Pager } from "../components/pager";
|
||||
|
||||
// asset.source / asset.asset_type → 中文标签(筛选下拉用)
|
||||
const LIB_PAGE_SIZE = 10;
|
||||
|
||||
// asset.source / asset.asset_type / asset.category → 中文标签(筛选下拉 + 卡片 meta 用,不给用户看裸枚举)
|
||||
const SOURCE_LABELS: Record<string, string> = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" };
|
||||
const KIND_LABELS: Record<string, string> = { image: "图片", video: "视频", audio: "音频", subtitle: "字幕", document: "文档" };
|
||||
const CATEGORY_LABELS: Record<string, string> = { person: "人物", scene: "场景", product_image: "商品图", video_clip: "视频片段", final_video: "成片", upload: "上传", uncategorized: "未分类" };
|
||||
|
||||
// 上传文件 → 资产类型:按 MIME 推断,字幕按后缀,兜底文档(后端直接存这个值,不能一律写死 image)
|
||||
function inferAssetType(file: File): string {
|
||||
const mime = file.type || "";
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
const ext = (file.name.split(".").pop() || "").toLowerCase();
|
||||
if (["srt", "vtt", "ass"].includes(ext)) return "subtitle";
|
||||
return "document";
|
||||
}
|
||||
|
||||
type LibTab = "people" | "scenes" | "products" | "finals" | "uploads" | "unclassified";
|
||||
|
||||
@@ -44,6 +59,7 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
const [drawer, setDrawer] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [openChip, setOpenChip] = useState("");
|
||||
const [srcFilter, setSrcFilter] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState("");
|
||||
@@ -52,13 +68,17 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
// 资产预览灯箱(图片放大 / 视频播放)
|
||||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | 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]);
|
||||
|
||||
// 分页:每页 10 个,切 tab / 改筛选 / 改排序回到第 1 页(数据多时全量渲染卡顿,视频卡每张挂一个 <video>)
|
||||
const [page, setPage] = useState(1);
|
||||
useEffect(() => { setPage(1); }, [tab, query, srcFilter, kindFilter, metaFilter, sortDesc]);
|
||||
|
||||
// 切 tab 时清空与该 tab 无关的筛选
|
||||
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); }, [tab]);
|
||||
useEffect(() => {
|
||||
@@ -86,17 +106,29 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
const cmp = (b.created_at || "").localeCompare(a.created_at || "");
|
||||
return sortDesc ? cmp : -cmp;
|
||||
});
|
||||
// 当前页切片(删除资产后页数缩水时钳回最后一页)
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / LIB_PAGE_SIZE));
|
||||
const curPage = Math.min(page, totalPages);
|
||||
const pageItems = filtered.slice((curPage - 1) * LIB_PAGE_SIZE, curPage * LIB_PAGE_SIZE);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!file) return;
|
||||
if (!file || uploading) return;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("name", name || file.name);
|
||||
formData.append("asset_type", "image");
|
||||
formData.append("asset_type", inferAssetType(file));
|
||||
formData.append("category", "upload");
|
||||
await onUpload(formData);
|
||||
setDrawer(false);
|
||||
// 上传 + 列表刷新要数秒,按钮给「上传中…」反馈,防止用户重复点
|
||||
setUploading(true);
|
||||
try {
|
||||
await onUpload(formData);
|
||||
setDrawer(false);
|
||||
setFile(null);
|
||||
setName("");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -209,14 +241,21 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="result-meta" id="result-meta">// 显示 <span className="count">{filtered.length}</span> / {inTab.length} 个资产</div>
|
||||
<div className="result-meta" id="result-meta">// 显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个资产{filtered.length !== inTab.length ? "(已筛选)" : ""}</div>
|
||||
|
||||
{filtered.length ? (
|
||||
<div className="asset-grid" id="asset-grid">
|
||||
{filtered.map((asset) => {
|
||||
{pageItems.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 openPreview = cover ? () => setPreview({ src: cover, kind: isVideo ? "video" : "image", name: asset.name }) : undefined;
|
||||
const isAudio = asset.asset_type === "audio";
|
||||
const previewKind = isVideo ? "video" : isAudio ? "audio" : "image";
|
||||
const openPreview = cover ? () => setPreview({ src: cover, kind: previewKind, name: asset.name }) : undefined;
|
||||
// 卡片 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", " ") : "";
|
||||
return (
|
||||
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
|
||||
{editMode && onDelete && (
|
||||
@@ -224,17 +263,24 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
<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" role={openPreview ? "button" : undefined} tabIndex={openPreview ? 0 : undefined} title={openPreview ? (isVideo ? "点击播放" : "点击放大") : undefined} style={openPreview ? { cursor: isVideo ? "pointer" : "zoom-in", position: "relative" } : undefined} onClick={openPreview} onKeyDown={openPreview ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPreview(); } } : undefined}>
|
||||
{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>
|
||||
</>
|
||||
: <img src={cover} alt={asset.name} loading="lazy" />)
|
||||
: <span className="ph-frame">{asset.asset_type}</span>}
|
||||
<div className="placeholder asset-thumb" role={openPreview ? "button" : undefined} tabIndex={openPreview ? 0 : undefined} title={openPreview ? (isVideo ? "点击播放" : isAudio ? "点击试听" : "点击放大") : undefined} style={openPreview ? { cursor: isVideo || isAudio ? "pointer" : "zoom-in", position: "relative" } : undefined} onClick={openPreview} onKeyDown={openPreview ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPreview(); } } : undefined}>
|
||||
{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">{asset.category} · {asset.source}</div></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>
|
||||
);
|
||||
})}
|
||||
@@ -243,6 +289,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
<div className="empty-filter">// 当前分类暂无真实资产</div>
|
||||
)}
|
||||
|
||||
<Pager page={curPage} total={filtered.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
||||
|
||||
<ConfirmModal
|
||||
@@ -254,7 +302,7 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
|
||||
onConfirm={async () => { const id = confirmId; setConfirmId(null); if (id) await onDelete?.(id); }}
|
||||
/>
|
||||
|
||||
<Drawer title="上传资产" open={drawer} close={() => setDrawer(false)}><form onSubmit={submit}><div className="field"><label className="field-label">文件</label><input className="input file-input" type="file" onChange={(event) => setFile(event.target.files?.[0] || null)} /></div><div className="field"><label className="field-label">资产名称</label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="drawer-actions"><button className="btn btn-ghost" type="button" onClick={() => setDrawer(false)}>取消</button><button className="btn btn-primary" type="submit" disabled={!file}>上传资产</button></div></form></Drawer>
|
||||
<Drawer title="上传资产" open={drawer} close={() => setDrawer(false)}><form onSubmit={submit}><div className="field"><label className="field-label">文件</label><input className="input file-input" type="file" accept="image/*,video/*,audio/*,.srt,.vtt,.ass" onChange={(event) => setFile(event.target.files?.[0] || null)} /></div><div className="field"><label className="field-label">资产名称</label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="drawer-actions"><button className="btn btn-ghost" type="button" onClick={() => setDrawer(false)}>取消</button><button className="btn btn-primary" type="submit" disabled={!file || uploading}>{uploading ? "上传中…" : "上传资产"}</button></div></form></Drawer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+1238
-146
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,10 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
||||
import { ConfirmModal, MediaLightbox, useBodyScrollLock } from "../components/overlays";
|
||||
import { Pager } from "../components/pager";
|
||||
|
||||
const PROD_PAGE_SIZE = 10;
|
||||
import type { Asset, Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import "../product-create-page.css";
|
||||
@@ -66,6 +69,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
setSelected(new Set());
|
||||
};
|
||||
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
|
||||
useBodyScrollLock(drawer);
|
||||
const [title, setTitle] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [target, setTarget] = useState("");
|
||||
@@ -120,6 +124,12 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
}
|
||||
return matchQuery && matchCat && matchDate;
|
||||
});
|
||||
// 分页:每页 10 个,搜索/筛选变化回第 1 页
|
||||
const [page, setPage] = useState(1);
|
||||
useEffect(() => { setPage(1); }, [query, catFilter, dateFilter]);
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PROD_PAGE_SIZE));
|
||||
const curPage = Math.min(page, totalPages);
|
||||
const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE);
|
||||
|
||||
function addBullet(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key !== "Enter") return;
|
||||
@@ -210,12 +220,12 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
</div>
|
||||
|
||||
<div className="result-meta" id="result-meta">
|
||||
<span>// 显示 <span className="count">{filtered.length}</span> / {products.length} 个商品</span>
|
||||
<span>// 显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个商品</span>
|
||||
</div>
|
||||
|
||||
<div className="product-grid-wrap">
|
||||
<div className="product-grid" id="product-grid">
|
||||
{filtered.map((product) => (
|
||||
{pageItems.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
@@ -227,6 +237,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Pager page={curPage} total={filtered.length} pageSize={PROD_PAGE_SIZE} onChange={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ import type { CSSProperties, FormEvent } from "react";
|
||||
import type { Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { ConfirmModal, EmptyPanel } from "../components/overlays";
|
||||
import { Pager } from "../components/pager";
|
||||
import "../project-wizard-page.css";
|
||||
|
||||
const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数
|
||||
|
||||
// 时长 / 脚本风格 / 人设 — 与 projects-new.html 基线对齐(创建页仅作视觉选择,
|
||||
// 脚本走向细节进入 Stage 1;onCreate 契约只携带 name + product)。
|
||||
const WIZ_DURATIONS = [
|
||||
@@ -29,14 +32,18 @@ const WIZ_PERSONAS = [
|
||||
|
||||
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
|
||||
|
||||
export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
export function ProjectWizardPage({ products, projects = [], onBack, onCreate }: {
|
||||
products: Product[];
|
||||
projects?: Project[];
|
||||
onBack: () => void;
|
||||
onCreate: (payload: { name: string; product: string; metadata?: Record<string, unknown> }) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [productId, setProductId] = useState(products[0]?.id || "");
|
||||
const product = products.find((item) => item.id === productId) || products[0];
|
||||
const [name, setName] = useState(() => `${(products[0]?.title || "商品").split(" ")[0]} · 痛点种草 · v1`);
|
||||
// 默认项目名跟随所选商品+脚本风格,版本号按已有同名项目自动递增(避免连建多个同名 v1);
|
||||
// 用户手动改过名字后不再覆盖。
|
||||
const [name, setName] = useState("");
|
||||
const [nameTouched, setNameTouched] = useState(false);
|
||||
|
||||
// Step 1 · 商品选择器本地交互态
|
||||
const [pickSearch, setPickSearch] = useState("");
|
||||
@@ -56,6 +63,15 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
if (!productId && products[0]) setProductId(products[0].id);
|
||||
}, [productId, products]);
|
||||
|
||||
useEffect(() => {
|
||||
if (nameTouched) return;
|
||||
const styleName = WIZ_STYLES.find((s) => s.id === scriptStyle)?.name || "痛点种草";
|
||||
const base = `${(product?.title || "商品").split(" ")[0]} · ${styleName}`;
|
||||
let version = 1;
|
||||
while (projects.some((p) => p.name === `${base} · v${version}`)) version += 1;
|
||||
setName(`${base} · v${version}`);
|
||||
}, [nameTouched, product, scriptStyle, projects]);
|
||||
|
||||
// 分类清单
|
||||
const cats = useMemo(
|
||||
() => ["全部", ...Array.from(new Set(products.map((p) => p.category || "未分类")))],
|
||||
@@ -119,23 +135,30 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
const config2Done = !!duration && !!styleObj && name.trim().length >= 2;
|
||||
const canStart = product1Done && config2Done;
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
// 提交中状态:创建项目后 App 端全量刷新需数秒,按钮要给「创建中…」反馈,避免用户以为没点上
|
||||
const [starting, setStarting] = useState(false);
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!canStart || !product) return;
|
||||
if (!canStart || !product || starting) return;
|
||||
// 向导选项(时长档/脚本风格/人设/选中卖点)随项目一起持久化进 metadata,Stage 1 生成脚本时可用
|
||||
const selectedPoints = Object.entries(points).filter(([, on]) => on).map(([id]) => id);
|
||||
void onCreate({
|
||||
name: name.trim() || `${product.title} · 短视频`,
|
||||
product: product.id,
|
||||
metadata: {
|
||||
wizard: {
|
||||
duration,
|
||||
script_style: scriptStyle,
|
||||
persona,
|
||||
selling_point_ids: selectedPoints
|
||||
setStarting(true);
|
||||
try {
|
||||
await onCreate({
|
||||
name: name.trim() || `${product.title} · 短视频`,
|
||||
product: product.id,
|
||||
metadata: {
|
||||
wizard: {
|
||||
duration,
|
||||
script_style: scriptStyle,
|
||||
persona,
|
||||
selling_point_ids: selectedPoints
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const productCover = (p: Product): CSSProperties | undefined => {
|
||||
@@ -273,7 +296,7 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
<div className="config-row">
|
||||
<div className="field">
|
||||
<label className="field-label">项目名称<span className="req">*</span></label>
|
||||
<input className="input" value={name} onChange={(event) => setName(event.target.value)} />
|
||||
<input className="input" value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">视频时长<span className="req">*</span></label>
|
||||
@@ -346,9 +369,9 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
|
||||
{/* ── 底部「开始」CTA ── */}
|
||||
<div className="wiz-start-bar">
|
||||
<button className={`btn-start${canStart ? "" : " disabled"}`} type="submit" disabled={!canStart}>
|
||||
<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>开始</span>
|
||||
<span>{starting ? "创建中…" : "开始"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -455,6 +478,12 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// 分页:每页 10 个,切 tab / 搜索 / 筛选回第 1 页(列表与网格共用)
|
||||
const [page, setPage] = useState(1);
|
||||
useEffect(() => { setPage(1); }, [tab, query, catFilter, sourceFilter, timeFilter]);
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PROJ_PAGE_SIZE));
|
||||
const curPage = Math.min(page, totalPages);
|
||||
const pageItems = filtered.slice((curPage - 1) * PROJ_PAGE_SIZE, curPage * PROJ_PAGE_SIZE);
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
@@ -545,7 +574,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="result-meta" id="result-meta">// 显示 <span className="count">{filtered.length}</span> / {projects.length} 个项目</div>
|
||||
<div className="result-meta" id="result-meta">// 显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个项目</div>
|
||||
|
||||
{view === "list" ? (
|
||||
<div id="list-view">
|
||||
@@ -562,7 +591,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="list-tbody">
|
||||
{filtered.map((project) => {
|
||||
{pageItems.map((project) => {
|
||||
const no = projStageNo(project);
|
||||
const shots = project.video_segments.length || 4;
|
||||
const cover = projCover(project.name);
|
||||
@@ -603,7 +632,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="proj-grid">{filtered.map((project) => {
|
||||
<div className="proj-grid">{pageItems.map((project) => {
|
||||
const cover = projCover(project.name);
|
||||
return (
|
||||
<article className="proj-card" key={project.id} onClick={() => openPipeline(project.id)}>
|
||||
@@ -617,6 +646,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
);
|
||||
})}</div>
|
||||
)}
|
||||
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
|
||||
{filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
|
||||
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}。`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
|
||||
</section>
|
||||
|
||||
@@ -104,6 +104,7 @@ export function SettingsPage({
|
||||
onUploadAvatar,
|
||||
onResetAvatar,
|
||||
onNotify,
|
||||
onLogout,
|
||||
}: {
|
||||
user: User;
|
||||
team: Team;
|
||||
@@ -118,6 +119,7 @@ export function SettingsPage({
|
||||
onUploadAvatar: (formData: FormData) => void | Promise<unknown>;
|
||||
onResetAvatar?: () => void | Promise<unknown>;
|
||||
onNotify?: (text: string) => void;
|
||||
onLogout?: () => void | Promise<void>;
|
||||
}) {
|
||||
const normalizedInitial = (["profile", "security", "notify", "pref", "display"] as const).includes(initialSection as SectionKey)
|
||||
? (initialSection as SectionKey)
|
||||
@@ -676,7 +678,7 @@ export function SettingsPage({
|
||||
icon={<LogOut size={16} />}
|
||||
close={() => setModal("")}
|
||||
footer={
|
||||
<button className="btn btn-primary" type="button" onClick={() => setModal("")}>
|
||||
<button className="btn btn-primary" type="button" onClick={() => { setModal(""); void onLogout?.(); }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="m16 17 5-5-5-5" /><path d="M21 12H9" /></svg>
|
||||
确认退出
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CircleDollarSign, KeyRound, UserPlus } from "lucide-react";
|
||||
import type { BillingSummary, 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;
|
||||
|
||||
// 角色 → pill key/label(对齐 api-bridge roleUi)
|
||||
function roleUi(role: string): { key: "super" | "admin" | "member"; label: string } {
|
||||
@@ -84,6 +87,12 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
|
||||
const email = member.user.email || "";
|
||||
return !needle || `${name} ${email}`.toLowerCase().includes(needle);
|
||||
});
|
||||
// 成员分页:每页 10 人,搜索变化回第 1 页
|
||||
const [memberPage, setMemberPage] = useState(1);
|
||||
useEffect(() => { setMemberPage(1); }, [search]);
|
||||
const memberTotalPages = Math.max(1, Math.ceil(list.length / MEMBERS_PER_PAGE));
|
||||
const memberCurPage = Math.min(memberPage, memberTotalPages);
|
||||
const pagedMembers = list.slice((memberCurPage - 1) * MEMBERS_PER_PAGE, memberCurPage * MEMBERS_PER_PAGE);
|
||||
|
||||
function openEdit(member: TeamMember) {
|
||||
setEditTarget(member);
|
||||
@@ -93,13 +102,15 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
|
||||
|
||||
async function submitCreate() {
|
||||
if (!cuUser.trim() || cuPass.length < 8) return;
|
||||
await onCreateMember({
|
||||
const created = await onCreateMember({
|
||||
username: cuUser.trim(),
|
||||
password: cuPass,
|
||||
name: cuName.trim() || undefined,
|
||||
role: cuRole,
|
||||
monthly_credit_limit: Number(cuMonthly) || 0
|
||||
});
|
||||
// 创建失败(action 报错返回 null)时保持弹窗与已填内容,只成功才关闭并清空
|
||||
if (!created) return;
|
||||
setModal("");
|
||||
setCuUser("");
|
||||
setCuPass("");
|
||||
@@ -241,6 +252,7 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
|
||||
<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>
|
||||
@@ -253,7 +265,7 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="members-tbody">
|
||||
{list.map((member) => {
|
||||
{pagedMembers.map((member) => {
|
||||
const rawName = (member.user.username || "").trim();
|
||||
const email = (member.user.email || "").trim();
|
||||
// 用户名是邮箱时取 @ 前作为显示名,完整邮箱作副行,避免名字与邮箱重复显示
|
||||
@@ -287,6 +299,8 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pager page={memberCurPage} total={list.length} pageSize={MEMBERS_PER_PAGE} onChange={setMemberPage} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user