Commit 175f406c authored by yaoke.yk's avatar yaoke.yk

项目编辑风格和三视图

parent 1e9c434e
import { useState, useRef } from "react";
import { useRef, useState, type ChangeEvent } from "react";
import { useNavigate, useParams } from "react-router";
import { ArrowRight, Sparkles, Plus, Edit2, Trash2, X, Check, Users, MapPin, Box, RefreshCw, ImageIcon, Loader2, Upload } from "lucide-react";
import { useCharacters, useExtractCharacters, useGenerateCharacterImage, useDeleteCharacter, useSaveCharacter, useUploadCharacterImage } from "../../hooks/useAi";
import {
ArrowRight,
Check,
Edit2,
ImageIcon,
Loader2,
MapPin,
Plus,
RefreshCw,
Sparkles,
Trash2,
Upload,
Users,
X,
Box,
} from "lucide-react";
import {
useCharacters,
useDeleteCharacter,
useExtractCharacters,
useGenerateCharacterImage,
useSaveCharacter,
useUploadCharacterImage,
} from "../../hooks/useAi";
import type { Character } from "../../lib/api/ai";
type SettingsTab = "characters" | "scenes" | "props";
type CharacterViewType = "front" | "side" | "back";
type ImageState = Record<CharacterViewType, string | null>;
type PendingFiles = Record<CharacterViewType, File | null>;
const ROLE_TYPES = [
{ value: "female_lead", label: "女主角" },
......@@ -13,10 +38,48 @@ const ROLE_TYPES = [
{ value: "supporting", label: "配角" },
];
const CHARACTER_VIEWS: Array<{ type: CharacterViewType; label: string; shortLabel: string }> = [
{ type: "front", label: "正面", shortLabel: "正" },
{ type: "side", label: "侧面", shortLabel: "侧" },
{ type: "back", label: "背面", shortLabel: "背" },
];
const emptyForm = (): Partial<Character> => ({
name: "", roleType: "supporting", gender: "female", age: "", personality: "", costume: "", visualHint: "",
name: "",
roleType: "supporting",
gender: "female",
age: "",
personality: "",
costume: "",
visualHint: "",
});
const emptyImageState = (): ImageState => ({
front: null,
side: null,
back: null,
});
const emptyPendingFiles = (): PendingFiles => ({
front: null,
side: null,
back: null,
});
function getCharacterViewUrl(character: Partial<Character>, viewType: CharacterViewType) {
if (viewType === "front") {
return character.frontImageUrl ?? character.imageUrl ?? null;
}
if (viewType === "side") {
return character.sideImageUrl ?? null;
}
return character.backImageUrl ?? null;
}
function hasCompleteThreeViews(character: Partial<Character>) {
return CHARACTER_VIEWS.every((view) => !!getCharacterViewUrl(character, view.type));
}
export function CharacterGeneration() {
const navigate = useNavigate();
const { projectId } = useParams();
......@@ -31,14 +94,17 @@ export function CharacterGeneration() {
const [activeTab] = useState<SettingsTab>("characters");
const [generatingImageId, setGeneratingImageId] = useState<string | null>(null);
// modal state
const [showModal, setShowModal] = useState(false);
const [editingChar, setEditingChar] = useState<Partial<Character>>(emptyForm());
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [imagePreviews, setImagePreviews] = useState<ImageState>(emptyImageState());
const [pendingFiles, setPendingFiles] = useState<PendingFiles>(emptyPendingFiles());
const [saving, setSaving] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const fileInputRefs = useRef<Record<CharacterViewType, HTMLInputElement | null>>({
front: null,
side: null,
back: null,
});
const settingsTabs = [
{ id: "characters" as SettingsTab, label: "角色", icon: Users, path: `/project/${projectId}/characters` },
......@@ -46,77 +112,116 @@ 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 generatedCount = characters.filter((character) => hasCompleteThreeViews(character)).length;
const generatingCount = characters.filter((character) => character.status === "generating").length;
const statusBadge = (status: string) => {
if (status === "ready") {
return <span className="px-2 py-0.5 rounded text-xs bg-green-500/10 text-green-600">三视图已完成</span>;
}
if (status === "generating") {
return (
<span className="px-2 py-0.5 rounded text-xs bg-blue-500/10 text-blue-600 flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
生成中
</span>
);
}
if (status === "failed") {
return <span className="px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-600">生成失败</span>;
}
return <span className="px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-700">待生成</span>;
};
const handleExtract = () => {
extract.mutate(undefined, { onError: (e) => alert("提取失败: " + (e as Error).message) });
extract.mutate(undefined, {
onError: (error) => alert(`提取失败: ${(error as Error).message}`),
});
};
const openCreate = () => {
setEditingChar(emptyForm());
setImagePreview(null);
setPendingFile(null);
setImagePreviews(emptyImageState());
setPendingFiles(emptyPendingFiles());
setShowModal(true);
};
const openEdit = (char: Character) => {
setEditingChar({ ...char });
setImagePreview(char.imageUrl ?? null);
setPendingFile(null);
const openEdit = (character: Character) => {
setEditingChar({ ...character });
setImagePreviews({
front: getCharacterViewUrl(character, "front"),
side: getCharacterViewUrl(character, "side"),
back: getCharacterViewUrl(character, "back"),
});
setPendingFiles(emptyPendingFiles());
setShowModal(true);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
setPendingFile(f);
setImagePreview(URL.createObjectURL(f));
const handleFileSelect = (viewType: CharacterViewType, e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setPendingFiles((current) => ({ ...current, [viewType]: file }));
setImagePreviews((current) => ({ ...current, [viewType]: URL.createObjectURL(file) }));
};
const clearImage = (viewType: CharacterViewType) => {
setPendingFiles((current) => ({ ...current, [viewType]: null }));
setImagePreviews((current) => ({
...current,
[viewType]: editingChar.id ? getCharacterViewUrl(editingChar, viewType) : null,
}));
if (fileInputRefs.current[viewType]) {
fileInputRefs.current[viewType]!.value = "";
}
};
const handleSave = async () => {
if (!editingChar.name?.trim()) return;
setSaving(true);
try {
const saved = await saveCharacter.mutateAsync({ ...editingChar, status: editingChar.status ?? "draft" });
if (pendingFile) {
await uploadImage.mutateAsync({ id: String(saved.id), file: pendingFile });
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 });
}
setShowModal(false);
} catch (e) {
alert("保存失败: " + (e as Error).message);
} catch (error) {
alert(`保存失败: ${(error as Error).message}`);
} finally {
setSaving(false);
}
};
const handleGenerateImage = async (char: Character) => {
setGeneratingImageId(char.id);
generateImage.mutate(char.id, {
const handleGenerateImage = async (character: Character) => {
setGeneratingImageId(character.id);
generateImage.mutate(character.id, {
onSettled: () => setGeneratingImageId(null),
onError: (e) => alert("图片生成失败: " + (e as Error).message),
onError: (error) => alert(`三视图生成失败: ${(error as Error).message}`),
});
};
const handleDelete = (char: Character) => {
if (!confirm(`确定要删除角色「${char.name}」吗?`)) return;
deleteChar.mutate(char.id, { onError: (e) => alert("删除失败: " + (e as Error).message) });
};
const roleTypeLabel: Record<string, string> = {
"女主": "女主角", "男主": "男主角", "反派": "反派", "配角": "配角",
"female_lead": "女主角", "male_lead": "男主角", "villain": "反派", "supporting": "配角", "antagonist": "反派",
};
const statusBadge = (status: string) => {
if (status === "ready") return <span className="px-1.5 py-0.5 rounded text-xs bg-green-500/10 text-green-600">已生成</span>;
if (status === "generating") return <span className="px-1.5 py-0.5 rounded text-xs bg-blue-500/10 text-blue-600 flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" />生成中</span>;
if (status === "failed") return <span className="px-1.5 py-0.5 rounded text-xs bg-red-500/10 text-red-600">失败</span>;
return <span className="px-1.5 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-600">待生成</span>;
const handleDelete = (character: Character) => {
if (!confirm(`确定要删除角色「${character.name}」吗?`)) return;
deleteChar.mutate(character.id, {
onError: (error) => alert(`删除失败: ${(error as Error).message}`),
});
};
return (
<div className="h-full overflow-auto bg-background relative">
<div className="p-6 pb-24">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center">
......@@ -124,10 +229,9 @@ export function CharacterGeneration() {
</div>
<h1 className="text-2xl font-semibold text-foreground">项目设定</h1>
</div>
<p className="text-sm text-muted-foreground">管理角色、场景和道具设定</p>
<p className="text-sm text-muted-foreground">管理角色、场景和道具设定。角色形象已升级为标准三视图。</p>
</div>
{/* Settings Navigation Tabs */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
{settingsTabs.map((tab) => {
......@@ -148,6 +252,7 @@ export function CharacterGeneration() {
);
})}
</div>
<div className="flex items-center gap-2">
<button
onClick={openCreate}
......@@ -167,34 +272,30 @@ export function CharacterGeneration() {
</div>
</div>
{/* Statistics */}
<div className="flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">总计:</span>
<span className="text-sm text-muted-foreground">角色总数</span>
<span className="text-lg font-semibold text-foreground">{characters.length}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">已生成图片:</span>
<span className="text-lg font-semibold text-green-600">
{characters.filter(c => c.status === "ready").length}
</span>
<span className="text-sm text-muted-foreground">三视图完整</span>
<span className="text-lg font-semibold text-green-600">{generatedCount}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">生成中:</span>
<span className="text-lg font-semibold text-blue-600">
{characters.filter(c => c.status === "generating").length}
</span>
<span className="text-sm text-muted-foreground">生成中</span>
<span className="text-lg font-semibold text-blue-600">{generatingCount}</span>
</div>
</div>
</div>
{/* Empty State */}
{!isLoading && characters.length === 0 && (
<div className="rounded-xl border border-dashed border-border bg-card p-12 text-center">
<Users className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">暂无角色</h3>
<p className="text-sm text-muted-foreground mb-6">手动添加角色,或点击「AI提取角色」从大纲和分集自动提取主要角色</p>
<p className="text-sm text-muted-foreground mb-6">
角色设定页现在支持三视图展示。你可以手动添加角色,或从大纲和分集里自动提取。
</p>
<div className="flex items-center justify-center gap-3">
<button
onClick={openCreate}
......@@ -221,30 +322,44 @@ export function CharacterGeneration() {
</div>
)}
{/* Character Grid */}
{characters.length > 0 && (
<div className="grid grid-cols-2 gap-6">
{characters.map((char) => (
<div key={char.id} className="rounded-xl border border-border bg-card overflow-hidden">
{/* Info Section */}
{characters.map((character) => {
const mainImage = getCharacterViewUrl(character, "front")
?? getCharacterViewUrl(character, "side")
?? getCharacterViewUrl(character, "back");
const isGenerating = generatingImageId === character.id || character.status === "generating";
return (
<div key={character.id} className="rounded-xl border border-border bg-card overflow-hidden">
<div className="p-5 border-b border-border">
<div className="flex items-start justify-between mb-4">
<div className="flex items-start gap-3">
<div className="w-14 h-14 rounded-full overflow-hidden bg-muted flex items-center justify-center">
{mainImage ? (
<img src={mainImage} alt={character.name} className="w-full h-full object-cover" />
) : (
<ImageIcon className="w-6 h-6 text-muted-foreground" />
)}
</div>
<div>
<h3 className="text-lg font-semibold text-foreground mb-1">{char.name}</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{roleTypeLabel[char.roleType] ?? char.roleType}</span>
{statusBadge(char.status)}
<h3 className="text-lg font-semibold text-foreground mb-1">{character.name}</h3>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{roleTypeLabel[character.roleType] ?? character.roleType}</span>
{statusBadge(character.status)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => openEdit(char)}
onClick={() => openEdit(character)}
className="p-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(char)}
onClick={() => handleDelete(character)}
className="p-2 rounded-lg border border-border bg-card text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-4 h-4" />
......@@ -254,78 +369,86 @@ export function CharacterGeneration() {
<div className="space-y-1.5 text-sm">
<div className="flex">
<span className="text-muted-foreground w-16">性别:</span>
<span className="text-foreground">{char.gender === "male" ? "男" : char.gender === "female" ? "女" : char.gender}</span>
<span className="text-muted-foreground w-16">性别</span>
<span className="text-foreground">{character.gender === "male" ? "男" : character.gender === "female" ? "女" : character.gender}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-16">年龄:</span>
<span className="text-foreground">{char.age}</span>
<span className="text-muted-foreground w-16">年龄</span>
<span className="text-foreground">{character.age || "未填写"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-16">性格:</span>
<span className="text-foreground">{char.personality}</span>
<span className="text-muted-foreground w-16">性格</span>
<span className="text-foreground">{character.personality || "未填写"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-16">服装:</span>
<span className="text-foreground line-clamp-1">{char.costume}</span>
<span className="text-muted-foreground w-16">服装</span>
<span className="text-foreground line-clamp-1">{character.costume || "未填写"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-16">外貌:</span>
<span className="text-foreground line-clamp-1">{char.visualHint}</span>
<span className="text-muted-foreground w-16">外貌</span>
<span className="text-foreground line-clamp-1">{character.visualHint || "未填写"}</span>
</div>
</div>
</div>
{/* Image Section */}
<div className="p-5 bg-muted/20">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-muted-foreground font-medium">角色形象</span>
<div>
<span className="text-xs text-muted-foreground font-medium">角色三视图</span>
<p className="text-[11px] text-muted-foreground mt-0.5">正面 + 侧面 + 背面,用于完整展示角色造型。</p>
</div>
<button
onClick={() => handleGenerateImage(char)}
disabled={generatingImageId === char.id || char.status === "generating"}
onClick={() => handleGenerateImage(character)}
disabled={isGenerating}
className="px-3 py-1.5 rounded-md border border-border bg-card text-foreground hover:bg-muted transition-colors text-xs flex items-center gap-1.5 disabled:opacity-50"
>
{generatingImageId === char.id || char.status === "generating" ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
{char.imageUrl ? "重新生成" : "AI生成图片"}
{isGenerating ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
{hasCompleteThreeViews(character) ? "重新生成三视图" : "AI生成三视图"}
</button>
</div>
<div className="aspect-square rounded-lg overflow-hidden bg-white border border-border">
{char.imageUrl ? (
<img src={char.imageUrl} alt={char.name} className="w-full h-full object-cover" />
<div className="grid grid-cols-3 gap-3">
{CHARACTER_VIEWS.map((view) => {
const imageUrl = getCharacterViewUrl(character, view.type);
return (
<div key={view.type}>
<div className="text-[11px] text-muted-foreground mb-1 text-center">{view.label}</div>
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-white border border-border">
{imageUrl ? (
<img src={imageUrl} alt={`${character.name}-${view.label}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
{char.status === "generating" ? (
{isGenerating ? (
<>
<Loader2 className="w-8 h-8 text-primary animate-spin" />
<span className="text-sm text-muted-foreground">AI生成中...</span>
<Loader2 className="w-6 h-6 text-primary animate-spin" />
<span className="text-xs text-muted-foreground">生成中</span>
</>
) : (
<>
<ImageIcon className="w-8 h-8 text-muted-foreground" />
<span className="text-sm text-muted-foreground">点击生成图片</span>
<ImageIcon className="w-6 h-6 text-muted-foreground" />
<span className="text-xs text-muted-foreground">待补充</span>
</>
)}
</div>
)}
</div>
</div>
);
})}
</div>
{char.imagePrompt && (
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">{char.imagePrompt}</p>
{character.imagePrompt && (
<p className="mt-3 text-xs text-muted-foreground line-clamp-3">{character.imagePrompt}</p>
)}
</div>
</div>
))}
);
})}
</div>
)}
</div>
</div>
{/* Floating Bottom Bar */}
<div className="fixed bottom-0 left-0 right-0 border-t border-border bg-card/95 backdrop-blur-sm px-6 py-3 z-10">
<div className="max-w-7xl mx-auto flex items-center justify-end gap-3">
<button
......@@ -338,68 +461,78 @@ export function CharacterGeneration() {
</div>
</div>
{/* Add/Edit Character Modal */}
{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-lg max-h-[90vh] flex flex-col">
{/* Modal Header */}
<div className="bg-card rounded-2xl border border-border w-full max-w-4xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold text-foreground">
{editingChar.id ? "编辑角色" : "手动添加角色"}
</h2>
<div>
<h2 className="text-lg font-semibold text-foreground">{editingChar.id ? "编辑角色" : "手动添加角色"}</h2>
<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" />
</button>
</div>
{/* Modal Body */}
<div className="overflow-auto p-6 space-y-4 flex-1">
{/* Image Upload */}
<div className="overflow-auto p-6 space-y-5 flex-1">
<div>
<label className="block text-sm font-medium text-foreground mb-2">角色图片(可选)</label>
<div className="flex items-start gap-4">
<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="w-24 h-24 rounded-lg border-2 border-dashed border-border bg-muted flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden flex-shrink-0"
onClick={() => fileInputRef.current?.click()}
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()}
>
{imagePreview ? (
<img src={imagePreview} alt="预览" className="w-full h-full object-cover" />
{imagePreviews[view.type] ? (
<img src={imagePreviews[view.type] ?? ""} alt={`${view.label}预览`} className="w-full h-full object-cover" />
) : (
<div className="text-center">
<Upload className="w-5 h-5 text-muted-foreground mx-auto mb-1" />
<span className="text-xs text-muted-foreground">上传图片</span>
<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="text-xs text-muted-foreground mt-1">
<p>点击上传角色参考图</p>
<p className="mt-1">支持 JPG、PNG,建议正方形比例</p>
{imagePreview && (
<button
onClick={() => { setImagePreview(null); setPendingFile(null); }}
className="mt-2 text-destructive hover:underline"
>
移除图片
<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>
))}
</div>
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileSelect} />
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">角色名称 <span className="text-destructive">*</span></label>
<label className="block text-sm font-medium text-foreground mb-1">
角色名称
<span className="text-destructive ml-1">*</span>
</label>
<input
type="text"
value={editingChar.name ?? ""}
onChange={(e) => setEditingChar({ ...editingChar, name: e.target.value })}
placeholder="例如:乔"
placeholder="例如:乔"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
{/* Role Type + Gender */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-foreground mb-1">角色类型</label>
......@@ -408,7 +541,11 @@ export function CharacterGeneration() {
onChange={(e) => setEditingChar({ ...editingChar, roleType: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer"
>
{ROLE_TYPES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
{ROLE_TYPES.map((role) => (
<option key={role.value} value={role.value}>
{role.label}
</option>
))}
</select>
</div>
<div>
......@@ -424,7 +561,6 @@ export function CharacterGeneration() {
</div>
</div>
{/* Age */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">年龄</label>
<input
......@@ -436,44 +572,40 @@ export function CharacterGeneration() {
/>
</div>
{/* Personality */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">性格描述</label>
<textarea
value={editingChar.personality ?? ""}
onChange={(e) => setEditingChar({ ...editingChar, personality: e.target.value })}
placeholder="例如:善良坚韧,忍辱负重但内心强大"
placeholder="例如:善良坚韧,外柔内刚,遇事很有主见。"
rows={2}
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>
{/* Visual Hint */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">外貌特征</label>
<textarea
value={editingChar.visualHint ?? ""}
onChange={(e) => setEditingChar({ ...editingChar, visualHint: e.target.value })}
placeholder="例如:长发,圆脸,气质温婉"
placeholder="例如:黑长直,杏眼,气质清冷,身形修长。"
rows={2}
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>
{/* Costume */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">服装描述</label>
<textarea
value={editingChar.costume ?? ""}
onChange={(e) => setEditingChar({ ...editingChar, costume: e.target.value })}
placeholder="例如:家居休闲装,朴素整洁"
placeholder="例如:浅色风衣搭配衬衫长裤,整体简洁利落。"
rows={2}
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>
{/* Modal Footer */}
<div className="flex gap-3 p-6 border-t border-border flex-shrink-0">
<button
onClick={() => setShowModal(false)}
......
......@@ -9,6 +9,14 @@ import { useTeamMembers } from "../../hooks/useTeam";
import { projectsApi } from "../../lib/api/projects";
import { useQueryClient } from "@tanstack/react-query";
import type { ProjectDTO } from "../../lib/api/projects";
import {
aspectRatioOptions,
getAspectRatioLabel,
getProjectStyleLabel,
getResolutionLabel,
projectStyleOptions,
resolutionOptions,
} from "../../lib/projectStyles";
// ── Default cover generator ─────────────────────────────────────────────────
function getProjectGradient(name: string): string {
......@@ -48,6 +56,9 @@ function EditModal({
const qc = useQueryClient();
const updateProject = useUpdateProject(project.id);
const [name, setName] = useState(project.name);
const [style, setStyle] = useState(project.style ?? "");
const [aspectRatio, setAspectRatio] = useState(project.aspectRatio ?? "");
const [resolution, setResolution] = useState(project.resolution ?? "");
const [coverFile, setCoverFile] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string>(project.coverUrl ?? "");
const [saving, setSaving] = useState(false);
......@@ -64,11 +75,12 @@ function EditModal({
if (!name.trim()) return;
setSaving(true);
try {
// Update name if changed
if (name.trim() !== project.name) {
await updateProject.mutateAsync({ name: name.trim() });
}
// Upload new cover if selected
await updateProject.mutateAsync({
name: name.trim(),
style: style || undefined,
aspectRatio: aspectRatio || undefined,
resolution: resolution || undefined,
});
if (coverFile) {
await projectsApi.uploadAsset(project.id, coverFile, "cover");
qc.invalidateQueries({ queryKey: ["projects"] });
......@@ -137,6 +149,54 @@ function EditModal({
maxLength={200}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs text-muted-foreground mb-2">视觉风格</label>
<select
value={style}
onChange={(e) => setStyle(e.target.value)}
className="w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="">未设置</option>
{projectStyleOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">画幅比例</label>
<select
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value)}
className="w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="">未设置</option>
{aspectRatioOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} {option.helper ?? ""}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">清晰度</label>
<select
value={resolution}
onChange={(e) => setResolution(e.target.value)}
className="w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="">未设置</option>
{resolutionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} {option.helper ?? ""}
</option>
))}
</select>
</div>
</div>
</div>
{/* Footer */}
......@@ -249,7 +309,11 @@ export function Dashboard() {
updatedAt: p.updatedAt?.slice(0, 10) ?? "",
createdAt: p.createdAt?.slice(0, 10) ?? "",
coverUrl: p.coverUrl,
tags: p.style ? [p.style] : [],
tags: [
{ key: "style", label: getProjectStyleLabel(p.style) },
{ key: "aspectRatio", label: getAspectRatioLabel(p.aspectRatio) },
{ key: "resolution", label: getResolutionLabel(p.resolution) },
].filter((tag) => tag.label !== "未设置"),
}));
const filtered = projects.filter((p) => {
......@@ -435,7 +499,9 @@ export function Dashboard() {
{project.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{project.tags.slice(0, 3).map((tag) => (
<span key={tag} className="px-2 py-0.5 rounded bg-muted text-xs text-foreground">{tag}</span>
<span key={tag.key} className="px-2 py-0.5 rounded bg-muted text-xs text-foreground">
{tag.label}
</span>
))}
</div>
)}
......
import { useState } from "react";
import { useState, type ChangeEvent, type MouseEvent } from "react";
import { useNavigate } from "react-router";
import { Upload, FileText, ArrowRight, Sparkles, Image as ImageIcon, X } from "lucide-react";
import {
AlertCircle,
ArrowRight,
FileText,
Image as ImageIcon,
Loader2,
Sparkles,
Upload,
X,
} from "lucide-react";
import { Progress } from "../components/ui/progress";
import { useCreateProject } from "../../hooks/useProjects";
import { projectsApi } from "../../lib/api/projects";
import { aspectRatioOptions, projectStyleOptions, resolutionOptions } from "../../lib/projectStyles";
type SubmitStage = "idle" | "creating" | "uploading" | "redirecting";
const submitStageMeta: Record<
Exclude<SubmitStage, "idle">,
{ title: string; description: string; progress: number }
> = {
creating: {
title: "正在创建项目",
description: "项目基础信息正在写入,请稍候。",
progress: 28,
},
uploading: {
title: "正在上传素材",
description: "如果你上传了剧本或封面,系统会先完成上传再进入大纲页。",
progress: 72,
},
redirecting: {
title: "准备进入剧本大纲",
description: "马上跳转到“生成剧本大纲”,可以继续解析已上传剧本,或手动输入内容。",
progress: 96,
},
};
export function NewProject() {
const navigate = useNavigate();
......@@ -10,138 +44,242 @@ export function NewProject() {
const [scriptFile, setScriptFile] = useState<File | null>(null);
const [coverFile, setCoverFile] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [aspectRatio, setAspectRatio] = useState(aspectRatioOptions[0].value);
const [resolution, setResolution] = useState(resolutionOptions[0].value);
const [style, setStyle] = useState(projectStyleOptions[0].value);
const [submitStage, setSubmitStage] = useState<SubmitStage>("idle");
const [submitError, setSubmitError] = useState<string | null>(null);
const createProject = useCreateProject();
const handleScriptChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const f = e.target.files[0];
setScriptFile(f);
// 仅当用户未手动填写名称时,自动用文件名填充
const isSubmitting = submitStage !== "idle";
const hasUpload = !!scriptFile || !!coverFile;
const handleScriptChange = (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.[0] || isSubmitting) return;
const file = e.target.files[0];
setScriptFile(file);
if (!projectName.trim()) {
setProjectName(f.name.replace(/\.[^/.]+$/, ""));
}
setProjectName(file.name.replace(/\.[^/.]+$/, ""));
}
};
const handleCoverChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const f = e.target.files[0];
setCoverFile(f);
setCoverPreview(URL.createObjectURL(f));
}
const handleCoverChange = (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.[0] || isSubmitting) return;
const file = e.target.files[0];
setCoverFile(file);
setCoverPreview((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(file);
});
};
const removeCover = (e: React.MouseEvent) => {
const removeCover = (e: MouseEvent) => {
e.preventDefault();
if (isSubmitting) return;
setCoverFile(null);
if (coverPreview) URL.revokeObjectURL(coverPreview);
setCoverPreview(null);
};
const handleSubmit = async () => {
const name = projectName.trim() || (scriptFile ? scriptFile.name.replace(/\.[^/.]+$/, "") : "新项目");
const project = await createProject.mutateAsync({ name });
if (isSubmitting) return;
const name =
projectName.trim() ||
(scriptFile ? scriptFile.name.replace(/\.[^/.]+$/, "") : "新短剧项目");
try {
setSubmitError(null);
setSubmitStage("creating");
const project = await createProject.mutateAsync({
name,
style,
aspectRatio,
resolution,
});
if (hasUpload) {
setSubmitStage("uploading");
await Promise.all([
scriptFile
? projectsApi.uploadAsset(project.id, scriptFile, "script").catch(() => {})
: Promise.resolve(),
coverFile
? projectsApi.uploadAsset(project.id, coverFile, "cover").catch(() => {})
: Promise.resolve(),
scriptFile ? projectsApi.uploadAsset(project.id, scriptFile, "script") : Promise.resolve(),
coverFile ? projectsApi.uploadAsset(project.id, coverFile, "cover") : Promise.resolve(),
]);
}
setSubmitStage("redirecting");
navigate(`/project/${project.id}/outline`);
navigate(`/project/${project.id}/outline`, {
state: {
fromNewProject: true,
projectName: name,
aspectRatio,
resolution,
style,
},
});
} catch (error) {
setSubmitStage("idle");
setSubmitError((error as Error)?.message ?? "创建项目失败,请重试");
}
};
const canSubmit = !!scriptFile && !createProject.isPending;
const canSubmit = !isSubmitting;
const activeSubmitMeta = submitStage === "idle" ? null : submitStageMeta[submitStage];
return (
<div className="h-full overflow-auto bg-background">
<div className="max-w-4xl mx-auto p-8">
{/* Header */}
<div className="relative h-full overflow-auto bg-background">
{activeSubmitMeta && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-6 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-6 shadow-2xl">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9]">
<Loader2 className="h-7 w-7 animate-spin text-white" />
</div>
<h2 className="mb-2 text-xl font-semibold text-foreground">{activeSubmitMeta.title}</h2>
<p className="mb-5 text-sm leading-relaxed text-muted-foreground">
{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-foreground">
<span>创建项目</span>
<span>{submitStage === "creating" ? "进行中" : "已准备"}</span>
</div>
<div className="flex items-center justify-between text-foreground">
<span>上传素材</span>
<span>
{!hasUpload
? "已跳过"
: submitStage === "uploading"
? "进行中"
: submitStage === "redirecting"
? "已完成"
: "等待中"}
</span>
</div>
<div className="flex items-center justify-between text-foreground">
<span>进入大纲页</span>
<span>{submitStage === "redirecting" ? "进行中" : "等待中"}</span>
</div>
</div>
<p className="mt-4 text-xs text-muted-foreground">
处理中已锁定按钮,避免重复点击创建多个项目。
</p>
</div>
</div>
)}
<div className="mx-auto max-w-4xl p-8">
<div className="mb-8 text-center">
<h1 className="text-3xl font-semibold text-foreground mb-2">翻开剧本,创作精品短剧</h1>
<p className="text-sm text-muted-foreground">上传您的剧本,让AI为您生成完整的制作方案</p>
<h1 className="mb-2 text-3xl font-semibold text-foreground">创建短剧项目</h1>
<p className="text-sm text-muted-foreground">
可以先上传剧本直接进入解析流程,也可以跳过上传,在下一页手动输入内容生成大纲。
</p>
</div>
{/* Project Name */}
{submitError && (
<div className="mb-5 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500" />
<p className="text-sm text-red-700">{submitError}</p>
</div>
)}
<div className="mb-5">
<label className="block text-sm font-medium text-foreground mb-2">项目名称</label>
<label className="mb-2 block text-sm font-medium text-foreground">项目名称</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="输入项目名称,或上传剧本后自动填充"
className="w-full px-4 py-3 rounded-xl border border-border bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
disabled={isSubmitting}
placeholder="输入项目名称,或上传剧本后自动带入文件名"
className="w-full rounded-xl border border-border bg-card px-4 py-3 text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
{/* Cover + Script Row */}
<div className="flex gap-4 mb-6">
{/* Cover Image */}
<div className="mb-6 flex gap-4">
<div className="flex-shrink-0">
<label className="block text-sm font-medium text-foreground mb-2">封面图片<span className="text-muted-foreground font-normal ml-1">(可选)</span></label>
<div className="relative w-28 h-40 rounded-xl border-2 border-dashed border-border bg-card overflow-hidden flex items-center justify-center">
<label className="mb-2 block text-sm font-medium text-foreground">
封面图片
<span className="ml-1 font-normal text-muted-foreground">(可选)</span>
</label>
<div className="relative flex h-40 w-28 items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-border bg-card">
<input
type="file"
accept="image/*"
onChange={handleCoverChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
disabled={isSubmitting}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
/>
{coverPreview ? (
<>
<img src={coverPreview} alt="封面预览" className="w-full h-full object-cover" />
<img src={coverPreview} alt="封面预览" className="h-full w-full object-cover" />
<button
onClick={removeCover}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 flex items-center justify-center hover:bg-black/80 transition-colors z-10"
disabled={isSubmitting}
className="absolute right-1 top-1 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 transition-colors hover:bg-black/80 disabled:opacity-50"
>
<X className="w-3 h-3 text-white" />
<X className="h-3 w-3 text-white" />
</button>
</>
) : (
<div className="text-center p-2">
<ImageIcon className="w-6 h-6 text-muted-foreground mx-auto mb-1" />
<div className="p-2 text-center">
<ImageIcon className="mx-auto mb-1 h-6 w-6 text-muted-foreground" />
<span className="text-xs text-muted-foreground">点击上传</span>
</div>
)}
</div>
</div>
{/* Script Upload */}
<div className="flex-1">
<label className="block text-sm font-medium text-foreground mb-2">剧本文件<span className="text-destructive ml-0.5">*</span></label>
<div className="relative rounded-xl border-2 border-dashed border-border bg-card h-40 flex items-center justify-center">
<label className="mb-2 block text-sm font-medium text-foreground">
剧本文件
<span className="ml-1 font-normal text-muted-foreground">(可选)</span>
</label>
<div className="relative flex h-40 items-center justify-center rounded-xl border-2 border-dashed border-border bg-card">
<input
type="file"
accept=".txt,.doc,.docx,.pdf,.md"
onChange={handleScriptChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
disabled={isSubmitting}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
/>
{scriptFile ? (
<div className="flex flex-col items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center">
<FileText className="w-6 h-6 text-white" />
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9]">
<FileText className="h-6 w-6 text-white" />
</div>
<div className="text-center">
<div className="text-foreground font-medium text-sm mb-0.5">{scriptFile.name}</div>
<div className="text-xs text-muted-foreground">{(scriptFile.size / 1024).toFixed(1)} KB</div>
<div className="mb-0.5 text-sm font-medium text-foreground">{scriptFile.name}</div>
<div className="text-xs text-muted-foreground">
{(scriptFile.size / 1024).toFixed(1)} KB
</div>
</div>
<button
onClick={(e) => { e.preventDefault(); setScriptFile(null); }}
className="text-xs text-primary hover:underline"
onClick={(e) => {
e.preventDefault();
if (!isSubmitting) setScriptFile(null);
}}
disabled={isSubmitting}
className="text-xs text-primary hover:underline disabled:no-underline disabled:opacity-50"
>
重新上传
</button>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center">
<Upload className="w-6 h-6 text-muted-foreground" />
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-muted">
<Upload className="h-6 w-6 text-muted-foreground" />
</div>
<div className="text-center">
<div className="text-sm text-foreground mb-0.5">点击或拖拽剧本文件至此</div>
<div className="text-xs text-muted-foreground">支持 doc、docx、txt、pdf、md,不超过 20M</div>
<div className="mb-0.5 text-sm text-foreground">点击或拖拽剧本文件到这里</div>
<div className="text-xs text-muted-foreground">
支持 doc、docx、txt、pdf、md,大小不超过 20M;也可以先跳过,下一页手动输入剧本。
</div>
</div>
</div>
)}
......@@ -149,45 +287,77 @@ export function NewProject() {
</div>
</div>
{/* Options */}
<div className="grid grid-cols-3 gap-4 mb-6">
<button className="rounded-xl border border-border bg-card p-4 text-left hover:border-primary transition-colors">
<div className="text-sm font-medium text-foreground mb-1">16:9</div>
<div className="text-xs text-muted-foreground">横屏</div>
</button>
<button className="rounded-xl border border-border bg-card p-4 text-left hover:border-primary transition-colors">
<div className="text-sm font-medium text-foreground mb-1">720p</div>
<div className="text-xs text-muted-foreground">标清</div>
</button>
<button className="rounded-xl border border-border bg-card p-4 text-left hover:border-primary transition-colors">
<div className="text-sm font-medium text-foreground mb-1">2D日漫</div>
<div className="text-xs text-muted-foreground">风格</div>
</button>
<div className="mb-6 grid grid-cols-3 gap-4">
<div className="rounded-xl border border-border bg-card p-4">
<div className="mb-2 text-xs text-muted-foreground">画幅比例</div>
<select
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value)}
disabled={isSubmitting}
className="w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{aspectRatioOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} · {option.helper}
</option>
))}
</select>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<div className="mb-2 text-xs text-muted-foreground">清晰度</div>
<select
value={resolution}
onChange={(e) => setResolution(e.target.value)}
disabled={isSubmitting}
className="w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{resolutionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} · {option.helper}
</option>
))}
</select>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<div className="mb-2 text-xs text-muted-foreground">视觉风格</div>
<select
value={style}
onChange={(e) => setStyle(e.target.value)}
disabled={isSubmitting}
className="w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{projectStyleOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{/* AI Info */}
<div className="rounded-xl border border-primary/20 bg-accent/50 p-4 mb-6">
<div className="mb-6 rounded-xl border border-primary/20 bg-accent/50 p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Sparkles className="w-5 h-5 text-primary" />
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Sparkles className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="text-sm font-medium text-foreground mb-1">AI智能处理</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
上传剧本后,AI将自动为您生成:大纲、分集、角色分析、场景规划、道具清单、服装设计建议,并支持多人协作编辑和审核
<h3 className="mb-1 text-sm font-medium text-foreground">AI 智能处理</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
如果上传了剧本,下一页会继续自动解析;如果没有上传,也可以直接手动输入剧本内容生成大纲。这里选择的视觉风格会继续影响后续人物与场景的 AI 生成效果
</p>
</div>
</div>
</div>
{/* Submit Button */}
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="w-full py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] py-3 text-white transition-all hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none"
>
<span>{createProject.isPending ? "创建中..." : "创建新短剧项目"}</span>
<ArrowRight className="w-5 h-5" />
<span>{isSubmitting ? activeSubmitMeta?.title ?? "处理中..." : "创建新短剧项目"}</span>
{isSubmitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <ArrowRight className="h-5 w-5" />}
</button>
</div>
</div>
......
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router";
import {
ArrowRight, Loader2, Sparkles, RefreshCw, AlertCircle,
FileText, Users, MapPin, Film, CheckCircle2,
AlertCircle,
ArrowRight,
CheckCircle2,
FileText,
Film,
Loader2,
MapPin,
RefreshCw,
Sparkles,
Users,
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useOutline, useGenerateOutline, useGenerateEpisodes } from "../../hooks/useAi";
import { projectsApi, ScriptInfoDTO } from "../../lib/api/projects";
import { Progress } from "../components/ui/progress";
import { useGenerateEpisodes, useGenerateOutline, useOutline } from "../../hooks/useAi";
import { projectsApi, type ScriptInfoDTO } from "../../lib/api/projects";
type ProcessingStep = {
label: string;
hint: string;
progress: number;
};
type ProcessingState = {
title: string;
description: string;
steps: ProcessingStep[];
};
type OutlineLocationState = {
fromNewProject?: boolean;
projectName?: string;
};
const checkingOutlineState: ProcessingState = {
title: "正在检查项目状态",
description: "先确认这个项目是否已经有可用大纲。",
steps: [
{
label: "检查已有大纲",
hint: "正在确认是否需要重新解析上传的剧本。",
progress: 12,
},
],
};
const parsingScriptState: ProcessingState = {
title: "正在解析上传的剧本",
description: "系统正在读取文档内容,并抽取剧情结构、人物与场景信息。",
steps: [
{
label: "读取剧本文件",
hint: "正在加载上传的 Word、PDF 或文本内容。",
progress: 24,
},
{
label: "分析剧情结构",
hint: "正在识别主线冲突、题材和整体节奏。",
progress: 52,
},
{
label: "整理角色与场景",
hint: "正在生成可预览的结构化摘要。",
progress: 82,
},
],
};
const readingScriptState: ProcessingState = {
title: "正在准备剧本文本",
description: "未拿到结构化摘要,正在回退为读取原始剧本文本。",
steps: [
{
label: "读取原始文本",
hint: "正在提取上传剧本中的正文内容。",
progress: 36,
},
{
label: "填充编辑器",
hint: "马上就可以在这里直接生成大纲。",
progress: 74,
},
],
};
const importingScriptState: ProcessingState = {
title: "正在导入剧本数据",
description: "系统会把剧本拆解为大纲、分集、角色、场景和分镜,请稍候。",
steps: [
{
label: "解析整体结构",
hint: "正在理解故事主线与题材风格。",
progress: 24,
},
{
label: "生成大纲与分集",
hint: "正在生成故事梗概和每集节奏。",
progress: 56,
},
{
label: "写入角色与场景",
hint: "正在把结果保存到项目工作区。",
progress: 88,
},
],
};
const generatingOutlineState: ProcessingState = {
title: "AI 正在生成剧本大纲",
description: "正在根据剧本文本组织故事主线、题材和概要描述。",
steps: [
{
label: "理解剧本主线",
hint: "正在提取核心矛盾和人物关系。",
progress: 30,
},
{
label: "归纳故事结构",
hint: "正在组织题材、世界观和章节节奏。",
progress: 62,
},
{
label: "输出大纲结果",
hint: "正在整理可直接预览的大纲内容。",
progress: 90,
},
],
};
function formatElapsed(seconds: number) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return mins > 0 ? `${mins}${secs}秒` : `${secs}秒`;
}
function ProcessingScreen({ state, elapsedSeconds, projectName }: {
state: ProcessingState;
elapsedSeconds: number;
projectName?: string;
}) {
const [activeStep, setActiveStep] = useState(0);
useEffect(() => {
setActiveStep(0);
if (state.steps.length <= 1) return undefined;
const timer = window.setInterval(() => {
setActiveStep((current) => Math.min(current + 1, state.steps.length - 1));
}, 1800);
return () => window.clearInterval(timer);
}, [state]);
const progressValue = state.steps[Math.min(activeStep, state.steps.length - 1)]?.progress ?? 0;
return (
<div className="h-full bg-background flex items-center justify-center p-6">
<div className="w-full max-w-2xl rounded-3xl border border-border bg-card shadow-xl p-8">
<div className="flex items-start gap-4 mb-6">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center flex-shrink-0">
<Loader2 className="w-8 h-8 text-white animate-spin" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between gap-4 mb-2">
<h1 className="text-2xl font-semibold text-foreground">{state.title}</h1>
<span className="text-xs text-muted-foreground whitespace-nowrap">已用时 {formatElapsed(elapsedSeconds)}</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">{state.description}</p>
{projectName && (
<p className="text-xs text-primary mt-2">当前项目:{projectName}</p>
)}
</div>
</div>
<div className="rounded-2xl border border-primary/15 bg-primary/5 p-5 mb-6">
<div className="flex items-center justify-between text-sm mb-3">
<span className="text-foreground font-medium">{state.steps[Math.min(activeStep, state.steps.length - 1)]?.label}</span>
<span className="text-primary">{progressValue}%</span>
</div>
<Progress value={progressValue} className="h-2.5 mb-3" />
<p className="text-sm text-muted-foreground">{state.steps[Math.min(activeStep, state.steps.length - 1)]?.hint}</p>
</div>
<div className="space-y-3">
{state.steps.map((step, index) => {
const isDone = index < activeStep;
const isCurrent = index === activeStep;
return (
<div key={step.label} className="flex items-start gap-3 rounded-xl border border-border/70 bg-background px-4 py-3">
<div className="mt-0.5">
{isDone ? (
<CheckCircle2 className="w-5 h-5 text-green-500" />
) : isCurrent ? (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-border" />
)}
</div>
<div>
<div className="text-sm font-medium text-foreground">{step.label}</div>
<div className="text-xs text-muted-foreground mt-0.5">{step.hint}</div>
</div>
</div>
);
})}
</div>
<p className="text-xs text-muted-foreground mt-5">
当前为前端阶段性实时反馈。如果后端后续提供解析进度接口,这里可以进一步切成真实百分比。
</p>
</div>
</div>
);
}
export function OutlineGeneration() {
const navigate = useNavigate();
const location = useLocation();
const { projectId } = useParams<{ projectId: string }>();
const pid = projectId ?? "";
const qc = useQueryClient();
const outlineState = location.state as OutlineLocationState | null;
const projectNameFromState = outlineState?.projectName;
const { data: outline, isLoading: loadingOutline } = useOutline(pid);
const generateOutline = useGenerateOutline(pid);
const generateEpisodes = useGenerateEpisodes(pid);
// 解析结果(预览卡片)
const [scriptInfo, setScriptInfo] = useState<ScriptInfoDTO | null>(null);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
// 手动输入模式(AI 生成大纲)
const [script, setScript] = useState("");
const [showManual, setShowManual] = useState(false);
const [loadingScript, setLoadingScript] = useState(false);
const [preparingState, setPreparingState] = useState<"idle" | "parsing" | "reading">("idle");
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
// 进入页面时:先尝试解析文档(有 doc/docx 上传时)
useEffect(() => {
if (outline || loadingOutline) return;
if (!pid || outline || loadingOutline) return;
let cancelled = false;
setPreparingState("parsing");
setImportError(null);
projectsApi.parseScript(pid)
.then((info) => setScriptInfo(info))
.catch(() => {
// 没有上传文档,尝试预填文本内容
.then((info) => {
if (cancelled) return;
setScriptInfo(info);
setShowManual(false);
})
.catch(async () => {
if (cancelled) return;
setPreparingState("reading");
setLoadingScript(true);
projectsApi.readScriptContent(pid)
.then((text) => { if (text) setScript(text.slice(0, 8000)); })
.catch(() => {})
.finally(() => setLoadingScript(false));
try {
const text = await projectsApi.readScriptContent(pid);
if (cancelled) return;
if (text) {
setScript(text.slice(0, 8000));
}
} catch {
// Keep manual editor empty so the user can paste script content manually.
} finally {
if (!cancelled) {
setLoadingScript(false);
}
}
})
.finally(() => {
if (!cancelled) {
setPreparingState("idle");
}
});
return () => {
cancelled = true;
};
}, [pid, outline, loadingOutline]);
// 一键导入
const handleImport = async () => {
if (!pid || importing) return;
setImporting(true);
setImportError(null);
try {
await projectsApi.importScript(pid);
// 刷新相关查询缓存
await qc.invalidateQueries({ queryKey: ["outline", pid] });
await qc.invalidateQueries({ queryKey: ["episodes", pid] });
await qc.invalidateQueries({ queryKey: ["characters", pid] });
await qc.invalidateQueries({ queryKey: ["scenes", pid] });
navigate(`/project/${projectId}/episodes`);
} catch (e) {
setImportError((e as Error)?.message ?? "导入失败,请重试");
} catch (error) {
setImportError((error as Error)?.message ?? "导入失败,请重试");
} finally {
setImporting(false);
}
};
// AI 生成大纲
const handleGenerate = async () => {
if (!script.trim()) return;
setImportError(null);
await generateOutline.mutateAsync(script);
setShowManual(false);
};
// 已有大纲 → 生成分集
const handleContinue = async () => {
if (!outline) return;
await generateEpisodes.mutateAsync();
navigate(`/project/${projectId}/episodes`);
};
const isWorking = generateOutline.isPending || generateEpisodes.isPending || loadingOutline || importing;
const processingState = useMemo<ProcessingState | null>(() => {
if (!outline && loadingOutline) return checkingOutlineState;
if (importing) return importingScriptState;
if (generateOutline.isPending) return generatingOutlineState;
if (preparingState === "parsing") return parsingScriptState;
if (preparingState === "reading") return readingScriptState;
return null;
}, [generateOutline.isPending, importing, loadingOutline, outline, preparingState]);
useEffect(() => {
if (!processingState || outline) {
setElapsedSeconds(0);
return undefined;
}
setElapsedSeconds(0);
const timer = window.setInterval(() => {
setElapsedSeconds((current) => current + 1);
}, 1000);
return () => window.clearInterval(timer);
}, [outline, processingState]);
// ── 全屏加载态 ─────────────────────────────────────────────────────────────
if (isWorking && !outline) {
if (processingState && !outline) {
return (
<div className="h-full flex items-center justify-center bg-background">
<div className="text-center">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center mb-6 mx-auto">
<Loader2 className="w-10 h-10 text-white animate-spin" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
{importing ? "正在导入剧本数据..." : "AI正在生成大纲..."}
</h2>
<p className="text-sm text-muted-foreground">
{importing ? "解析大纲、分集、角色、场景、分镜,请稍候" : "分析剧本结构,提取核心情节"}
</p>
</div>
</div>
<ProcessingScreen
state={processingState}
elapsedSeconds={elapsedSeconds}
projectName={projectNameFromState}
/>
);
}
// ── 无大纲 → 输入/导入界面 ─────────────────────────────────────────────────
if (!outline && !loadingOutline) {
if (!outline) {
return (
<div className="h-full overflow-auto bg-background p-6">
<div className="max-w-3xl mx-auto">
......@@ -107,10 +359,12 @@ export function OutlineGeneration() {
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-white" />
</div>
<div>
<h1 className="text-2xl font-semibold text-foreground">生成剧本大纲</h1>
<p className="text-sm text-muted-foreground">先预览剧本解析结果,再决定直接导入或手动生成大纲。</p>
</div>
</div>
{/* 错误提示 */}
{(importError || generateOutline.isError) && (
<div className="mb-4 p-4 rounded-xl border border-red-200 bg-red-50 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
......@@ -120,37 +374,31 @@ export function OutlineGeneration() {
</div>
)}
{/* ── 文档解析预览卡片 ── */}
{scriptInfo && !showManual ? (
<div className="rounded-xl border border-primary/40 bg-primary/5 p-6 mb-4">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5 text-primary" />
<span className="text-sm font-medium text-primary">已识别剧本文档</span>
<span className="text-sm font-medium text-primary">已识别上传剧本</span>
</div>
<CheckCircle2 className="w-5 h-5 text-green-500" />
</div>
<h2 className="text-xl font-bold text-foreground mb-1">
{scriptInfo.title || "(未识别标题)"}
</h2>
<p className="text-sm text-muted-foreground mb-4">{scriptInfo.genre}</p>
<h2 className="text-xl font-bold text-foreground mb-1">{scriptInfo.title || "未识别标题"}</h2>
<p className="text-sm text-muted-foreground mb-4">{scriptInfo.genre || "待补充题材"}</p>
{scriptInfo.synopsis && (
<p className="text-sm text-foreground/80 bg-background/60 rounded-lg p-3 mb-4 leading-relaxed">
{scriptInfo.synopsis.length > 120
? scriptInfo.synopsis.slice(0, 120) + "..."
: scriptInfo.synopsis}
{scriptInfo.synopsis.length > 120 ? `${scriptInfo.synopsis.slice(0, 120)}...` : scriptInfo.synopsis}
</p>
)}
{/* 统计数字 */}
<div className="grid grid-cols-4 gap-3 mb-5">
{[
{ icon: <Film className="w-4 h-4" />, label: "集数", value: `${scriptInfo.episodeCount} 集` },
{ icon: <Users className="w-4 h-4" />, label: "角色", value: `${scriptInfo.characterCount} 个` },
{ icon: <MapPin className="w-4 h-4" />,label: "场景", value: `${scriptInfo.sceneCount} 个` },
{ icon: <Sparkles className="w-4 h-4" />, label: "分镜脚本", value: `${scriptInfo.storyboardCount} ` },
{ icon: <MapPin className="w-4 h-4" />, label: "场景", value: `${scriptInfo.sceneCount} 个` },
{ icon: <Sparkles className="w-4 h-4" />, label: "分镜脚本", value: `${scriptInfo.storyboardCount} ` },
].map(({ icon, label, value }) => (
<div key={label} className="rounded-lg bg-background border border-border p-3 text-center">
<div className="flex justify-center mb-1 text-primary">{icon}</div>
......@@ -160,16 +408,22 @@ export function OutlineGeneration() {
))}
</div>
{/* 一键导入 */}
<button
onClick={handleImport}
disabled={importing}
className="w-full py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{importing ? (
<><Loader2 className="w-5 h-5 animate-spin" /><span>导入中...</span></>
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>导入中...</span>
</>
) : (
<><Sparkles className="w-5 h-5" /><span>一键导入大纲 · 分集 · 角色 · 分镜</span><ArrowRight className="w-4 h-4" /></>
<>
<Sparkles className="w-5 h-5" />
<span>一键导入大纲 · 分集 · 角色 · 分镜</span>
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
......@@ -181,18 +435,18 @@ export function OutlineGeneration() {
</button>
</div>
) : (
/* ── 手动输入 / AI 生成模式 ── */
<div className="rounded-xl border border-border bg-card p-6">
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-medium text-foreground">剧本内容</label>
<div className="flex items-center gap-2">
{loadingScript && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />正在加载已上传剧本...
<Loader2 className="w-3 h-3 animate-spin" />
正在加载上传剧本...
</span>
)}
{!loadingScript && script && (
<span className="text-xs text-green-600">✓ 已自动填入上传的剧</span>
<span className="text-xs text-green-600">已自动带入上传剧本文</span>
)}
{scriptInfo && (
<button
......@@ -204,13 +458,14 @@ export function OutlineGeneration() {
)}
</div>
</div>
<textarea
className="w-full h-64 p-4 rounded-lg border border-border bg-background text-sm text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="将您的剧本文本粘贴到此处,AI将自动生成大纲..."
placeholder="将您的剧本文本粘贴到此处,AI 将自动生成大纲..."
value={script}
onChange={(e) => setScript(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-2">支持中英文,建议 500~5000 字</p>
<p className="text-xs text-muted-foreground mt-2">支持中英文剧本,建议正文长度 500 到 5000 字。</p>
<button
onClick={handleGenerate}
......@@ -218,9 +473,15 @@ export function OutlineGeneration() {
className="w-full mt-4 py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{generateOutline.isPending ? (
<><Loader2 className="w-5 h-5 animate-spin" /><span>生成中...</span></>
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>生成中...</span>
</>
) : (
<><Sparkles className="w-5 h-5" /><span>AI 生成大纲</span></>
<>
<Sparkles className="w-5 h-5" />
<span>AI 生成大纲</span>
</>
)}
</button>
</div>
......@@ -230,7 +491,6 @@ export function OutlineGeneration() {
);
}
// ── 已有大纲 → 展示 + 生成分集 ──────────────────────────────────────────
return (
<div className="h-full overflow-auto bg-background p-6">
<div className="max-w-5xl mx-auto">
......@@ -242,13 +502,14 @@ export function OutlineGeneration() {
</div>
<h1 className="text-2xl font-semibold text-foreground">剧本大纲</h1>
</div>
<p className="text-sm text-muted-foreground">AI已为您生成剧本大纲</p>
<p className="text-sm text-muted-foreground">AI 已为您生成当前项目的大纲结果。</p>
</div>
<button
onClick={() => setShowManual(true)}
className="px-4 py-2 rounded-lg border border-border bg-card text-foreground flex items-center gap-2 hover:bg-muted transition-colors text-sm"
>
<RefreshCw className="w-4 h-4" />重新生成
<RefreshCw className="w-4 h-4" />
重新生成
</button>
</div>
......@@ -264,11 +525,15 @@ export function OutlineGeneration() {
<button
onClick={handleGenerate}
disabled={!script.trim() || generateOutline.isPending}
className="px-4 py-2 rounded-lg bg-primary text-white text-sm disabled:opacity-50"
className="px-4 py-2 rounded-lg bg-primary text-white text-sm disabled:opacity-50 flex items-center gap-2"
>
{generateOutline.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
{generateOutline.isPending ? "生成中..." : "确认生成"}
</button>
<button onClick={() => setShowManual(false)} className="px-4 py-2 rounded-lg border border-border text-sm">
<button
onClick={() => setShowManual(false)}
className="px-4 py-2 rounded-lg border border-border text-sm"
>
取消
</button>
</div>
......@@ -286,20 +551,20 @@ export function OutlineGeneration() {
<div className="grid grid-cols-2 gap-6 mb-6">
<div>
<label className="block text-xs text-muted-foreground mb-2">剧名</label>
<div className="text-xl font-semibold text-foreground">{outline?.title}</div>
<div className="text-xl font-semibold text-foreground">{outline.title}</div>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">类型</label>
<div className="text-xl font-semibold text-foreground">{outline?.genre}</div>
<div className="text-xl font-semibold text-foreground">{outline.genre}</div>
</div>
</div>
<div className="mb-4">
<label className="block text-xs text-muted-foreground mb-2">故事梗概</label>
<p className="text-sm text-foreground leading-relaxed">{outline?.synopsis}</p>
<p className="text-sm text-foreground leading-relaxed">{outline.synopsis}</p>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">集数</label>
<span className="px-3 py-1 rounded-lg bg-accent text-primary text-sm">{outline?.episodeCount}</span>
<span className="px-3 py-1 rounded-lg bg-accent text-primary text-sm">{outline.episodeCount}</span>
</div>
</div>
......@@ -309,9 +574,15 @@ export function OutlineGeneration() {
className="w-full py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all disabled:opacity-50"
>
{generateEpisodes.isPending ? (
<><Loader2 className="w-5 h-5 animate-spin" /><span>生成分集中...</span></>
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>生成分集中...</span>
</>
) : (
<><span>生成分集内容</span><ArrowRight className="w-5 h-5" /></>
<>
<span>生成分集内容</span>
<ArrowRight className="w-5 h-5" />
</>
)}
</button>
</div>
......
......@@ -271,6 +271,9 @@ function CharactersTab({
onNavigateChars: () => void;
onNavigateScenes: () => void;
}) {
const getMainCharacterImage = (char: import("../../lib/api/ai").Character) =>
char.frontImageUrl ?? char.imageUrl ?? char.sideImageUrl ?? char.backImageUrl ?? null;
return (
<div className="space-y-8">
{/* Characters */}
......@@ -306,8 +309,8 @@ function CharactersTab({
className="rounded-xl border border-border bg-card p-4 text-center cursor-pointer hover:border-primary transition-colors"
>
<div className="w-20 h-20 rounded-full overflow-hidden mx-auto mb-3 bg-muted flex items-center justify-center">
{char.imageUrl ? (
<img src={char.imageUrl} alt={char.name} className="w-full h-full object-cover" />
{getMainCharacterImage(char) ? (
<img src={getMainCharacterImage(char) ?? ""} alt={char.name} className="w-full h-full object-cover" />
) : (
<ImageIcon className="w-8 h-8 text-muted-foreground" />
)}
......
......@@ -5,6 +5,14 @@ import {
} from "lucide-react";
import { useTeamMembers, useUpdateMember, useRemoveMember, useAddMember } from "../../hooks/useTeam";
import { useProject, useUpdateProject } from "../../hooks/useProjects";
import {
aspectRatioOptions,
getAspectRatioLabel,
getProjectStyleLabel,
getResolutionLabel,
projectStyleOptions,
resolutionOptions,
} from "../../lib/projectStyles";
const ROLES = ["owner", "admin", "member"] as const;
type Role = typeof ROLES[number];
......@@ -37,10 +45,22 @@ export function ProjectSettings() {
const [inviteError, setInviteError] = useState("");
const [editingProject, setEditingProject] = useState(false);
const [projectForm, setProjectForm] = useState({ name: "", description: "" });
const [projectForm, setProjectForm] = useState({
name: "",
description: "",
style: "",
aspectRatio: "",
resolution: "",
});
const openEditProject = () => {
setProjectForm({ name: project?.name ?? "", description: project?.description ?? "" });
setProjectForm({
name: project?.name ?? "",
description: project?.description ?? "",
style: project?.style ?? "",
aspectRatio: project?.aspectRatio ?? "",
resolution: project?.resolution ?? "",
});
setEditingProject(true);
};
......
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useParams, useNavigate } from "react-router";
import {
Plus, Wand2, Play, Trash2, Copy,
......@@ -69,6 +69,9 @@ export function StoryboardWorkspace() {
const [generatingVideoId, setGeneratingVideoId] = useState<string | null>(null);
const [batchGenerating, setBatchGenerating] = useState(false);
const getCharacterPrimaryImage = (character: Character) =>
character.frontImageUrl ?? character.imageUrl ?? character.sideImageUrl ?? character.backImageUrl ?? null;
// Reset selection when episode changes
useEffect(() => {
setSelected(null);
......@@ -171,12 +174,59 @@ export function StoryboardWorkspace() {
setPromptChanged(true);
};
// 所有有 imageTosKey 的角色/场景,按顺序编号为 图1, 图2...
const refImages: Array<{ label: string; name: string; imageUrl: string | null; imageTosKey: string }> = [
...characters.filter((c) => c.imageTosKey).map((c) => ({ label: c.name, name: c.name, imageUrl: c.imageUrl, imageTosKey: c.imageTosKey! })),
...scenes.filter((s) => s.imageTosKey).map((s) => ({ label: s.name, name: s.name, imageUrl: s.imageUrl, imageTosKey: s.imageTosKey! })),
// 所有可引用的角色/场景参考图,角色支持正面/侧面/背面三视图。
const refImages = useMemo<Array<{
label: string;
name: string;
imageUrl: string | null;
imageTosKey: string;
kind: "character" | "scene";
viewLabel?: string;
}>>(() => {
const characterRefImages = characters.flatMap((character) => {
const views = [
{
viewLabel: "正面",
imageUrl: character.frontImageUrl ?? character.imageUrl ?? null,
imageTosKey: character.frontImageTosKey ?? character.imageTosKey ?? null,
},
{
viewLabel: "侧面",
imageUrl: character.sideImageUrl ?? null,
imageTosKey: character.sideImageTosKey ?? null,
},
{
viewLabel: "背面",
imageUrl: character.backImageUrl ?? null,
imageTosKey: character.backImageTosKey ?? null,
},
];
return views
.filter((view) => !!view.imageTosKey)
.map((view) => ({
label: `${character.name}·${view.viewLabel}`,
name: character.name,
imageUrl: view.imageUrl,
imageTosKey: view.imageTosKey!,
kind: "character" as const,
viewLabel: view.viewLabel,
}));
});
const sceneRefImages = scenes
.filter((scene) => !!scene.imageTosKey)
.map((scene) => ({
label: scene.name,
name: scene.name,
imageUrl: scene.imageUrl,
imageTosKey: scene.imageTosKey!,
kind: "scene" as const,
}));
return [...characterRefImages, ...sceneRefImages];
}, [characters, scenes]);
const insertMention = (name: string) => {
const mention = `@${name}`;
setPromptDraft((prev) => (prev ? prev + " " + mention : mention));
......@@ -459,7 +509,7 @@ export function StoryboardWorkspace() {
onClick={() => insertMention(c.name)}
className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-700 text-[11px] text-violet-700 dark:text-violet-300 hover:bg-violet-100 transition"
>
{c.imageUrl && <img src={c.imageUrl} className="w-3.5 h-3.5 rounded-full object-cover" />}
{getCharacterPrimaryImage(c) && <img src={getCharacterPrimaryImage(c) ?? ""} className="w-3.5 h-3.5 rounded-full object-cover" />}
@{c.name}
</button>
))}
......@@ -480,7 +530,9 @@ export function StoryboardWorkspace() {
{/* @图N:参考图面板 */}
{refImages.length > 0 && (
<div className="mt-3">
<div className="text-[11px] text-muted-foreground mb-1.5">@图N 引用参考图(生成视频时跳过文生图,直接用此图)</div>
<div className="text-[11px] text-muted-foreground mb-1.5">
@图N 引用参考图。角色支持正面、侧面、背面三视图,生成视频时会直接使用所选参考图。
</div>
<div className="flex gap-2 flex-wrap">
{refImages.map((img, idx) => (
<button
......@@ -497,7 +549,14 @@ export function StoryboardWorkspace() {
)}
</div>
<span className="text-[10px] text-primary font-medium">@图{idx + 1}</span>
<span className="text-[9px] text-muted-foreground leading-none max-w-[48px] truncate">{img.label}</span>
<span className="text-[9px] text-muted-foreground leading-none max-w-[56px] truncate">{img.label}</span>
<span
className={`text-[9px] leading-none ${
img.kind === "character" ? "text-violet-600" : "text-sky-600"
}`}
>
{img.kind === "character" ? img.viewLabel : "场景"}
</span>
</button>
))}
</div>
......
import { useState } from "react";
import { useNavigate, useParams } from "react-router";
import { Check, ArrowRight, Loader2 } from "lucide-react";
import { ArrowRight, Check, Loader2 } from "lucide-react";
import { useUpdateProject } from "../../hooks/useProjects";
const styles = [
{
id: "urban",
name: "都市情感",
description: "现代都市背景,情感纠葛,职场爱情",
image: "https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=400&h=300&fit=crop",
},
{
id: "fantasy",
name: "玄幻修仙",
description: "仙侠世界观,修炼升级,恢弘场景",
image: "https://images.unsplash.com/photo-1518709268805-4e9042af9f23?w=400&h=300&fit=crop",
},
{
id: "romance",
name: "甜宠恋爱",
description: "校园青春,浪漫爱情,轻松甜蜜",
image: "https://images.unsplash.com/photo-1522529599102-193c0d76b5b6?w=400&h=300&fit=crop",
},
{
id: "historical",
name: "古装宫廷",
description: "古代背景,宫廷权谋,历史传奇",
image: "https://images.unsplash.com/photo-1548198264-8fc644a0c8f1?w=400&h=300&fit=crop",
},
{
id: "suspense",
name: "悬疑推理",
description: "烧脑剧情,逻辑推理,紧张刺激",
image: "https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?w=400&h=300&fit=crop",
},
{
id: "comedy",
name: "轻喜剧",
description: "幽默搞笑,轻松愉快,生活趣事",
image: "https://images.unsplash.com/photo-1511632765486-a01980e01a18?w=400&h=300&fit=crop",
},
];
import { projectStyleOptions } from "../../lib/projectStyles";
export function StyleSelection() {
const navigate = useNavigate();
......@@ -56,58 +18,57 @@ export function StyleSelection() {
return (
<div className="h-full overflow-auto bg-background p-6">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mx-auto max-w-6xl">
<div className="mb-8 text-center">
<h1 className="text-3xl font-semibold text-foreground mb-2">选择剧集风格</h1>
<p className="text-sm text-muted-foreground">选择一个风格,AI将根据风格生成相应的大纲和场景</p>
<h1 className="mb-2 text-3xl font-semibold text-foreground">选择项目视觉风格</h1>
<p className="text-sm text-muted-foreground">
这里选择的是视觉呈现方式,不是题材类型。AI 会根据所选风格影响人物、场景与后续图像生成。
</p>
</div>
{/* Style Grid */}
<div className="grid grid-cols-3 gap-4 mb-8">
{styles.map((style) => (
<div className="mb-8 grid grid-cols-3 gap-4">
{projectStyleOptions.map((style) => (
<button
key={style.id}
onClick={() => setSelectedStyle(style.id)}
className={`group relative rounded-xl overflow-hidden transition-all border ${
selectedStyle === style.id
key={style.value}
onClick={() => setSelectedStyle(style.value)}
className={`group relative overflow-hidden rounded-xl border transition-all ${
selectedStyle === style.value
? "border-primary shadow-md"
: "border-border hover:border-primary/50"
}`}
>
<div className="aspect-[4/3] relative overflow-hidden">
<div className="relative aspect-[4/3] overflow-hidden">
<img
src={style.image}
alt={style.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
alt={style.label}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
{selectedStyle === style.id && (
<div className="absolute top-3 right-3 w-6 h-6 rounded-full bg-primary flex items-center justify-center">
<Check className="w-4 h-4 text-white" />
{selectedStyle === style.value && (
<div className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
<Check className="h-4 w-4 text-white" />
</div>
)}
</div>
<div className="p-4 bg-card">
<h3 className="font-medium text-foreground mb-1">{style.name}</h3>
<div className="bg-card p-4 text-left">
<h3 className="mb-1 font-medium text-foreground">{style.label}</h3>
<p className="text-sm text-muted-foreground">{style.description}</p>
</div>
</button>
))}
</div>
{/* Continue Button */}
<button
onClick={handleContinue}
disabled={!selectedStyle || updateProject.isPending}
className="w-full py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] py-3 text-white transition-all hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none"
>
{updateProject.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<>
<span>继续</span>
<ArrowRight className="w-5 h-5" />
<ArrowRight className="h-5 w-5" />
</>
)}
</button>
......
......@@ -22,7 +22,11 @@ export function useGenerateCharacterImage(projectId: string) {
}
export function useUploadCharacterImage(projectId: string) {
const qc = useQueryClient();
return useMutation({ mutationFn: ({ id, file }: { id: string; file: File }) => aiApi.uploadCharacterImage(projectId, id, file), onSuccess: () => qc.invalidateQueries({ queryKey: charactersKey(projectId) }) });
return useMutation({
mutationFn: ({ id, file, viewType }: { id: string; file: File; viewType: "front" | "side" | "back" }) =>
aiApi.uploadCharacterImage(projectId, id, file, viewType),
onSuccess: () => qc.invalidateQueries({ queryKey: charactersKey(projectId) }),
});
}
export function useDeleteCharacter(projectId: string) {
const qc = useQueryClient();
......
......@@ -56,6 +56,12 @@ export interface Character {
imagePrompt: string;
imageUrl: string | null;
imageTosKey: string | null;
frontImageUrl?: string | null;
frontImageTosKey?: string | null;
sideImageUrl?: string | null;
sideImageTosKey?: string | null;
backImageUrl?: string | null;
backImageTosKey?: string | null;
status: string;
createdAt: string;
}
......@@ -160,13 +166,26 @@ export const aiApi = {
return r.data.data;
},
generateCharacterImage: async (projectId: string, characterId: string): Promise<Character> => {
const r = await apiClient.post(`/projects/${projectId}/characters/${characterId}/generate-image`);
const r = await apiClient.post(
`/projects/${projectId}/characters/${characterId}/generate-image`,
undefined,
{ timeout: 180_000 }
);
return r.data.data;
},
uploadCharacterImage: async (projectId: string, characterId: string, file: File): Promise<Character> => {
uploadCharacterImage: async (
projectId: string,
characterId: string,
file: File,
viewType: "front" | "side" | "back" = "front"
): Promise<Character> => {
const form = new FormData();
form.append("file", file);
const r = await apiClient.post(`/projects/${projectId}/characters/${characterId}/upload-image`, form, { headers: { "Content-Type": undefined } });
const r = await apiClient.post(
`/projects/${projectId}/characters/${characterId}/upload-image?viewType=${viewType}`,
form,
{ headers: { "Content-Type": undefined } }
);
return r.data.data;
},
deleteCharacter: async (projectId: string, characterId: string): Promise<void> => {
......@@ -187,7 +206,11 @@ export const aiApi = {
return r.data.data;
},
generateSceneImage: async (projectId: string, sceneId: string): Promise<Scene> => {
const r = await apiClient.post(`/projects/${projectId}/scenes/${sceneId}/generate-image`);
const r = await apiClient.post(
`/projects/${projectId}/scenes/${sceneId}/generate-image`,
undefined,
{ timeout: 120_000 }
);
return r.data.data;
},
uploadSceneImage: async (projectId: string, sceneId: string, file: File): Promise<Scene> => {
......
......@@ -3,12 +3,17 @@ import { apiClient } from "./client";
export interface ProjectCreatePayload {
name: string;
description?: string;
style?: string;
aspectRatio?: string;
resolution?: string;
}
export interface ProjectUpdatePayload {
name?: string;
description?: string;
style?: string;
aspectRatio?: string;
resolution?: string;
}
export interface ProjectDTO {
......@@ -17,6 +22,8 @@ export interface ProjectDTO {
description?: string;
status: "draft" | "processing" | "completed" | "failed";
style?: string;
aspectRatio?: string;
resolution?: string;
coverUrl?: string;
assetCount: number;
createdAt: string;
......
export interface SelectOption {
value: string;
label: string;
helper?: string;
}
export interface ProjectStyleOption {
value: string;
label: string;
description: string;
image: string;
}
export const aspectRatioOptions: SelectOption[] = [
{ value: "16:9", label: "16:9", helper: "横屏" },
{ value: "9:16", label: "9:16", helper: "竖屏" },
{ value: "1:1", label: "1:1", helper: "方屏" },
];
export const resolutionOptions: SelectOption[] = [
{ value: "720p", label: "720p", helper: "标清" },
{ value: "1080p", label: "1080p", helper: "高清" },
{ value: "4K", label: "4K", helper: "超清" },
];
export const projectStyleOptions: ProjectStyleOption[] = [
{
value: "live_action_drama",
label: "真人短剧视觉",
description: "真人演员质感,贴近短剧成片观感,服装与人物比例更写实。",
image: "https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=400&h=300&fit=crop",
},
{
value: "japanese_anime",
label: "2D日漫",
description: "日系动画角色设计,线条干净,赛璐璐上色,角色辨识度高。",
image: "https://images.unsplash.com/photo-1519608487953-e999c86e7455?w=400&h=300&fit=crop",
},
{
value: "korean_webtoon",
label: "2D韩漫都市",
description: "韩漫都市感,时装感更强,光影柔和,适合情感与都市题材。",
image: "https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?w=400&h=300&fit=crop",
},
{
value: "chinese_anime",
label: "2D国漫",
description: "国漫人物设定,造型更利落,兼顾东方气质与动画表现力。",
image: "https://images.unsplash.com/photo-1514539079130-25950c84af65?w=400&h=300&fit=crop",
},
{
value: "chinese_fantasy_3d",
label: "3D国风仙侠",
description: "三维国风与仙侠质感,服饰层次更丰富,适合奇幻与古风项目。",
image: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&h=300&fit=crop",
},
{
value: "cg_cinematic",
label: "CG电影感",
description: "高质感 CG 角色设定,偏电影海报与游戏宣传级别的视觉效果。",
image: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32?w=400&h=300&fit=crop",
},
];
const projectStyleLabelMap = new Map(projectStyleOptions.map((option) => [option.value, option.label]));
const aspectRatioLabelMap = new Map(
aspectRatioOptions.map((option) => [option.value, option.helper ? `${option.label} ${option.helper}` : option.label])
);
const resolutionLabelMap = new Map(
resolutionOptions.map((option) => [option.value, option.helper ? `${option.label} ${option.helper}` : option.label])
);
export function getProjectStyleLabel(style?: string | null): string {
if (!style) return "未设置";
return projectStyleLabelMap.get(style) ?? style;
}
export function getAspectRatioLabel(aspectRatio?: string | null): string {
if (!aspectRatio) return "未设置";
return aspectRatioLabelMap.get(aspectRatio) ?? aspectRatio;
}
export function getResolutionLabel(resolution?: string | null): string {
if (!resolution) return "未设置";
return resolutionLabelMap.get(resolution) ?? resolution;
}
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