Commit 74404e9d authored by yaoke.yk's avatar yaoke.yk

客户端页面优化figma

parent dcfda87a
......@@ -120,7 +120,7 @@ export function Layout() {
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-[#0073ff] to-[#7b2cff]">
<Film className="h-4 w-4 text-white" />
</div>
<span className="text-base font-semibold tracking-normal text-[#111827]">Dramix</span>
<span className="text-base font-semibold tracking-normal text-[#7a5cf6]">Dramix.ai</span>
</Link>
</div>
......@@ -309,31 +309,66 @@ export function Layout() {
<section className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
{isProjectRoute && (
<div className="flex h-12 items-center gap-5 border-b border-[#dfe3ee] bg-white px-6">
<div className="flex min-w-0 items-center gap-2 border-r border-[#dfe3ee] pr-5">
<Film className="h-4 w-4 flex-shrink-0 text-[#1664ff]" />
<span className="max-w-[240px] truncate text-sm font-medium text-[#111827]">
{currentProject?.name ?? projectId}
</span>
<div className="flex h-16 items-center border-b border-[#e5e8f0] bg-[#fbfcff] px-5">
<div className="flex min-w-[310px] items-center gap-3">
<button
type="button"
onClick={() => navigate("/")}
className="flex h-7 w-7 items-center justify-center rounded-md text-[#111827] hover:bg-[#edf1f8]"
aria-label="返回项目列表"
>
<ChevronDown className="h-4 w-4 rotate-90" />
</button>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="max-w-[270px] truncate text-sm font-semibold text-[#111827]">
{currentProject?.name ?? projectId}
</span>
<button className="text-[#98a2b3] hover:text-[#6f55f2]" aria-label="编辑项目名">
<FileText className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-[#667085]">
<span>9:16</span>
<span>720</span>
<span>写实电影感</span>
</div>
</div>
</div>
<div className="flex min-w-0 items-center gap-1.5 overflow-x-auto">
{projectSections.map((section) => {
<div className="mx-auto hidden items-center gap-5 xl:flex">
{projectSections.filter((section) => section.id !== "agent").map((section, index, arr) => {
const Icon = section.icon;
const active = getActiveSection() === section.id;
return (
<button
key={section.id}
onClick={() => navigate(section.path)}
className={`flex h-8 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm transition ${
active ? "bg-[#eef3ff] text-[#1664ff]" : "text-[#475467] hover:bg-[#f2f4f8]"
}`}
>
<Icon className="h-4 w-4" />
{section.label}
</button>
<div key={section.id} className="flex items-center gap-5">
<button
onClick={() => navigate(section.path)}
className={`flex h-12 min-w-[96px] flex-col items-center justify-center gap-1 rounded-full border px-6 text-xs transition ${
active
? "border-[#7657ff] bg-white text-[#7657ff] shadow-[0_6px_18px_rgba(118,87,255,0.18)]"
: "border-[#e2e6ef] bg-white text-[#111827] hover:border-[#b8c1d4]"
}`}
>
<Icon className="h-4 w-4" />
{section.label}
</button>
{index < arr.length - 1 && <span className="h-px w-8 bg-[#d9dee9]" />}
</div>
);
})}
</div>
<div className="ml-auto flex min-w-[260px] items-center justify-end gap-3">
<button className="text-xs text-[#667085] hover:text-[#6f55f2]">优化建议</button>
<button
onClick={() => navigate("/usage")}
className="flex h-8 items-center gap-1.5 rounded-md border border-[#d9d2ff] bg-[#f6f2ff] px-3 text-xs font-semibold text-[#6f55f2]"
>
<Sparkles className="h-3.5 w-3.5" />
{Number(balance?.balance ?? 7492538).toLocaleString()}
</button>
</div>
</div>
)}
......
......@@ -43,6 +43,14 @@ const ROLE_TYPES = [
{ value: "supporting", label: "配角" },
];
const ROLE_TYPE_LABELS: Record<string, string> = {
female_lead: "女主角",
male_lead: "男主角",
antagonist: "反派",
villain: "反派",
supporting: "配角",
};
const CHARACTER_VIEWS: Array<{ type: CharacterViewType; label: string; shortLabel: string }> = [
{ type: "front", label: "正面", shortLabel: "正" },
{ type: "side", label: "侧面", shortLabel: "侧" },
......@@ -57,6 +65,7 @@ const emptyForm = (): Partial<Character> => ({
personality: "",
costume: "",
visualHint: "",
imagePrompt: "",
});
const emptyImageState = (): ImageState => ({
......@@ -98,6 +107,35 @@ function hasCharacterImage(character: Partial<Character>) {
return CHARACTER_VIEWS.every((view) => !!getCharacterViewUrl(character, view.type));
}
function isMostlyEnglishPrompt(prompt: string) {
if (!prompt.trim()) return false;
const latinChars = prompt.match(/[A-Za-z]/g)?.length ?? 0;
const chineseChars = prompt.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
return latinChars > chineseChars * 2;
}
function buildChineseCharacterPrompt(character: Partial<Character>) {
const name = character.name?.trim() || "该角色";
const role = character.roleType ? ROLE_TYPE_LABELS[character.roleType] ?? character.roleType : "";
const gender = character.gender === "male" ? "男性" : character.gender === "female" ? "女性" : character.gender;
const details = [
`角色:${name}`,
role ? `定位:${role}` : "",
gender ? `性别:${gender}` : "",
character.age ? `年龄:${character.age}` : "",
character.personality ? `性格气质:${character.personality}` : "",
character.visualHint ? `外貌特征:${character.visualHint}` : "",
character.costume ? `服装造型:${character.costume}` : "",
].filter(Boolean);
return [
"生成一张单张角色参考图,用于 AI 漫剧角色一致性。",
details.join(";"),
"画面需要同时包含清晰大头照,以及正面、侧面、背面全身三视图;五官、发型、服装、体型在所有视角中保持一致。",
"全身视图自然站姿,双臂自然下垂,脚部完整入镜,背景干净,光线均匀,细节清晰。",
].join("\n");
}
export function CharacterGeneration() {
const navigate = useNavigate();
const { projectId } = useParams();
......@@ -139,13 +177,7 @@ export function CharacterGeneration() {
{ id: "props" as SettingsTab, label: "道具", icon: Box, path: `/project/${projectId}/props` },
];
const roleTypeLabel: Record<string, string> = {
female_lead: "女主角",
male_lead: "男主角",
antagonist: "反派",
villain: "反派",
supporting: "配角",
};
const roleTypeLabel = ROLE_TYPE_LABELS;
const generatedCount = characters.filter((character) => hasCharacterImage(character)).length;
const generatingCount = characters.filter((character) => character.status === "generating").length;
......@@ -183,7 +215,13 @@ export function CharacterGeneration() {
};
const openEdit = (character: Character) => {
setEditingChar({ ...character });
const prompt = character.imagePrompt?.trim() ?? "";
setEditingChar({
...character,
imagePrompt: !prompt || isMostlyEnglishPrompt(prompt)
? buildChineseCharacterPrompt(character)
: prompt,
});
setImagePreviews({
front: getCharacterViewUrl(character, "front"),
side: getCharacterViewUrl(character, "side"),
......@@ -219,10 +257,9 @@ export function CharacterGeneration() {
setSaving(true);
try {
const saved = await saveCharacter.mutateAsync({ ...editingChar, status: editingChar.status ?? "draft" });
for (const view of CHARACTER_VIEWS) {
const pendingFile = pendingFiles[view.type];
if (!pendingFile) continue;
await uploadImage.mutateAsync({ id: String(saved.id), file: pendingFile, viewType: view.type });
const pendingFile = pendingFiles.front;
if (pendingFile) {
await uploadImage.mutateAsync({ id: String(saved.id), file: pendingFile, viewType: "front" });
}
setShowModal(false);
} catch (error) {
......@@ -555,11 +592,11 @@ export function CharacterGeneration() {
{showModal && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6">
<div className="bg-card rounded-2xl border border-border w-full max-w-4xl max-h-[90vh] flex flex-col">
<div className="bg-card rounded-2xl border border-border w-full max-w-3xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-border flex-shrink-0">
<div>
<h2 className="text-lg font-semibold text-foreground">{editingChar.id ? "编辑角色" : "手动添加角色"}</h2>
<p className="text-sm text-muted-foreground mt-1">支持上传正面、侧面、背面三张标准视角图</p>
<p className="text-sm text-muted-foreground mt-1">角色图已合并为单张图片,只需要维护图片和生成提示词</p>
</div>
<button onClick={() => setShowModal(false)} className="p-2 hover:bg-muted rounded-lg transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
......@@ -567,48 +604,66 @@ export function CharacterGeneration() {
</div>
<div className="overflow-auto p-6 space-y-5 flex-1">
<div>
<label className="block text-sm font-medium text-foreground mb-3">角色三视图上传(可选)</label>
<div className="grid grid-cols-3 gap-4">
{CHARACTER_VIEWS.map((view) => (
<div key={view.type} className="rounded-xl border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">{view.label}</span>
<span className="text-[11px] text-muted-foreground">{view.shortLabel}视图</span>
</div>
<div
className="aspect-[3/4] rounded-lg border-2 border-dashed border-border bg-background flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden"
onClick={() => fileInputRefs.current[view.type]?.click()}
>
{imagePreviews[view.type] ? (
<img src={imagePreviews[view.type] ?? ""} alt={`${view.label}预览`} className="w-full h-full object-cover" />
) : (
<div className="text-center px-4">
<Upload className="w-5 h-5 text-muted-foreground mx-auto mb-2" />
<span className="text-xs text-muted-foreground">点击上传{view.label}</span>
</div>
)}
</div>
<div className="mt-2 flex items-center justify-between text-xs">
<span className="text-muted-foreground">建议全身标准视角</span>
{imagePreviews[view.type] && (
<button onClick={() => clearImage(view.type)} className="text-destructive hover:underline">
移除
</button>
)}
</div>
<input
ref={(node) => {
fileInputRefs.current[view.type] = node;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleFileSelect(view.type, e)}
/>
<div className="rounded-xl border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between mb-3">
<div>
<label className="block text-sm font-medium text-foreground">角色图片(可选)</label>
<p className="text-xs text-muted-foreground mt-1">上传大头照与三视图合并后的单张角色参考图。</p>
</div>
{imagePreviews.front && (
<button onClick={() => clearImage("front")} className="text-xs text-destructive hover:underline">
移除
</button>
)}
</div>
<div
className="aspect-[16/9] rounded-lg border-2 border-dashed border-border bg-background flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden"
onClick={() => fileInputRefs.current.front?.click()}
>
{imagePreviews.front ? (
<img src={imagePreviews.front} alt="角色图片预览" className="w-full h-full object-contain" />
) : (
<div className="text-center px-4">
<Upload className="w-6 h-6 text-muted-foreground mx-auto mb-2" />
<span className="text-sm text-muted-foreground">点击上传角色图片</span>
</div>
))}
)}
</div>
<input
ref={(node) => {
fileInputRefs.current.front = node;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleFileSelect("front", e)}
/>
</div>
<div>
<div className="mb-1 flex items-center justify-between gap-3">
<label className="block text-sm font-medium text-foreground">角色图片提示词</label>
<button
type="button"
onClick={() =>
setEditingChar({
...editingChar,
imagePrompt: buildChineseCharacterPrompt(editingChar),
})
}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted"
>
<Sparkles className="h-3.5 w-3.5" />
生成中文提示词
</button>
</div>
<textarea
value={editingChar.imagePrompt ?? ""}
onChange={(e) => setEditingChar({ ...editingChar, imagePrompt: e.target.value })}
placeholder="用于生成或重生成角色参考图的提示词,可描述外貌、服装、气质和画面要求。"
rows={6}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<div>
......
......@@ -20,7 +20,7 @@ function ProjectCover({ url, name, className = "" }: { url?: string; name: strin
return <img src={url} alt={name} className={`w-full h-full object-cover ${className}`} />;
}
return (
<div className={`flex h-full w-full items-center justify-center bg-[linear-gradient(135deg,#e5fff2_0%,#b9d8ff_52%,#61d7ee_100%)] ${className}`}>
<div className={`flex h-full w-full items-center justify-center bg-[linear-gradient(135deg,#f4f0ff_0%,#d9cdff_48%,#9f7bff_100%)] ${className}`}>
<div className="relative text-white">
<Film className="h-8 w-8 stroke-[2.2]" />
<Sparkles className="absolute -right-2 bottom-0 h-4 w-4 fill-white stroke-white" />
......@@ -299,17 +299,17 @@ export function Dashboard() {
}, [page, totalPages]);
return (
<div className="h-full overflow-auto bg-[#f6f7fb] p-5 text-[#0f172a]">
<div className="h-full overflow-auto bg-[#f8f9fa] p-6 text-[#15131f]">
<div className="mx-auto max-w-[1440px]">
{/* Header */}
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="mb-1 text-2xl font-semibold text-[#111827]">剧本生短剧</h1>
<h1 className="mb-1 text-2xl font-semibold text-[#15131f]">剧本生短剧</h1>
</div>
<Link
to="/new-project"
className="flex h-9 items-center gap-2 rounded-md bg-[#1664ff] px-4 text-sm font-medium text-white transition hover:bg-[#0f56d9]"
className="dramix-primary flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium transition"
>
<Plus className="w-4 h-4" />
新建项目
......@@ -317,54 +317,54 @@ export function Dashboard() {
</div>
{/* Search & Filters */}
<div className="mb-4 rounded-lg border border-[#e1e6f0] bg-white p-4">
<div className="dramix-panel mb-5 rounded-xl p-4">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_minmax(260px,340px)_minmax(140px,160px)_auto]">
<div>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#8a94a6]" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#8d86a2]" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索短剧项目名称"
className="h-9 w-full rounded-md border border-[#d6dbe7] bg-white pl-9 pr-3 text-sm text-[#111827] outline-none transition placeholder:text-[#98a2b3] focus:border-[#1664ff] focus:ring-2 focus:ring-[#1664ff]/10"
className="h-9 w-full rounded-lg border border-[#d8dae9] bg-white pl-9 pr-3 text-sm text-[#15131f] outline-none transition placeholder:text-[#9c9c9c] focus:border-[#5337c7] focus:ring-2 focus:ring-[#f3eefe]"
/>
</div>
</div>
<div className="flex h-9 items-center rounded-md border border-[#d6dbe7] bg-white px-3">
<div className="flex h-9 items-center rounded-lg border border-[#d8dae9] bg-white px-3">
<div className="relative min-w-0 flex-1">
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="h-7 w-full bg-transparent text-sm text-[#111827] outline-none"
className="h-7 w-full bg-transparent text-sm text-[#15131f] outline-none"
/>
</div>
<span className="px-2 text-sm text-[#98a2b3]">-</span>
<span className="px-2 text-sm text-[#9b94ad]">-</span>
<div className="relative min-w-0 flex-1">
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="h-7 w-full bg-transparent text-sm text-[#111827] outline-none"
className="h-7 w-full bg-transparent text-sm text-[#15131f] outline-none"
/>
</div>
<Calendar className="ml-2 h-4 w-4 text-[#667085]" />
<Calendar className="ml-2 h-4 w-4 text-[#7a748c]" />
</div>
<div>
<div className="relative">
<button
onClick={() => setShowMemberFilter(!showMemberFilter)}
className="flex h-9 w-full items-center justify-between rounded-md border border-[#d6dbe7] bg-white px-3 text-left text-sm text-[#111827] transition hover:border-[#b8c2d6]"
className="flex h-9 w-full items-center justify-between rounded-lg border border-[#d8dae9] bg-white px-3 text-left text-sm text-[#15131f] transition hover:border-[#cac3f3]"
>
<span className="flex items-center gap-2">
<Filter className="h-4 w-4 text-[#8a94a6]" />
<Filter className="h-4 w-4 text-[#8d86a2]" />
{selectedMembers.length > 0 ? `${selectedMembers.length}个成员` : "选择成员"}
</span>
<span className="text-xs text-[#8a94a6]">{showMemberFilter ? "▲" : "▼"}</span>
<span className="text-xs text-[#8d86a2]">{showMemberFilter ? "▲" : "▼"}</span>
</button>
{showMemberFilter && (
<div className="absolute left-0 right-0 top-full z-20 mt-2 rounded-lg border border-[#dfe3ee] bg-white p-3 shadow-lg">
<div className="absolute left-0 right-0 top-full z-20 mt-2 rounded-xl border border-[#e6eaed] bg-white p-3 shadow-lg">
<div className="flex flex-wrap gap-2">
{allMembers.map((member) => (
<button
......@@ -373,7 +373,7 @@ export function Dashboard() {
prev.includes(member) ? prev.filter((m) => m !== member) : [...prev, member]
)}
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
selectedMembers.includes(member) ? "bg-[#1664ff] text-white" : "bg-[#f2f4f8] text-[#344054] hover:bg-[#e8edf6]"
selectedMembers.includes(member) ? "bg-[#5337c7] text-white" : "bg-[#f7f5fb] text-[#51476f] hover:bg-[#f3eefe]"
}`}
>
{member}
......@@ -391,7 +391,7 @@ export function Dashboard() {
</div>
<Link
to="/new-project"
className="flex h-9 items-center justify-center gap-2 rounded-md bg-[#5d5bff] px-4 text-sm font-medium text-white transition hover:bg-[#4d4af0]"
className="dramix-primary flex h-9 items-center justify-center gap-2 rounded-lg px-4 text-sm font-medium transition"
>
<Plus className="h-4 w-4" />
创建项目
......@@ -411,7 +411,7 @@ export function Dashboard() {
<div key={project.id} className="group relative">
{/* Cover */}
<Link to={`/project/${project.id}`} className="block aspect-[16/10] overflow-hidden rounded-lg bg-[#eef1f7]">
<Link to={`/project/${project.id}`} className="block aspect-[16/10] overflow-hidden rounded-xl bg-[#f0edf7] shadow-[0_12px_30px_rgba(37,31,63,0.08)]">
<ProjectCover
url={project.coverUrl}
name={project.title}
......@@ -423,26 +423,26 @@ export function Dashboard() {
<div className="relative pt-3">
<Link to={`/project/${project.id}`} className="block pr-9">
<div className="flex min-w-0 items-center gap-2">
<h3 className="line-clamp-1 font-medium text-[#111827]">{project.title}</h3>
<span className="shrink-0 rounded bg-[#ecebff] px-1.5 py-0.5 text-xs text-[#5d5bff]">
<h3 className="line-clamp-1 font-medium text-[#15131f]">{project.title}</h3>
<span className="shrink-0 rounded bg-[#f3eefe] px-1.5 py-0.5 text-xs text-[#5337c7]">
{project.progress === 100 ? "已完成" : "创作中"}
</span>
</div>
<div className="mt-2 text-xs text-[#a0a9bc]">
<div className="mt-2 text-xs text-[#8d86a2]">
{project.createdAt ? `${project.createdAt.replace("T", " ").slice(0, 19)} 创建` : "—"}
</div>
</Link>
<div className="absolute right-0 top-3 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => { e.preventDefault(); setEditingProject(project.raw); }}
className="flex h-7 w-7 items-center justify-center rounded text-[#475467] hover:bg-[#eef1f7]"
className="flex h-7 w-7 items-center justify-center rounded text-[#5b546e] hover:bg-[#f3eefe]"
title="编辑项目"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => { e.preventDefault(); setDeletingProject(project.raw); }}
className="flex h-7 w-7 items-center justify-center rounded text-[#475467] hover:bg-[#fff1f0] hover:text-[#d92d20]"
className="flex h-7 w-7 items-center justify-center rounded text-[#5b546e] hover:bg-[#fff1f0] hover:text-[#d92d20]"
title="删除项目"
>
<MoreHorizontal className="h-4 w-4" />
......@@ -452,29 +452,29 @@ export function Dashboard() {
</div>
))}
</div>
<div className="mt-12 flex items-center justify-end gap-2 text-sm text-[#344054]">
<div className="mt-12 flex items-center justify-end gap-2 text-sm text-[#51476f]">
<span className="mr-1">{filtered.length}</span>
<button
onClick={() => setPage((value) => Math.max(1, value - 1))}
disabled={safePage === 1}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[#d6dbe7] bg-white disabled:cursor-not-allowed disabled:opacity-50"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-[#d8dae9] bg-white disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
<span className="flex h-8 min-w-8 items-center justify-center rounded-md border border-[#5d5bff] bg-white px-2 text-[#5d5bff]">
<span className="flex h-8 min-w-8 items-center justify-center rounded-lg border border-[#5337c7] bg-white px-2 text-[#5337c7]">
{safePage}
</span>
<button
onClick={() => setPage((value) => Math.min(totalPages, value + 1))}
disabled={safePage === totalPages}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[#d6dbe7] bg-white disabled:cursor-not-allowed disabled:opacity-50"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-[#d8dae9] bg-white disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
className="ml-2 h-8 rounded-md border border-[#d6dbe7] bg-white px-3 text-sm outline-none"
className="ml-2 h-8 rounded-lg border border-[#d8dae9] bg-white px-3 text-sm outline-none"
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>{size} 条/页</option>
......@@ -487,30 +487,30 @@ export function Dashboard() {
const value = Number(e.target.value);
if (Number.isFinite(value)) setPage(Math.min(Math.max(1, value), totalPages));
}}
className="h-8 w-14 rounded-md border border-[#d6dbe7] bg-white px-2 text-center outline-none"
className="h-8 w-14 rounded-lg border border-[#d8dae9] bg-white px-2 text-center outline-none"
/>
<span></span>
</div>
</>
) : projects.length === 0 ? (
<div className="rounded-lg border border-dashed border-[#cfd6e4] bg-white py-16 text-center">
<Film className="mx-auto mb-4 h-14 w-14 text-[#98a2b3]" />
<p className="mb-4 text-[#667085]">还没有项目,开始创建第一个吧</p>
<div className="rounded-xl border border-dashed border-[#d6cfea] bg-white py-16 text-center">
<Film className="mx-auto mb-4 h-14 w-14 text-[#9b94ad]" />
<p className="mb-4 text-[#7a748c]">还没有项目,开始创建第一个吧</p>
<Link
to="/new-project"
className="inline-flex h-9 items-center gap-2 rounded-md bg-[#1664ff] px-4 text-sm font-medium text-white transition hover:bg-[#0f56d9]"
className="dramix-primary inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium transition"
>
<Plus className="h-4 w-4" />
新建项目
</Link>
</div>
) : (
<div className="rounded-lg border border-dashed border-[#cfd6e4] bg-white py-16 text-center">
<Film className="mx-auto mb-4 h-14 w-14 text-[#98a2b3]" />
<p className="text-[#667085]">没有找到符合条件的项目</p>
<div className="rounded-xl border border-dashed border-[#d6cfea] bg-white py-16 text-center">
<Film className="mx-auto mb-4 h-14 w-14 text-[#9b94ad]" />
<p className="text-[#7a748c]">没有找到符合条件的项目</p>
<button
onClick={() => { setSearchTerm(""); setDateRange({ start: "", end: "" }); setSelectedMembers([]); }}
className="mt-4 text-sm text-[#1664ff] hover:underline"
className="mt-4 text-sm text-[#5337c7] hover:underline"
>
清除所有筛选条件
</button>
......
......@@ -143,24 +143,24 @@ export function NewProject() {
const activeSubmitMeta = submitStage === "idle" ? null : submitStageMeta[submitStage];
return (
<div className="relative min-h-full overflow-auto bg-[#f6f7fb] text-[#0f172a]">
<div className="relative min-h-full overflow-auto bg-[#f8f9fa] text-[#15131f]">
{activeSubmitMeta && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 p-6 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl border border-[#dfe6f3] bg-white p-6 shadow-2xl">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-[#1677ff] to-[#7c2dff]">
<div className="w-full max-w-md rounded-2xl border border-[#e6eaed] bg-white p-6 shadow-2xl">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#8064f1]">
<Loader2 className="h-7 w-7 animate-spin text-white" />
</div>
<h2 className="mb-2 text-xl font-semibold text-[#0f172a]">{activeSubmitMeta.title}</h2>
<p className="mb-5 text-sm leading-relaxed text-[#667085]">
<h2 className="mb-2 text-xl font-semibold text-[#15131f]">{activeSubmitMeta.title}</h2>
<p className="mb-5 text-sm leading-relaxed text-[#7a748c]">
{activeSubmitMeta.description}
</p>
<Progress value={activeSubmitMeta.progress} className="mb-4 h-2.5" />
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between text-[#202b3d]">
<div className="flex items-center justify-between text-[#262238]">
<span>创建项目</span>
<span>{submitStage === "creating" ? "进行中" : "已完成"}</span>
</div>
<div className="flex items-center justify-between text-[#202b3d]">
<div className="flex items-center justify-between text-[#262238]">
<span>上传剧本</span>
<span>
{!hasUpload
......@@ -172,28 +172,28 @@ export function NewProject() {
: "等待中"}
</span>
</div>
<div className="flex items-center justify-between text-[#202b3d]">
<div className="flex items-center justify-between text-[#262238]">
<span>进入大纲页</span>
<span>{submitStage === "redirecting" ? "进行中" : "等待中"}</span>
</div>
</div>
<p className="mt-4 text-xs text-[#8a94a6]">处理过程中按钮已锁定,避免重复创建项目。</p>
<p className="mt-4 text-xs text-[#7a748c]">处理过程中按钮已锁定,避免重复创建项目。</p>
</div>
</div>
)}
<div className="flex min-h-full flex-col">
<div className="flex h-12 items-center justify-center gap-2 border-b border-[#e7ebf3] bg-[#f0f3ff] px-6 text-sm text-[#111827]">
<Megaphone className="h-4 w-4 text-[#1764ff]" />
<div className="flex h-12 items-center justify-center gap-2 border-b border-[#e6eaed] bg-[#f3eefe] px-6 text-sm text-[#262238]">
<Megaphone className="h-4 w-4 text-[#5337c7]" />
<span>
公告:视频生成在使用视频素材作为输入时,积分计算逻辑已更新。针对不同的视频输出时长,现设有最低积分消耗限制,详情请参见
</span>
<span className="font-medium text-[#1764ff]">视频生成计费说明。</span>
<span className="font-medium text-[#5337c7]">视频生成计费说明。</span>
</div>
<main className="mx-auto flex w-full max-w-[1280px] flex-1 flex-col items-center px-8 pb-10 pt-14">
<h1 className="text-center text-[40px] font-semibold leading-tight tracking-normal text-black">
翻开剧本,创作<span className="text-[#5b55ff]">精品短剧</span>
翻开剧本,创作<span className="text-[#5337c7]">精品短剧</span>
</h1>
{submitError && (
......@@ -203,13 +203,13 @@ export function NewProject() {
</div>
)}
<section className="mt-12 w-full max-w-[1000px] rounded-[20px] border border-[#dde5f1] bg-white p-4 shadow-[0_22px_60px_rgba(38,51,77,0.08)]">
<section className="dramix-panel mt-12 w-full max-w-[1000px] rounded-2xl p-4">
<label
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
className={`flex min-h-[228px] items-center justify-center rounded-2xl border border-dashed border-[#d9e2f1] bg-[#f8faff] transition-colors ${
className={`flex min-h-[228px] items-center justify-center rounded-xl border border-dashed border-[#d6cfea] bg-[#fbfaff] transition-colors ${
mode === "agent" && !isSubmitting
? "cursor-pointer hover:border-[#7b8cff] hover:bg-[#f5f7ff]"
? "cursor-pointer hover:border-[#a58bff] hover:bg-[#f7f3ff]"
: "cursor-default"
}`}
>
......@@ -226,11 +226,11 @@ export function NewProject() {
{mode === "agent" ? (
scriptFile ? (
<div className="flex flex-col items-center text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl border border-[#dfe6f3] bg-white shadow-sm">
<FileText className="h-8 w-8 text-[#5b55ff]" />
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl border border-[#e6eaed] bg-white shadow-sm">
<FileText className="h-8 w-8 text-[#5337c7]" />
</div>
<div className="text-sm font-medium text-[#111827]">{scriptFile.name}</div>
<div className="mt-1 text-xs text-[#8a94a6]">{formatFileSize(scriptFile.size)}</div>
<div className="text-sm font-medium text-[#15131f]">{scriptFile.name}</div>
<div className="mt-1 text-xs text-[#7a748c]">{formatFileSize(scriptFile.size)}</div>
<button
type="button"
onClick={(e) => {
......@@ -238,7 +238,7 @@ export function NewProject() {
if (!isSubmitting) setScriptFile(null);
}}
disabled={isSubmitting}
className="mt-4 rounded-lg border border-[#dfe6f3] bg-white px-3 py-1.5 text-xs text-[#4f46e5] transition-colors hover:bg-[#f5f7ff] disabled:cursor-not-allowed disabled:opacity-60"
className="mt-4 rounded-lg border border-[#e6eaed] bg-white px-3 py-1.5 text-xs text-[#5337c7] transition-colors hover:bg-[#f7f3ff] disabled:cursor-not-allowed disabled:opacity-60"
>
重新上传
</button>
......@@ -246,34 +246,34 @@ export function NewProject() {
) : (
<div className="flex flex-col items-center text-center">
<div className="mb-6 flex items-center justify-center">
<div className="-mr-2 rotate-[-14deg] rounded-lg border border-[#dfe6f3] bg-white p-2 text-[#a8b3c7] shadow-sm">
<div className="-mr-2 rotate-[-14deg] rounded-lg border border-[#e6eaed] bg-white p-2 text-[#a9a0bb] shadow-sm">
<FileText className="h-5 w-5" />
</div>
<div className="z-10 rounded-xl border border-[#dfe6f3] bg-white p-3 text-[#69758a] shadow-md">
<div className="z-10 rounded-xl border border-[#e6eaed] bg-white p-3 text-[#5b546e] shadow-md">
<BookOpen className="h-6 w-6" />
</div>
<div className="-ml-2 rotate-[14deg] rounded-lg border border-[#dfe6f3] bg-white p-2 text-[#a8b3c7] shadow-sm">
<div className="-ml-2 rotate-[14deg] rounded-lg border border-[#e6eaed] bg-white p-2 text-[#a9a0bb] shadow-sm">
<FileText className="h-5 w-5" />
</div>
</div>
<div className="text-sm text-[#8a94a6]">
<div className="text-sm text-[#7a748c]">
点击或拖拽剧本至此,支持 doc、docx、txt、pdf 和 md 格式,文件大小不超过 20M
</div>
</div>
)
) : (
<div className="flex flex-col items-center text-center">
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-[#dfe6f3] bg-white shadow-sm">
<Wand2 className="h-8 w-8 text-[#5b55ff]" />
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-[#e6eaed] bg-white shadow-sm">
<Wand2 className="h-8 w-8 text-[#5337c7]" />
</div>
<div className="text-sm font-medium text-[#111827]">人工模式将在下一页手动输入剧本和大纲</div>
<div className="mt-2 text-sm text-[#8a94a6]">这里不需要填写项目名称,也不需要上传图片。</div>
<div className="text-sm font-medium text-[#15131f]">人工模式将在下一页手动输入剧本和大纲</div>
<div className="mt-2 text-sm text-[#7a748c]">这里不需要填写项目名称,也不需要上传图片。</div>
</div>
)}
</label>
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="inline-flex w-fit rounded-lg border border-[#dbe3f0] bg-white p-0.5">
<div className="inline-flex w-fit rounded-lg border border-[#e6eaed] bg-white p-0.5">
<button
type="button"
onClick={() => {
......@@ -283,8 +283,8 @@ export function NewProject() {
disabled={isSubmitting}
className={`h-9 rounded-md px-4 text-sm transition-colors ${
mode === "agent"
? "bg-[#eef0ff] text-[#5b55ff]"
: "text-[#111827] hover:bg-[#f6f7fb]"
? "bg-[#f3eefe] text-[#5337c7]"
: "text-[#15131f] hover:bg-[#f7f5fb]"
} disabled:cursor-not-allowed disabled:opacity-60`}
>
Agent模式
......@@ -298,8 +298,8 @@ export function NewProject() {
disabled={isSubmitting}
className={`h-9 rounded-md px-4 text-sm transition-colors ${
mode === "manual"
? "bg-[#eef0ff] text-[#5b55ff]"
: "text-[#111827] hover:bg-[#f6f7fb]"
? "bg-[#f3eefe] text-[#5337c7]"
: "text-[#15131f] hover:bg-[#f7f5fb]"
} disabled:cursor-not-allowed disabled:opacity-60`}
>
人工模式
......@@ -308,12 +308,12 @@ export function NewProject() {
<div className="flex flex-wrap items-center gap-3">
<label className="relative">
<Monitor className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#445066]" />
<Monitor className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#5b546e]" />
<select
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value)}
disabled={isSubmitting}
className="h-10 min-w-[126px] appearance-none rounded-lg border border-[#dbe3f0] bg-white py-0 pl-9 pr-8 text-sm text-[#111827] outline-none transition-colors focus:border-[#7b8cff] disabled:cursor-not-allowed disabled:opacity-60"
className="h-10 min-w-[126px] appearance-none rounded-lg border border-[#e6eaed] bg-white py-0 pl-9 pr-8 text-sm text-[#15131f] outline-none transition-colors focus:border-[#8064f1] disabled:cursor-not-allowed disabled:opacity-60"
>
{aspectRatioOptions.map((option) => (
<option key={option.value} value={option.value}>
......@@ -327,7 +327,7 @@ export function NewProject() {
value={resolution}
onChange={(e) => setResolution(e.target.value)}
disabled={isSubmitting}
className="h-10 min-w-[112px] rounded-lg border border-[#dbe3f0] bg-white px-3 text-sm text-[#111827] outline-none transition-colors focus:border-[#7b8cff] disabled:cursor-not-allowed disabled:opacity-60"
className="h-10 min-w-[112px] rounded-lg border border-[#e6eaed] bg-white px-3 text-sm text-[#15131f] outline-none transition-colors focus:border-[#8064f1] disabled:cursor-not-allowed disabled:opacity-60"
>
{resolutionOptions.map((option) => (
<option key={option.value} value={option.value}>
......@@ -340,7 +340,7 @@ export function NewProject() {
value={style}
onChange={(e) => setStyle(e.target.value)}
disabled={isSubmitting}
className="h-10 min-w-[150px] rounded-lg border border-[#dbe3f0] bg-white px-3 text-sm text-[#111827] outline-none transition-colors focus:border-[#7b8cff] disabled:cursor-not-allowed disabled:opacity-60"
className="h-10 min-w-[150px] rounded-lg border border-[#e6eaed] bg-white px-3 text-sm text-[#15131f] outline-none transition-colors focus:border-[#8064f1] disabled:cursor-not-allowed disabled:opacity-60"
>
{projectStyleOptions.map((option) => (
<option key={option.value} value={option.value}>
......@@ -356,13 +356,13 @@ export function NewProject() {
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
className="mt-16 flex h-[52px] min-w-[250px] items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#1677ff] to-[#7c2dff] px-8 text-sm font-medium text-white shadow-[0_18px_38px_rgba(88,82,255,0.28)] transition-all hover:-translate-y-0.5 hover:shadow-[0_22px_46px_rgba(88,82,255,0.34)] disabled:cursor-not-allowed disabled:translate-y-0 disabled:opacity-60"
className="dramix-primary mt-16 flex h-[52px] min-w-[250px] items-center justify-center gap-2 rounded-xl px-8 text-sm font-medium transition-all hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:translate-y-0 disabled:opacity-60"
>
{isSubmitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
<span>{isSubmitting ? activeSubmitMeta?.title ?? "处理中..." : "创建短剧项目"}</span>
</button>
<div className="mt-8 flex items-center gap-2 text-sm text-[#9aa4b8]">
<div className="mt-8 flex items-center gap-2 text-sm text-[#8d86a2]">
<CheckCircle2 className="h-4 w-4" />
<span>平台内容均由人工智能模型生成,不代表平台立场</span>
</div>
......
......@@ -59,6 +59,17 @@ export function ProjectDetail() {
}
return (
<ProjectScriptView
project={project}
outline={outline}
episodes={episodes}
characters={characters}
projectId={projectId}
onNavigate={(path) => navigate(path)}
/>
);
return (
<div className="h-full overflow-auto bg-background relative">
{/* Header */}
<div className="border-b border-border bg-card px-6 py-3">
......@@ -169,6 +180,180 @@ export function ProjectDetail() {
);
}
function ProjectScriptView({
project,
outline,
episodes,
characters,
projectId,
onNavigate,
}: {
project: import("../../lib/api/projects").ProjectDTO | undefined;
outline: import("../../lib/api/ai").Outline | undefined;
episodes: import("../../lib/api/ai").Episode[];
characters: import("../../lib/api/ai").Character[];
projectId?: string;
onNavigate: (path: string) => void;
}) {
const orderedEpisodes = [...episodes].sort(
(a, b) => (a.episodeNumber || 0) - (b.episodeNumber || 0)
);
const episodeTotal = Math.max(
outline?.episodeCount ?? 0,
orderedEpisodes.length,
orderedEpisodes.length === 0 ? 60 : 0
);
const [selectedEpisodeNo, setSelectedEpisodeNo] = useState(1);
const selectedEpisode =
orderedEpisodes.find((ep) => ep.episodeNumber === selectedEpisodeNo) ?? null;
const selectedNo = selectedEpisodeNo;
const uniqueTags = Array.from(
new Set(
[outline?.genre, project?.style, "爽文", "高燃", "悬疑", "玄幻"].filter(Boolean) as string[]
)
).slice(0, 6);
const defaultSynopsis =
"一场意外让主角卷入仙门与妖族的暗潮之中。看似平凡的手艺成为破局关键,少年在一次次试炼里识破阴谋、守住同伴,也逐渐揭开自己身世与旧日神工之间的联系。";
const fallbackCharacters = [
{
id: "fallback-1",
name: "夏母",
roleType: "关键人物",
gender: "",
age: "中年",
personality: "温和坚韧,遇事有主见",
costume: "朴素家常服",
},
{
id: "fallback-2",
name: "萧医师",
roleType: "推动剧情",
gender: "",
age: "青年",
personality: "理性克制,暗藏锋芒",
costume: "青色长衫",
},
];
const displayCharacters = characters.length > 0 ? characters.slice(0, 4) : fallbackCharacters;
const nextPath = episodes.length === 0 ? `/project/${projectId}/outline` : `/project/${projectId}/characters`;
return (
<div className="relative h-full overflow-auto bg-[#f7f8fb] text-[#111827]">
<main className="mx-auto max-w-[860px] px-6 pb-32 pt-12">
<section className="flex flex-col gap-8 sm:flex-row sm:items-start">
<div className="h-[218px] w-[154px] flex-shrink-0 overflow-hidden rounded-[3px] bg-[#dfe3ea] shadow-[0_18px_48px_rgba(15,23,42,0.14)]">
{project?.coverUrl ? (
<img src={project.coverUrl} alt="项目封面" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full flex-col items-center justify-center bg-[linear-gradient(145deg,#262b36,#10131a)] text-white">
<Film className="mb-3 h-9 w-9 opacity-70" />
<span className="px-5 text-center text-sm font-semibold leading-5">
{project?.name ?? outline?.title ?? "剧本封面"}
</span>
</div>
)}
</div>
<div className="min-w-0 flex-1 pt-1">
<h1 className="mb-4 text-[28px] font-semibold leading-[1.22] tracking-normal text-[#0b1220]">
{outline?.title ?? project?.name ?? "未命名项目"}
</h1>
<div className="mb-5 grid grid-cols-2 gap-x-8 gap-y-2 text-[13px] leading-6 text-[#6b7280] sm:grid-cols-4">
<span>集数:{outline?.episodeCount ?? episodeTotal}</span>
<span>比例:{project?.aspectRatio ?? "16:9"}</span>
<span>清晰度:{project?.resolution ?? "720p"}</span>
<span>风格:{project?.style ?? outline?.genre ?? "待生成"}</span>
</div>
<div className="flex flex-wrap gap-2">
{uniqueTags.map((tag) => (
<span
key={tag}
className="rounded-full border border-[#e4e7ef] bg-white px-3 py-1 text-xs font-medium text-[#536079]"
>
{tag}
</span>
))}
</div>
</div>
</section>
<div className="my-10 h-px bg-[#e6e8ef]" />
<section className="mb-11">
<h2 className="mb-4 text-[17px] font-semibold text-[#0b1220]">大纲</h2>
<p className="text-[14px] leading-8 text-[#384152]">
{outline?.synopsis ?? project?.description ?? defaultSynopsis}
</p>
</section>
<section className="mb-12">
<h2 className="mb-5 text-[17px] font-semibold text-[#0b1220]">人物小传</h2>
<div className="grid gap-x-12 gap-y-6 md:grid-cols-2">
{displayCharacters.map((char) => (
<article key={char.id} className="text-[14px] leading-7 text-[#384152]">
<h3 className="mb-1 font-semibold text-[#111827]">{char.name}</h3>
<p>
{char.roleType ? `${char.roleType},` : ""}
{char.gender ? `${char.gender},` : ""}
{char.age ? `${char.age}。` : ""}
{char.personality || "性格鲜明,与主线冲突和情感推进紧密相关。"}
{char.costume ? ` 服饰:${char.costume}。` : ""}
</p>
</article>
))}
</div>
</section>
<section>
<h2 className="mb-5 text-[17px] font-semibold text-[#0b1220]">分集剧情</h2>
<div className="mb-7 grid grid-cols-8 gap-3 sm:grid-cols-10 md:grid-cols-12">
{Array.from({ length: episodeTotal }, (_, index) => index + 1).map((num) => (
<button
key={num}
type="button"
onClick={() => setSelectedEpisodeNo(num)}
className={`h-9 rounded-full text-sm font-medium transition-all ${
num === selectedNo
? "bg-[#111827] text-white shadow-[0_8px_18px_rgba(17,24,39,0.2)]"
: "bg-white text-[#4b5563] ring-1 ring-[#e3e6ee] hover:bg-[#f1f3f7]"
}`}
>
{num}
</button>
))}
</div>
<article className="text-[14px] leading-8 text-[#384152]">
<h3 className="mb-2 font-semibold text-[#111827]">
{selectedNo}{selectedEpisode?.title ? `:${selectedEpisode.title}` : ""}
</h3>
<p className="mb-4">
{selectedEpisode?.summary ??
"本集承接上一段危机,主角在新的线索中发现关键异常,并由此推动人物关系、外部冲突和最终选择进一步升级。"}
</p>
<p className="whitespace-pre-wrap">
{selectedEpisode?.script ??
"镜头从喧闹的街巷切入,主角察觉到细微异动。几句试探之后,暗处的矛盾浮出水面,众人的立场开始分化。主角在压力下做出决定,为下一集的分镜和视觉呈现留下清晰的动作线。"}
</p>
</article>
</section>
</main>
<div className="pointer-events-none fixed bottom-8 left-0 right-0 z-20 flex justify-center px-6">
<div className="pointer-events-auto flex w-full max-w-[520px] items-center justify-center">
<button
type="button"
onClick={() => onNavigate(nextPath)}
className="h-12 min-w-[260px] rounded-full bg-[#0b0f17] px-10 text-[15px] font-semibold text-white shadow-[0_18px_46px_rgba(17,24,39,0.3)] transition-transform hover:scale-[1.01] active:scale-[0.99]"
>
{episodes.length === 0 ? "生成分集" : "进入下一步"}
</button>
</div>
</div>
</div>
);
}
// ---- Outline Tab ----
function OutlineTab({
outline,
......
......@@ -28,15 +28,15 @@ import {
import type { Storyboard, Character, CharacterState, Scene, Episode, AiTask } from "../../lib/api/ai";
import { useOperationCost } from "../../hooks/useUsage";
const DURATION_OPTIONS = [5, 10, 15, 20, 30];
const DURATION_OPTIONS = [5, 10, 11, 15, 20, 30];
const RATIO_OPTIONS = [
{ value: "16:9", label: "16:9 横屏" },
{ value: "9:16", label: "9:16 竖屏" },
{ value: "1:1", label: "1:1 方形" },
];
const VIDEO_MODEL_OPTIONS = [
{ value: "doubao-seedance-2-0-fast-260128", label: "doubao-seedance-2-0-fast" },
{ value: "doubao-seedance-2-0-260128", label: "doubao-seedance-2-0" },
{ value: "doubao-seedance-2-0-fast-260128", label: "Doubao-Seedance-2-0" },
{ value: "doubao-seedance-2-0-260128", label: "Doubao-Seedance-2-0 Pro" },
];
const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_OPTIONS[0].value;
const MAX_CHARACTERS = 5;
......@@ -300,7 +300,7 @@ export function StoryboardWorkspace() {
const [styleKey] = useState<string>(""); // reserved; not exposed in this UI
const [shortDescription, setShortDescription] = useState<string>("");
const [freeformPrompt, setFreeformPrompt] = useState<string>("");
const [selectedDuration, setSelectedDuration] = useState(5);
const [selectedDuration, setSelectedDuration] = useState(11);
const [selectedRatio, setSelectedRatio] = useState<string>("16:9");
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_VIDEO_MODEL);
const [audioOn, setAudioOn] = useState(true);
......@@ -372,11 +372,11 @@ export function StoryboardWorkspace() {
// Auto-expand first storyboard on load
useEffect(() => {
if (storyboards.length > 0 && !expandedId) {
if (storyboards.length > 0 && (!expandedId || !storyboards.some((sb) => sb.id === expandedId))) {
setExpandedId(storyboards[0].id);
setActiveVideoSbId(storyboards[0].id);
}
}, [storyboards.length]);
}, [storyboards, expandedId]);
useEffect(() => {
if (!expanded) return;
......@@ -610,7 +610,8 @@ export function StoryboardWorkspace() {
() => storyboards.find((s) => s.id === activeVideoSbId) ?? expanded,
[storyboards, activeVideoSbId, expanded]
);
const previewTask = previewSb ? getTaskForSb(previewSb) : null;
const previewDisplaySb = previewSb ?? null;
const previewTask = previewDisplaySb ? getTaskForSb(previewDisplaySb) : null;
const promptPreviewText = freeformPrompt.trim();
const sceneRefDisplay = expanded
......@@ -630,13 +631,20 @@ export function StoryboardWorkspace() {
{ label: "镜头运动", value: motionScriptDisplay },
{ label: "视频提示词", value: videoPromptDisplay, multiline: true },
] : [];
const currentEpisode = episodes.find((ep) => ep.id === activeEpisodeId) ?? episodes[0] ?? null;
const currentEpisodeTitle = currentEpisode
? currentEpisode.title || `${currentEpisode.episodeNumber}`
: "暂无分集";
const currentEpisodeSummary = currentEpisode?.summary
|| "当前项目还没有可用分集。生成分集后,可以在这里继续生成分镜。";
const selectedScene = sceneKey ? sceneByKey.get(sceneKey) ?? null : null;
return (
<div className="h-full flex overflow-hidden bg-[#f4f7fb] text-foreground">
<div className="h-full flex overflow-hidden bg-[#f7f8fb] text-[#111827]">
{/* ─── Far Left: Episode list strip ─── */}
<div className="flex-shrink-0 border-r border-slate-200/80 bg-white/90 flex flex-col items-center overflow-hidden py-5 shadow-[1px_0_0_rgba(15,23,42,0.02)]" style={{ width: 80 }}>
<span className="font-semibold mb-5" style={{ color: "#7c8598", fontSize: 16, letterSpacing: 1 }}>
<div className="flex-shrink-0 border-r border-[#e5e8f0] bg-[#fbfcff] flex flex-col items-center overflow-hidden py-4" style={{ width: 56 }}>
<span className="mb-3 text-[13px] font-medium text-[#475467]">
集数
</span>
<div
......@@ -648,8 +656,8 @@ export function StoryboardWorkspace() {
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
) : episodes.length === 0 ? (
<div className="px-1 pt-4 text-center">
<span className="text-[12px] text-muted-foreground leading-tight">暂无集数</span>
<div className="px-2 pt-4 text-center text-[11px] leading-4 text-[#98a2b3]">
暂无分集
</div>
) : (
episodes.map((ep) => {
......@@ -663,14 +671,14 @@ export function StoryboardWorkspace() {
active ? "" : "hover:bg-[#f0f4fb] hover:text-[#475569] hover:shadow-sm"
}`}
style={{
width: 44,
height: 50,
borderRadius: 14,
fontSize: 16,
backgroundColor: active ? "#1c64ff" : "#ffffff",
width: 34,
height: 34,
borderRadius: 6,
fontSize: 14,
backgroundColor: active ? "#7657ff" : "#ffffff",
color: active ? "#ffffff" : "#8b95a7",
boxShadow: active ? "0 12px 24px rgba(28, 100, 255, 0.28)" : "0 1px 2px rgba(15, 23, 42, 0.05)",
border: active ? "1px solid rgba(28, 100, 255, 0.35)" : "1px solid rgba(148, 163, 184, 0.22)",
boxShadow: active ? "0 8px 18px rgba(118, 87, 255, 0.28)" : "0 1px 2px rgba(15, 23, 42, 0.05)",
border: active ? "1px solid rgba(118, 87, 255, 0.45)" : "1px solid rgba(148, 163, 184, 0.25)",
}}
>
{ep.episodeNumber}
......@@ -681,17 +689,63 @@ export function StoryboardWorkspace() {
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden px-5 pb-5 pt-4">
<div className="mb-3 flex items-start gap-4">
<button className="h-8 rounded-md border border-[#dfe3ee] bg-white px-4 text-sm font-medium text-[#111827] shadow-sm">
分镜表
</button>
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold text-[#111827]">{currentEpisodeTitle}</h2>
<p className="mt-1 line-clamp-1 text-xs leading-5 text-[#475467]">{currentEpisodeSummary}</p>
</div>
<button
onClick={handleGenerateAll}
disabled={generateSbs.isPending || !eid}
className="flex h-9 items-center gap-2 rounded-md border border-[#dde3ee] bg-white px-4 text-sm font-medium text-[#111827] shadow-sm transition hover:border-[#b9c3d6] disabled:opacity-50"
>
{generateSbs.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Layers className="h-4 w-4" />}
批量生成视频
</button>
</div>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-5 text-sm">
<button className="flex h-8 items-center gap-2 rounded-md px-2 text-[#111827] hover:bg-white">
<span className="text-[#98a2b3]">⋮⋮</span>
{expanded ? `分镜${expanded.sequenceNum}` : "暂无分镜"}
</button>
<span className="flex h-8 items-center gap-2 rounded-md border border-[#d7ceff] bg-[#f4f0ff] px-3 text-[#6147e8]">
<span className="h-2.5 w-2.5 rounded-full bg-[#7657ff]" />
全能参考生成视频
</span>
</div>
<div className="flex items-center gap-4 text-[#475467]">
<button onClick={handleCreate} disabled={createSb.isPending || !eid} className="hover:text-[#7657ff]" aria-label="新增分镜">
{createSb.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</button>
<button className="hover:text-[#7657ff]" aria-label="复制分镜">
<Layers className="h-4 w-4" />
</button>
<button onClick={() => expanded && handleDelete(expanded)} disabled={!expanded} className="hover:text-red-500 disabled:opacity-40" aria-label="删除分镜">
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-[#e4e8f0] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.04)]">
{/* ─── Column 2: Storyboard accordion list (top: prompt summary, then cards) ─── */}
<div className="flex flex-col flex-shrink-0 border-r border-slate-200/80 overflow-hidden bg-white/95" style={{ width: 380 }}>
<div className="flex flex-col flex-shrink-0 overflow-hidden rounded-lg border border-[#eef1f6] bg-[#fbfcff]" style={{ width: 394 }}>
{/* Top: 分镜表 / 备注描述 */}
<div className="px-4 py-3 border-b border-slate-200/80 bg-white/95 flex-shrink-0 relative shadow-[0_1px_0_rgba(15,23,42,0.03)]">
<span className="absolute left-0 top-3 bottom-3 w-[3px] bg-[#1c64ff]/55 rounded-r-full" />
<div className="px-3 py-3 border-b border-[#edf0f5] bg-white flex-shrink-0 relative">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] font-semibold tracking-[0.12em] uppercase text-muted-foreground">分镜备注</span>
<span className="text-sm font-semibold text-[#111827]">
{expanded ? `分镜信息${String(expanded.sequenceNum).padStart(3, "0")}` : "分镜信息"}
</span>
{(promptPreviewText || expanded) && (
<button
onClick={() => setShowPromptDetails(true)}
className="text-[10px] text-[#1c64ff] hover:text-[#164fd0] flex items-center gap-0.5 font-semibold transition-colors"
className="text-xs text-[#7657ff] hover:text-[#5e42df] flex items-center gap-0.5 font-medium transition-colors"
title="查看完整内容"
>
<Info className="w-3 h-3" />
......@@ -699,21 +753,18 @@ export function StoryboardWorkspace() {
</button>
)}
</div>
<p className={`text-[11px] leading-relaxed line-clamp-2 ${promptPreviewText ? "text-foreground/85" : "text-muted-foreground italic"}`}>
{promptPreviewText
? (promptPreviewText.length > PROMPT_PREVIEW_LIMIT
? promptPreviewText.slice(0, PROMPT_PREVIEW_LIMIT) + "…"
: promptPreviewText)
: "选中分镜后,这里会显示已填写的视频提示词内容"}
<div className="mb-2 text-xs font-medium text-[#344054]">分镜描述</div>
<p className={`min-h-[96px] rounded-md border border-[#d9dee8] bg-white px-3 py-2 text-xs leading-relaxed ${shortDescription ? "text-[#111827]" : "text-[#98a2b3]"}`}>
{shortDescription || "暂无分镜描述"}
</p>
</div>
{/* Action row: new + AI generate */}
<div className="h-12 px-3 border-b border-slate-200/80 flex items-center gap-2 flex-shrink-0 bg-white/95">
<div className="h-10 px-3 border-b border-[#edf0f5] flex items-center gap-2 flex-shrink-0 bg-white">
<button
onClick={handleCreate}
disabled={createSb.isPending || !eid}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg bg-[#1c64ff] text-white text-xs font-semibold tracking-wide shadow-[0_6px_14px_rgba(28,100,255,0.22)] transition-all duration-150 hover:bg-[#1857e8] hover:shadow-[0_8px_18px_rgba(28,100,255,0.28)] hover:-translate-y-px disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none"
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md border border-[#dde3ee] bg-white text-xs font-medium text-[#111827] transition hover:border-[#b9c3d6] disabled:opacity-50"
>
{createSb.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />}
新建分镜
......@@ -722,25 +773,29 @@ export function StoryboardWorkspace() {
onClick={handleGenerateAll}
disabled={generateSbs.isPending || !eid}
title="AI批量生成分镜"
className={`p-2 rounded-lg text-muted-foreground hover:bg-[#f6f9ff] hover:border-[#1c64ff]/30 hover:text-[#1c64ff] transition-colors disabled:opacity-50 ${CONTROL_SURFACE}`}
className={`p-2 rounded-md text-[#475467] hover:bg-[#f6f9ff] hover:border-[#7657ff]/30 hover:text-[#7657ff] transition-colors disabled:opacity-50 ${CONTROL_SURFACE}`}
>
{generateSbs.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />}
</button>
</div>
{/* Cards (accordion) */}
<div className="flex-1 overflow-y-auto px-2 py-2 bg-[#f7f9fd]">
<div className="flex-1 overflow-y-auto px-3 py-2 bg-[#fbfcff]">
{!eid ? (
<div className="px-3 py-8 text-center">
<p className="text-xs text-muted-foreground">请从左侧选择集数</p>
<div className="flex h-full min-h-[360px] flex-col items-center justify-center rounded-lg border border-dashed border-[#d9dee8] bg-white px-6 text-center">
<Layers className="mb-3 h-8 w-8 text-[#98a2b3]" />
<p className="text-sm font-medium text-[#111827]">暂无分集数据</p>
<p className="mt-1 text-xs leading-5 text-[#98a2b3]">请先生成或选择分集,再继续创建分镜。</p>
</div>
) : isLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : storyboards.length === 0 ? (
<div className="px-3 py-8 text-center">
<p className="text-xs text-muted-foreground">点击「AI生成」或「新建分镜」开始</p>
<div className="flex h-full min-h-[360px] flex-col items-center justify-center rounded-lg border border-dashed border-[#d9dee8] bg-white px-6 text-center">
<Layers className="mb-3 h-8 w-8 text-[#98a2b3]" />
<p className="text-sm font-medium text-[#111827]">暂无分镜数据</p>
<p className="mt-1 text-xs leading-5 text-[#98a2b3]">点击“新建分镜”手动添加,或使用 AI 批量生成。</p>
</div>
) : (
storyboards.map((sb) => (
......@@ -776,9 +831,9 @@ export function StoryboardWorkspace() {
</div>
{/* ─── Column 3: Video generation panel (5-section prompt + duration + button) ─── */}
<div className="flex flex-col flex-shrink-0 border-r border-slate-200/80 overflow-hidden bg-[#f7f9fd]" style={{ width: 340 }}>
<div className="h-14 px-4 border-b border-slate-200/80 flex items-center flex-shrink-0 bg-white/95 shadow-[0_1px_0_rgba(15,23,42,0.03)]">
<span className="text-[13px] font-semibold text-foreground tracking-tight">分镜视频生成</span>
<div className="ml-3 flex flex-col flex-shrink-0 overflow-hidden rounded-lg border border-[#eef1f6] bg-white" style={{ width: 407 }}>
<div className="h-12 px-4 flex items-center flex-shrink-0 bg-white">
<span className="text-sm font-semibold text-[#111827] tracking-tight">分镜视频生成</span>
{expanded && (
<span className="ml-2 text-[11px] text-muted-foreground tabular-nums">
· 分镜 {String(expanded.sequenceNum).padStart(3, "0")}
......@@ -788,12 +843,16 @@ export function StoryboardWorkspace() {
<div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
{!expanded ? (
<p className="text-xs text-muted-foreground italic mt-4">展开左侧分镜后填写提示词</p>
<div className="flex-1 min-h-[420px] rounded-md border border-dashed border-[#d9dee8] bg-white px-6 py-8 text-center flex flex-col items-center justify-center">
<Play className="mb-3 h-8 w-8 text-[#98a2b3]" />
<p className="text-sm font-medium text-[#111827]">暂无可生成的视频分镜</p>
<p className="mt-1 text-xs leading-5 text-[#98a2b3]">创建或选择分镜后,这里会显示视频生成提示词。</p>
</div>
) : (
<>
<div className="flex items-start gap-1.5 px-3 py-2.5 rounded-xl bg-white border border-[#1c64ff]/15 shadow-[0_4px_14px_rgba(28,100,255,0.06)]">
<Sparkles className="w-3.5 h-3.5 text-[#1c64ff] mt-0.5 flex-shrink-0" />
<p className="text-[11px] text-[#1c64ff] leading-relaxed">
<Plus className="w-3.5 h-3.5 text-[#98a2b3] mt-0.5 flex-shrink-0" />
<p className="text-[11px] text-[#98a2b3] leading-relaxed">
{referenceCount > 0
? `已预选 ${referenceCount} 个参考素材,可继续手工调整`
: "可手工选择参考素材;AI 批量生成会自动预选可用角色/场景"}
......@@ -803,13 +862,13 @@ export function StoryboardWorkspace() {
value={freeformPrompt}
onChange={(e) => setFreeformPrompt(e.target.value)}
placeholder={FREEFORM_PLACEHOLDER}
className={`flex-1 min-h-[280px] px-3 py-2.5 rounded-xl border border-slate-200/80 bg-white text-sm text-foreground leading-relaxed resize-none font-mono shadow-inner shadow-slate-100/80 ${FOCUS_RING}`}
className={`flex-1 min-h-[420px] px-3 py-2.5 rounded-md border border-[#d9dee8] bg-white text-sm text-[#111827] leading-relaxed resize-none shadow-inner shadow-slate-100/80 ${FOCUS_RING}`}
/>
</>
)}
</div>
<div className="px-3 py-3 border-t border-slate-200/80 flex-shrink-0 bg-white/95 space-y-2 shadow-[0_-1px_0_rgba(15,23,42,0.03)]">
<div className="px-3 py-3 flex-shrink-0 bg-white space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<select
......@@ -817,7 +876,7 @@ export function StoryboardWorkspace() {
onChange={(e) => setSelectedModel(e.target.value)}
disabled={!expanded}
title="视频生成模型"
className={`w-full appearance-none pl-3 pr-7 py-2 rounded-lg bg-[#f8fafc] text-xs text-foreground cursor-pointer disabled:opacity-50 transition-colors ${CONTROL_SURFACE} ${FOCUS_RING}`}
className={`w-full appearance-none pl-3 pr-7 py-2 rounded-md bg-white text-xs text-[#111827] cursor-pointer disabled:opacity-50 transition-colors ${CONTROL_SURFACE} ${FOCUS_RING}`}
>
{VIDEO_MODEL_OPTIONS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
......@@ -830,7 +889,7 @@ export function StoryboardWorkspace() {
value={selectedRatio}
onChange={(e) => setSelectedRatio(e.target.value)}
disabled={!expanded}
className={`w-full appearance-none pl-3 pr-7 py-2 rounded-lg bg-[#f8fafc] text-xs text-foreground cursor-pointer disabled:opacity-50 transition-colors ${CONTROL_SURFACE} ${FOCUS_RING}`}
className={`w-full appearance-none pl-3 pr-7 py-2 rounded-md bg-white text-xs text-[#111827] cursor-pointer disabled:opacity-50 transition-colors ${CONTROL_SURFACE} ${FOCUS_RING}`}
>
{RATIO_OPTIONS.map((r) => (
<option key={r.value} value={r.value}>{r.label}</option>
......@@ -843,7 +902,7 @@ export function StoryboardWorkspace() {
value={selectedDuration}
onChange={(e) => setSelectedDuration(Number(e.target.value))}
disabled={!expanded}
className={`appearance-none pl-2 pr-6 py-2 rounded-lg bg-[#f8fafc] text-xs text-foreground cursor-pointer w-16 disabled:opacity-50 transition-colors tabular-nums ${CONTROL_SURFACE} ${FOCUS_RING}`}
className={`appearance-none pl-2 pr-6 py-2 rounded-md bg-white text-xs text-[#111827] cursor-pointer w-16 disabled:opacity-50 transition-colors tabular-nums ${CONTROL_SURFACE} ${FOCUS_RING}`}
>
{DURATION_OPTIONS.map((d) => (
<option key={d} value={d}>{d}</option>
......@@ -869,7 +928,7 @@ export function StoryboardWorkspace() {
onClick={handleGenerate}
disabled={!canGenerate || generatingVideoId === expanded?.id}
title={!canGenerate ? "需要:至少 1 张参考图(角色/场景/道具任一)+ 提示词" : ""}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-[#1c64ff] text-white text-xs font-semibold tracking-wide shadow-[0_9px_20px_rgba(28,100,255,0.26)] transition-all duration-150 hover:bg-[#1857e8] hover:shadow-[0_12px_24px_rgba(28,100,255,0.32)] hover:-translate-y-px disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none whitespace-nowrap"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-md bg-[#7657ff] text-white text-xs font-semibold tracking-wide shadow-[0_9px_20px_rgba(118,87,255,0.24)] transition-all duration-150 hover:bg-[#6747f4] hover:-translate-y-px disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none whitespace-nowrap"
>
{generatingVideoId === expanded?.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
......@@ -882,13 +941,13 @@ export function StoryboardWorkspace() {
</div>
{/* ─── Column 4: Big video preview + thumbnail timeline ─── */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-white/90">
<div className="h-14 px-5 border-b border-slate-200/80 flex items-center justify-between flex-shrink-0 bg-white/95 shadow-[0_1px_0_rgba(15,23,42,0.03)]">
<div className="ml-3 flex-1 flex flex-col overflow-hidden min-w-0 rounded-lg bg-white">
<div className="h-12 px-5 flex items-center justify-between flex-shrink-0 bg-white">
<span className="text-[13px] font-semibold text-foreground tracking-tight">
视频预览
{previewSb && (
{previewDisplaySb && (
<span className="ml-2 text-[11px] font-normal text-muted-foreground tabular-nums">
· 分镜 {String(previewSb.sequenceNum).padStart(3, "0")}
· 分镜信息{String(previewDisplaySb.sequenceNum).padStart(3, "0")}
</span>
)}
</span>
......@@ -931,20 +990,24 @@ export function StoryboardWorkspace() {
</div>
</div>
<div className="flex-1 overflow-y-auto p-5 min-h-0 bg-[#f4f7fb]">
{!previewSb ? (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-muted-foreground">展开任意分镜后查看视频</p>
<div className="flex-1 min-h-0 overflow-hidden bg-white px-5 pb-4 pt-2">
{!previewDisplaySb ? (
<div className="h-full flex flex-col items-center justify-center gap-4">
<div className="flex w-full max-w-[920px] aspect-video flex-col items-center justify-center rounded-lg border border-dashed border-[#d9dee8] bg-[#fbfcff]">
<ImageIcon className="mb-3 h-9 w-9 text-[#98a2b3]" />
<p className="text-sm font-medium text-[#111827]">暂无视频预览</p>
<p className="mt-1 text-xs text-[#98a2b3]">生成分镜视频后会在这里播放。</p>
</div>
</div>
) : previewTask?.status === "succeeded" && previewTask.resultVideoUrl ? (
<div className="space-y-3">
<div className="rounded-2xl overflow-hidden bg-black aspect-video shadow-[0_18px_45px_rgba(15,23,42,0.18)] ring-1 ring-black/10">
<div className="flex h-full flex-col justify-center gap-3">
<div className="overflow-hidden rounded-lg bg-black shadow-[0_18px_45px_rgba(15,23,42,0.16)] ring-1 ring-black/10">
<video
key={previewTask.resultVideoUrl}
src={previewTask.resultVideoUrl}
controls
autoPlay={false}
className="w-full h-full object-contain"
className="aspect-video w-full object-contain"
/>
</div>
<a
......@@ -976,10 +1039,27 @@ export function StoryboardWorkspace() {
</div>
) : (
<div className="h-full flex flex-col items-center justify-center gap-4">
<div className="w-full max-w-sm aspect-video rounded-2xl border-[1.5px] border-dashed border-slate-300/80 bg-white/90 shadow-[0_10px_30px_rgba(15,23,42,0.05)] flex flex-col items-center justify-center gap-3">
<Play className="w-12 h-12 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">尚未生成视频</p>
<p className="text-xs text-muted-foreground/70">在左侧填好素材+提示词后生成</p>
<div className="relative w-full max-w-[920px] aspect-video overflow-hidden rounded-lg bg-[#101319] shadow-[0_18px_45px_rgba(15,23,42,0.16)]">
{selectedScene?.imageUrl ? (
<img src={selectedScene.imageUrl} alt={selectedScene.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full flex-col items-center justify-center bg-[#fbfcff]">
<ImageIcon className="mb-3 h-9 w-9 text-[#98a2b3]" />
<p className="text-sm font-medium text-[#111827]">暂无生成视频</p>
<p className="mt-1 text-xs text-[#98a2b3]">点击生成视频后会显示结果。</p>
</div>
)}
{selectedScene?.imageUrl && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 to-transparent px-4 pb-3 pt-14">
<div className="flex items-center justify-between text-white">
<div className="flex items-center gap-3 text-xs font-mono">
<Play className="h-4 w-4 fill-white" />
<span>0:00 / 0:{String(selectedDuration).padStart(2, "0")}</span>
</div>
<span className="text-[11px] text-white/75">等待生成</span>
</div>
</div>
)}
</div>
</div>
)}
......@@ -987,11 +1067,11 @@ export function StoryboardWorkspace() {
{/* Thumbnail timeline */}
{storyboards.length > 0 && (
<div className="border-t border-slate-200/80 bg-white/95 flex-shrink-0 shadow-[0_-1px_0_rgba(15,23,42,0.03)]">
<div className="px-3 py-3 flex items-center gap-2 overflow-x-auto">
<div className="bg-white flex-shrink-0">
<div className="flex items-center justify-start gap-3 overflow-x-auto px-3 py-3">
{storyboards.map((sb) => {
const t = getTaskForSb(sb);
const isActive = activeVideoSbId === sb.id;
const isActive = activeVideoSbId === sb.id || (!activeVideoSbId && previewDisplaySb?.id === sb.id);
const url = t?.resultVideoUrl;
return (
<button
......@@ -1001,10 +1081,10 @@ export function StoryboardWorkspace() {
setExpandedId(sb.id);
}}
title={`分镜 ${String(sb.sequenceNum).padStart(3, "0")}`}
className={`relative flex-shrink-0 w-[88px] h-[52px] rounded-lg overflow-hidden border transition-all duration-150 ${
className={`relative flex-shrink-0 w-[70px] h-[40px] rounded-md overflow-hidden border transition-all duration-150 ${
isActive
? "border-[#1c64ff] ring-2 ring-[#1c64ff]/25 shadow-[0_8px_18px_rgba(28,100,255,0.22)]"
: "border-slate-200 hover:border-[#1c64ff]/40 hover:-translate-y-px hover:shadow-sm"
? "border-[#2f80ff] ring-2 ring-[#2f80ff]/25 shadow-[0_8px_18px_rgba(47,128,255,0.20)]"
: "border-slate-200 hover:border-[#2f80ff]/40 hover:-translate-y-px hover:shadow-sm"
}`}
>
{url ? (
......@@ -1030,6 +1110,8 @@ export function StoryboardWorkspace() {
</div>
)}
</div>
</div>
</div>
{/* ─── Modals ─── */}
{showCharacterPicker && expanded && (
......@@ -1117,23 +1199,23 @@ function StoryboardCard(props: StoryboardCardProps) {
const [descCollapsed, setDescCollapsed] = useState(false);
return (
<div className={`my-2 rounded-2xl border transition-all duration-200 ${
<div className={`my-2 rounded-lg border transition-all duration-200 ${
isExpanded
? "border-[#1c64ff]/35 bg-white shadow-[0_12px_30px_rgba(15,23,42,0.08)]"
: "border-slate-200/80 bg-white/80 hover:bg-white hover:border-[#1c64ff]/25 hover:shadow-[0_8px_18px_rgba(15,23,42,0.05)]"
? "border-[#e4e8f0] bg-white shadow-sm"
: "border-[#edf0f5] bg-white hover:bg-white hover:border-[#cfd6e4]"
}`}>
<button
onClick={onToggle}
className="w-full px-3.5 pt-3 pb-2.5 flex items-center gap-2 text-left rounded-t-2xl"
className="w-full px-3 pt-3 pb-2.5 flex items-center gap-2 text-left rounded-t-lg"
>
<span className="text-sm font-semibold text-foreground flex-1 truncate">
分镜信息{String(sb.sequenceNum).padStart(3, "0")}
</span>
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ring-4 ring-white ${dotClass}`} />
<span className={`w-2 h-2 rounded-full flex-shrink-0 ring-4 ring-white ${dotClass}`} />
</button>
{isExpanded && (
<div className="px-3.5 pb-3.5 space-y-3.5 border-t border-slate-100">
<div className="px-3 pb-3 space-y-3 border-t border-[#f1f3f7]">
{/* 分镜描述 */}
<div>
<button
......@@ -1151,7 +1233,7 @@ function StoryboardCard(props: StoryboardCardProps) {
value={props.shortDescription}
onChange={(e) => props.onShortDescriptionChange(e.target.value)}
placeholder="简要描述此分镜的剧情或画面"
className={`w-full px-3 py-2 rounded-xl border border-slate-200/80 bg-[#f8fafc] text-xs text-foreground leading-relaxed resize-none shadow-inner shadow-slate-100/80 ${FOCUS_RING}`}
className={`w-full px-3 py-2 rounded-md border border-[#d9dee8] bg-white text-xs text-[#111827] leading-relaxed resize-none shadow-inner shadow-slate-100/80 ${FOCUS_RING}`}
/>
)}
</div>
......@@ -1214,7 +1296,7 @@ function CharacterRowList({
characterKeys, keyToCharacterRef, characterRefOptions, onRemove, onSwap, onAdd,
}: CharacterRowListProps) {
return (
<div className="rounded-xl bg-[#f8fafc] p-3 ring-1 ring-slate-200/70">
<div className="rounded-md bg-white p-0">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-foreground">出镜角色</span>
<button
......@@ -1281,8 +1363,8 @@ function CharacterRow({
const options = allRefOptions.filter((option) => option.key === tosKey || !currentKeys.includes(option.key));
return (
<div className="flex items-center gap-1.5 rounded-xl bg-white p-1.5 ring-1 ring-slate-200/70">
<div className="w-8 h-8 rounded-lg overflow-hidden border border-slate-200 bg-slate-100 flex-shrink-0">
<div className="flex items-center gap-1.5 rounded-md bg-white py-1">
<div className="w-10 h-10 rounded-md overflow-hidden border border-slate-200 bg-slate-100 flex-shrink-0">
{url ? (
<img src={url} alt={refOption?.name ?? ""} className="w-full h-full object-contain bg-white" />
) : (
......@@ -1293,7 +1375,7 @@ function CharacterRow({
<select
value={tosKey}
onChange={(e) => onSwap(e.target.value)}
className={`w-full appearance-none pl-2 pr-6 py-1.5 rounded-lg border border-slate-200/80 bg-white text-xs text-foreground cursor-pointer truncate ${FOCUS_RING}`}
className={`w-full appearance-none pl-2 pr-6 py-1.5 rounded-md border border-[#dfe3ee] bg-white text-xs text-[#111827] cursor-pointer truncate ${FOCUS_RING}`}
>
{!refOption && <option value={tosKey}>(已失效)</option>}
{options.map((option) => (
......@@ -1307,7 +1389,7 @@ function CharacterRow({
<div className="relative flex-1 min-w-0">
<select
disabled
className="w-full appearance-none pl-2 pr-6 py-1.5 rounded-lg border border-slate-200/80 bg-slate-100/80 text-xs text-muted-foreground cursor-not-allowed truncate"
className="w-full appearance-none pl-2 pr-6 py-1.5 rounded-md border border-[#dfe3ee] bg-white text-xs text-[#111827] cursor-not-allowed truncate"
>
<option>未配置音色</option>
</select>
......@@ -1315,14 +1397,14 @@ function CharacterRow({
</div>
<button
onClick={onRemove}
className="px-2 py-1.5 rounded-lg border border-slate-200/80 text-[11px] text-muted-foreground hover:bg-red-50 hover:text-red-600 transition-colors"
className="px-2 py-1.5 rounded-md border border-[#dfe3ee] text-[11px] text-[#475467] hover:bg-red-50 hover:text-red-600 transition-colors"
>
删除
</button>
<button
onClick={handleRegen}
disabled={!refOption || isRegenerating}
className="px-2 py-1.5 rounded-lg border border-slate-200/80 text-[11px] text-muted-foreground hover:bg-[#f6f9ff] hover:text-[#1c64ff] disabled:opacity-40 flex items-center gap-1 transition-colors"
className="px-2 py-1.5 rounded-md border border-[#dfe3ee] text-[11px] text-[#475467] hover:bg-[#f6f9ff] hover:text-[#7657ff] disabled:opacity-40 flex items-center gap-1 transition-colors"
>
{isRegenerating && <Loader2 className="w-3 h-3 animate-spin" />}
重绘
......@@ -1346,7 +1428,7 @@ function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) {
const selected = usable.find((s) => s.imageTosKey === selectedKey) ?? null;
return (
<div className="rounded-xl bg-[#f8fafc] p-3 ring-1 ring-slate-200/70">
<div className="rounded-md bg-white p-0">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-foreground">分镜场景</span>
{usable.length > 0 && (
......@@ -1365,7 +1447,7 @@ function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) {
type="button"
onClick={() => usable.length > 0 && setShowPicker((v) => !v)}
disabled={usable.length === 0}
className="w-full aspect-video rounded-xl overflow-hidden border border-slate-200/80 bg-slate-100 mb-2 block hover:border-[#1c64ff]/40 hover:shadow-sm transition disabled:cursor-not-allowed"
className="w-full aspect-video rounded-md overflow-hidden border border-[#d9dee8] bg-slate-100 mb-2 block hover:border-[#7657ff]/40 hover:shadow-sm transition disabled:cursor-not-allowed"
>
{selected?.imageUrl ? (
<img src={selected.imageUrl} alt={selected.name} className="w-full h-full object-cover" />
......@@ -1381,17 +1463,17 @@ function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) {
{/* Bottom row: scene name + role label + actions menu */}
<div className="flex items-center gap-1.5">
<div className="flex-1 min-w-0 px-2.5 py-1.5 rounded-lg border border-slate-200/80 bg-white text-xs text-foreground truncate">
<div className="flex-1 min-w-0 px-2.5 py-1.5 rounded-md border border-[#dfe3ee] bg-white text-xs text-[#111827] truncate">
{selected?.name || <span className="text-muted-foreground">请选择场景</span>}
</div>
<div className="px-2.5 py-1.5 rounded-lg border border-slate-200/80 bg-white/80 text-xs text-muted-foreground flex-shrink-0">
<div className="px-2.5 py-1.5 rounded-md border border-[#dfe3ee] bg-white/80 text-xs text-muted-foreground flex-shrink-0">
主场景图
</div>
<div className="relative flex-shrink-0">
<button
onClick={() => setMenuOpen((v) => !v)}
disabled={!selected}
className="p-1.5 rounded-lg border border-slate-200/80 bg-white text-muted-foreground hover:bg-[#f6f9ff] hover:text-[#1c64ff] disabled:opacity-40 transition-colors"
className="p-1.5 rounded-md border border-[#dfe3ee] bg-white text-muted-foreground hover:bg-[#f6f9ff] hover:text-[#7657ff] disabled:opacity-40 transition-colors"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
......@@ -1483,7 +1565,7 @@ function PropGrid({ scenes, sceneKey, selectedKeys, onToggle }: PropGridProps) {
const canAddMore = selectedKeys.length < MAX_PROPS;
return (
<div className="rounded-xl bg-[#f8fafc] p-3 ring-1 ring-slate-200/70">
<div className="rounded-md bg-white p-0">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-foreground">场景道具</span>
<button
......@@ -1505,7 +1587,7 @@ function PropGrid({ scenes, sceneKey, selectedKeys, onToggle }: PropGridProps) {
key={s.id}
onClick={() => onToggle(s.imageTosKey!)}
title={`移除 ${s.name}`}
className="relative aspect-square rounded-xl overflow-hidden border border-slate-200/80 bg-slate-100 group hover:shadow-sm transition-shadow"
className="relative aspect-square rounded-md overflow-hidden border border-[#dfe3ee] bg-slate-100 group hover:shadow-sm transition-shadow"
>
{s.imageUrl ? (
<img src={s.imageUrl} alt={s.name} className="w-full h-full object-cover" />
......@@ -1524,7 +1606,7 @@ function PropGrid({ scenes, sceneKey, selectedKeys, onToggle }: PropGridProps) {
)}
{showPicker && (
<div className="rounded-xl border border-slate-200/80 bg-white/80 p-2">
<div className="rounded-md border border-[#dfe3ee] bg-white/80 p-2">
{available.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic text-center py-2">
{allCandidates.length === 0 ? "先到「场景」页创建场景" : "已添加全部可选道具"}
......@@ -1540,7 +1622,7 @@ function PropGrid({ scenes, sceneKey, selectedKeys, onToggle }: PropGridProps) {
}}
disabled={!canAddMore}
title={s.name}
className="relative aspect-square rounded-lg overflow-hidden border border-slate-200 bg-white hover:border-[#1c64ff]/40 hover:shadow-sm disabled:opacity-40 transition"
className="relative aspect-square rounded-md overflow-hidden border border-slate-200 bg-white hover:border-[#7657ff]/40 hover:shadow-sm disabled:opacity-40 transition"
>
{s.imageUrl ? (
<img src={s.imageUrl} alt={s.name} className="w-full h-full object-cover" />
......
......@@ -3,42 +3,42 @@
:root {
--font-size: 14px;
--background: #f8f9fa;
--foreground: #1a1a1a;
--foreground: #15131f;
--card: #ffffff;
--card-foreground: #1a1a1a;
--card-foreground: #15131f;
--popover: #ffffff;
--popover-foreground: #1a1a1a;
--primary: #5b5ff9;
--popover-foreground: #15131f;
--primary: #5337c7;
--primary-foreground: #ffffff;
--secondary: #f1f3f5;
--secondary-foreground: #1a1a1a;
--secondary: #f3eefe;
--secondary-foreground: #262238;
--muted: #f8f9fa;
--muted-foreground: #6c757d;
--accent: #e7f5ff;
--accent-foreground: #1971c2;
--muted-foreground: #4b5864;
--accent: #f3eefe;
--accent-foreground: #5337c7;
--destructive: #f03e3e;
--destructive-foreground: #ffffff;
--border: rgba(0, 0, 0, 0.06);
--input: rgba(0, 0, 0, 0.06);
--border: #e6eaed;
--input: #d8dae9;
--input-background: #ffffff;
--switch-background: #dee2e6;
--font-weight-medium: 500;
--font-weight-normal: 400;
--ring: #5b5ff9;
--chart-1: #5b5ff9;
--chart-2: #22b8cf;
--chart-3: #51cf66;
--chart-4: #ff6b6b;
--chart-5: #fcc419;
--radius: 0.75rem;
--ring: #5337c7;
--chart-1: #5337c7;
--chart-2: #36bffa;
--chart-3: #12b76a;
--chart-4: #f04438;
--chart-5: #fdb022;
--radius: 0.625rem;
--sidebar: #ffffff;
--sidebar-foreground: #1a1a1a;
--sidebar-primary: #5b5ff9;
--sidebar-foreground: #15131f;
--sidebar-primary: #5337c7;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f8f9fa;
--sidebar-accent-foreground: #1a1a1a;
--sidebar-border: rgba(0, 0, 0, 0.06);
--sidebar-ring: #5b5ff9;
--sidebar-accent: #f3eefe;
--sidebar-accent-foreground: #5337c7;
--sidebar-border: #e6eaed;
--sidebar-ring: #5337c7;
}
.dark {
......@@ -188,3 +188,26 @@
line-height: 1.5;
}
}
@layer components {
.dramix-surface {
background: #f8f9fa;
}
.dramix-panel {
border: 1px solid #e6eaed;
background: #ffffff;
box-shadow: 0 10px 30px rgba(37, 31, 63, 0.05);
}
.dramix-primary {
background: #8064f1;
color: #ffffff;
box-shadow: 0 10px 22px rgba(83, 55, 199, 0.18);
}
.dramix-primary:hover {
background: #7357e8;
box-shadow: 0 14px 28px rgba(83, 55, 199, 0.24);
}
}
# doc/html Figma 对齐交接记录
日期:2026-05-25
## 项目范围
- 前端项目:`doc/html`
- Figma 文件:`https://www.figma.com/design/sRNoRpGZrFlmC2Du8SOIXw/AI%E6%BC%AB%E5%89%A7%E5%B9%B3%E5%8F%B0`
- 主要目标:把 `doc/html` 的 AI 漫剧工作流页面尽量对齐 Figma 设计稿。
- 本地后端接口可用时,前端 `.env.local` 指向 `http://localhost:8080`
## Figma 覆盖的主要页面
Figma MCP 已确认主工作流 section 内包含:
- `首页`
- `剧本1``剧本2``剧本3``剧本4`
- `角色库`
- `新增角色`
- `场景库`
- `场景库-空`
- `道具库`
- `分镜1``分镜2``分镜3``分镜4``分镜5`
- `视频`
- `余量统计`
本地导入参考图位于:
- `doc/html/src/imports/1项目入口.png`
- `doc/html/src/imports/2选择风格.png`
- `doc/html/src/imports/3生成大纲.png`
- `doc/html/src/imports/3生成大纲2.png`
- `doc/html/src/imports/4生成分集.png`
- `doc/html/src/imports/5生成角色.png`
- `doc/html/src/imports/6生成场景.png`
- `doc/html/src/imports/7生成道具.png`
- `doc/html/src/imports/8生成每集分镜.png`
- `doc/html/src/imports/10生成视频.png`
## 已完成或已接近 Figma 的页面
### Layout
文件:`doc/html/src/app/components/Layout.tsx`
- 顶部品牌改为 `Dramix.ai`
- 项目页工作流导航已改成 Figma 风格:`剧本 / 设定 / 分镜 / 视频`
- 右侧加入类似 Figma 的优化建议和余额区域。
### 项目入口 / 新建项目
文件:`doc/html/src/app/pages/NewProject.tsx`
- 已接近 Figma 的项目入口方向。
- 上传剧本区域、创建中遮罩、模式切换已有较完整结构。
### 剧本展示页
文件:`doc/html/src/app/pages/ProjectDetail.tsx`
- `/project/:projectId` 已改成接近 `3生成大纲.png``4生成分集.png` 的阅读式页面。
- 结构包括:封面、标题信息、标签、大纲、人物小传、分集剧情按钮网格、底部黑色 CTA。
- 分集按钮可切换显示当前集内容。
### 分镜工作台
文件:`doc/html/src/app/pages/StoryboardWorkspace.tsx`
- 已按 Figma `分镜1-5` 做过主结构改造。
- 已修复视频预览区第一条分镜 `001` 横向滚动看不到的问题。
- 已取消无数据时的“垫图/假分镜”:
- 没有分集时显示“暂无分集”
- 没有分镜时显示“暂无分镜数据”
- 视频生成区没有真实分镜时显示空状态
- 视频预览没有真实视频时显示空状态
- 缩略图只在真实 `storyboards` 存在时显示
### 角色编辑弹窗
文件:`doc/html/src/app/pages/CharacterGeneration.tsx`
- 角色图片已从正面/侧面/背面三个上传框改成单张角色图。
- 编辑弹窗新增 `角色图片提示词`
- 提示词支持中文显示和一键生成中文提示词。
- 保存仍写回后端 `imagePrompt` 字段。
- 上传单张图时仍走现有 `front` 兼容接口,后端会同步主图字段。
## 还没优化到位的页面
### 选择风格页
文件:`doc/html/src/app/pages/StyleSelection.tsx`
- 对应 `2选择风格.png`
- 当前仍是旧的大卡片网格 + 紫色渐变按钮。
- 需要对齐 Figma 的工作流页头和轻量选择布局。
### 大纲生成过程页
文件:`doc/html/src/app/pages/OutlineGeneration.tsx`
- 对应 Figma `剧本1 / 剧本2` 的生成过程。
- 当前是功能型处理页,有大 loading 卡、进度卡、手动编辑区。
- 视觉还未完全贴 Figma。
### 分集独立页
文件:`doc/html/src/app/pages/EpisodeGeneration.tsx`
- 对应 `剧本4 / 生成分集`
- `/project/:projectId` 已经做了 Figma 风格分集展示,但这个独立路由仍是旧列表页。
### 角色库列表页
文件:`doc/html/src/app/pages/CharacterGeneration.tsx`
- 只优化了编辑弹窗。
- 角色列表本体仍有旧统计条、旧卡片和旧 tab。
- 需要对齐 Figma `角色库``新增角色`
### 场景库页
文件:`doc/html/src/app/pages/SceneGeneration.tsx`
- 对应 Figma 多个 `场景库 / 场景库-空` 节点。
- 当前仍是旧的“项目设定 + 统计条 + 三列卡片”结构。
- 这是下一轮建议最高优先级页面。
### 道具库页
文件:`doc/html/src/app/pages/PropsGeneration.tsx`
- 对应 `道具库`
- 当前仍是旧资产库风格,并且接的是 global assets。
- 需要按 Figma 道具库重新组织布局。
### 视频页
文件:`doc/html/src/app/pages/VideoGeneration.tsx`
- 对应 `10生成视频.png` / Figma `视频`
- 当前还是旧的左选集 + 播放器 + 分镜列表结构。
- 需要整体对齐 Figma 视频管理/预览页。
## 推荐下一步顺序
1. 优先优化 `SceneGeneration.tsx`
2. 优化 `CharacterGeneration.tsx` 的角色列表主体
3. 优化 `VideoGeneration.tsx`
4. 优化 `StyleSelection.tsx``OutlineGeneration.tsx`
5. 优化 `PropsGeneration.tsx`
6. 最后处理 `EpisodeGeneration.tsx`,或考虑让它跳转/复用 `ProjectDetail.tsx` 的分集区域
## 注意事项
- 不要回滚这些已有改动:
- `doc/html/src/app/components/Layout.tsx`
- `doc/html/src/app/pages/ProjectDetail.tsx`
- `doc/html/src/app/pages/StoryboardWorkspace.tsx`
- `doc/html/src/app/pages/CharacterGeneration.tsx`
- `doc/html/src/app/pages/NewProject.tsx`
- `doc/html/src/app/pages/Dashboard.tsx`
- `doc/html/src/styles/theme.css`
- 当前工作区里有用户已有修改,继续开发前应先看 `git status --short`
- 验证命令:在 `doc/html` 下执行 `npm run build`
- 不要把账号密码写进仓库文件。需要登录时由用户重新提供,或使用当前会话上下文。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment