feat: 完成短剧项目空间与结构化资产创作闭环 (#38)
* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP * feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环 Refs #30 * fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置 * feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环 Refs #30 --------- Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
90
web/src/pages/projects/detail.tsx
Normal file
90
web/src/pages/projects/detail.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Alert, App, Button, Tooltip } from "antd";
|
||||
import { ArrowLeft, BookOpenText, Images, LayoutDashboard, LayoutGrid, Plus, Settings2, type LucideIcon } from "lucide-react";
|
||||
import { Link, Navigate, useNavigate, useParams } from "react-router";
|
||||
|
||||
import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync";
|
||||
import { getProject } from "@/services/api/projects";
|
||||
import { WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceErrorState, WorkspaceLoadingState } from "@/components/layout/workspace-state";
|
||||
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
|
||||
|
||||
import ProjectAssetsView from "./detail/assets";
|
||||
import ProjectCanvasesView from "./detail/canvases";
|
||||
import ProjectChaptersView from "./detail/chapters";
|
||||
import ProjectOverviewView from "./detail/overview";
|
||||
import ProjectSettingsView from "./detail/settings";
|
||||
|
||||
type DetailView = "overview" | "chapters" | "canvases" | "assets" | "settings";
|
||||
|
||||
const views: Array<{ key: DetailView; label: string; shortLabel: string; icon: LucideIcon }> = [
|
||||
{ key: "overview", label: "制作概览", shortLabel: "概览", icon: LayoutDashboard },
|
||||
{ key: "chapters", label: "剧情章节", shortLabel: "章节", icon: BookOpenText },
|
||||
{ key: "canvases", label: "项目画布", shortLabel: "画布", icon: LayoutGrid },
|
||||
{ key: "assets", label: "角色与资产", shortLabel: "资产", icon: Images },
|
||||
{ key: "settings", label: "项目设置", shortLabel: "设置", icon: Settings2 },
|
||||
];
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { projectId = "", view, chapterId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { message } = App.useApp();
|
||||
const activeView: DetailView = chapterId ? "chapters" : views.some((item) => item.key === view) ? view as DetailView : "overview";
|
||||
const detail = useQuery({ queryKey: ["project", projectId], queryFn: () => getProject(projectId), enabled: Boolean(projectId), refetchOnMount: "always" });
|
||||
const refreshProject = () => { void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); void queryClient.invalidateQueries({ queryKey: ["projects"] }); };
|
||||
const createCanvas = () => {
|
||||
if (detail.data?.project.status === "archived") { message.warning("项目已归档,请先在项目设置中恢复"); return; }
|
||||
void createCanvasProjectWithRemoteSync(`${detail.data?.project.name || "项目"} · 新画布`, projectId).then(({ id, syncError }) => {
|
||||
if (syncError) message.warning(syncError instanceof Error ? `画布已创建,项目关联稍后重试:${syncError.message}` : "画布已创建,项目关联稍后重试");
|
||||
else refreshProject();
|
||||
navigate(`/canvas/${id}`);
|
||||
}).catch((error) => message.error(error instanceof Error ? error.message : "画布创建失败"));
|
||||
};
|
||||
|
||||
if (detail.isLoading) return <WorkspacePage><WorkspaceLoadingState label="正在打开项目工作台" detail="读取章节、画布、资产和当前进度" /></WorkspacePage>;
|
||||
if (detail.isError || !detail.data) return <WorkspacePage><WorkspaceErrorState title="项目不可用" description="项目不存在、已被删除,或当前账号没有访问权限。" actionLabel="返回项目中心" onRetry={() => navigate("/projects")} /></WorkspacePage>;
|
||||
if (!chapterId && (!view || !views.some((item) => item.key === view))) return <Navigate to={`/projects/${projectId}/overview`} replace />;
|
||||
const chapterHref = projectChapterHref(detail.data.units, projectId, chapterId);
|
||||
return (
|
||||
<WorkspacePage className="project-workbench-page !overflow-hidden" fluid>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="shrink-0 border-b border-border/65 bg-background/80 px-3 py-2 backdrop-blur-md sm:px-4 lg:px-5 lg:py-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 lg:min-h-16 lg:flex-nowrap">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 lg:w-[250px] lg:flex-none xl:w-[290px]">
|
||||
<button type="button" onClick={() => navigate("/projects")} className="grid size-9 shrink-0 place-items-center rounded-md text-foreground/42 transition-colors hover:bg-foreground/[.055] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="返回项目" title="返回项目"><ArrowLeft className="size-4" /></button>
|
||||
<WorkspaceSignalIcon variant="projects" size="sm" />
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="min-w-0 truncate text-[13px] font-semibold text-foreground/90">{detail.data.project.name}</h1>
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${detail.data.project.status === "archived" ? "bg-foreground/30" : "bg-[var(--workspace-accent)]"}`} />
|
||||
<span className="hidden shrink-0 text-[10px] text-foreground/42 sm:inline">{detail.data.project.status === "archived" ? "已归档" : "进行中"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="thin-scrollbar order-last mt-1 flex h-11 w-full min-w-0 items-center gap-0.5 overflow-x-auto lg:order-none lg:mt-0 lg:h-16 lg:flex-1 lg:border-l lg:border-border/55 lg:pl-3" aria-label="项目导航">
|
||||
{views.map((item) => { const Icon = item.icon; const active = item.key === activeView; const href = item.key === "chapters" ? chapterHref : `/projects/${projectId}/${item.key}`; return <Link key={item.key} to={href} className={`relative flex h-11 shrink-0 items-center gap-2 rounded-md px-2.5 text-[13px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-3 ${active ? "bg-[var(--workspace-accent-soft)] font-medium text-foreground lg:after:absolute lg:after:inset-x-3 lg:after:bottom-0 lg:after:h-0.5 lg:after:rounded-full lg:after:bg-[var(--workspace-accent)]" : "text-foreground/52 hover:bg-foreground/[.045] hover:text-foreground"}`} aria-current={active ? "page" : undefined}><Icon className={`size-4 shrink-0 ${active ? "text-[var(--workspace-accent)]" : "text-foreground/45"}`} /><span className="sm:hidden">{item.shortLabel}</span><span className="hidden sm:inline">{item.label}</span></Link>; })}
|
||||
</nav>
|
||||
<Tooltip title="新建项目画布"><Button size="small" className="!h-9 !shrink-0 !px-2 sm:!px-3" icon={<Plus className="size-4" />} onClick={createCanvas} aria-label="新建项目画布"><span className="hidden sm:inline">新建画布</span></Button></Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
{detail.data.project.status === "archived" ? <Alert type="warning" showIcon banner message="项目已归档,恢复后才能创建画布和生成任务" className="!border-x-0 !border-t-0" /> : null}
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className={activeView === "chapters" ? "min-h-0 flex-1" : "thin-scrollbar min-h-0 flex-1 overflow-y-auto px-3 py-5 sm:px-5 lg:px-8 lg:py-7"}>
|
||||
<div className={activeView === "overview" ? "mx-auto w-full max-w-[1200px]" : activeView === "chapters" ? "h-full w-full" : "w-full"}>
|
||||
{activeView === "overview" ? <ProjectOverviewView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "chapters" ? <ProjectChaptersView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "canvases" ? <ProjectCanvasesView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "assets" ? <ProjectAssetsView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "settings" ? <ProjectSettingsView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</WorkspacePage>
|
||||
);
|
||||
}
|
||||
|
||||
function projectChapterHref(units: Array<{ id: string; position: number }>, projectId: string, routeChapterId?: string) {
|
||||
const rememberedId = sessionStorage.getItem(`project-active-chapter:${projectId}`) || "";
|
||||
const targetId = [routeChapterId, rememberedId].find((id) => id && units.some((unit) => unit.id === id)) || units.slice().sort((left, right) => left.position - right.position)[0]?.id;
|
||||
return targetId ? `/projects/${projectId}/chapters/${targetId}` : `/projects/${projectId}/chapters`;
|
||||
}
|
||||
210
web/src/pages/projects/detail/assets.tsx
Normal file
210
web/src/pages/projects/detail/assets.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Select, Tabs, type FormInstance } from "antd";
|
||||
import { Box, Check, ChevronDown, FileText, Image as ImageIcon, Link2, Music2, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react";
|
||||
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import {
|
||||
bindProjectCharacterVoice,
|
||||
confirmProjectAssetCandidate,
|
||||
createProjectAssetVersion,
|
||||
createProjectCharacter,
|
||||
linkProjectAsset,
|
||||
listVoiceProfiles,
|
||||
replaceProjectCharacterRepresentations,
|
||||
unbindProjectCharacterVoice,
|
||||
unlinkProjectAsset,
|
||||
updateProjectAssetCategory,
|
||||
updateProjectCharacter,
|
||||
type ProjectAsset,
|
||||
} from "@/services/api/projects";
|
||||
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { useAssetStore, type Asset, type AssetCategory, type AssetStatus, type EntityAsset, type ImageAsset } from "@/stores/use-asset-store";
|
||||
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
|
||||
import { ProjectCharacterCard } from "./project-character-card";
|
||||
import { generateCharacterTurnaround } from "./project-character-media";
|
||||
import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, type ProjectDetailViewProps } from "./shared";
|
||||
|
||||
const categories = ["all", "character", "environment", "wardrobe", "prop", "weapon", "style", "other"];
|
||||
const characterFields = [
|
||||
["role", "剧情定位与人物关系"], ["aliases", "别名"], ["appearance", "稳定外貌"], ["physique", "身高、体型与体态"],
|
||||
["clothing", "默认服装造型"], ["personality", "性格与表演基线"], ["props", "固定道具"],
|
||||
["consistencyPrompt", "跨镜头一致性约束"], ["multiViewPrompt", "三视图补充约束"],
|
||||
["voiceLanguage", "语言与口音"], ["voiceAge", "声音年龄感"], ["voiceTimbre", "音色气质"],
|
||||
] as const;
|
||||
|
||||
type CharacterForm = { name: string } & Record<(typeof characterFields)[number][0], string>;
|
||||
|
||||
export default function ProjectAssetsView({ detail, refreshProject }: ProjectDetailViewProps) {
|
||||
const { message } = App.useApp();
|
||||
const personalAssets = useAssetStore((state) => state.assets);
|
||||
const updatePersonalAsset = useAssetStore((state) => state.updateAsset);
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
const [category, setCategory] = useState("all");
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [assetId, setAssetId] = useState("");
|
||||
const [assetCategory, setAssetCategory] = useState("other");
|
||||
const [editorAsset, setEditorAsset] = useState<ProjectAsset | "new" | null>(null);
|
||||
const [imageAsset, setImageAsset] = useState<ProjectAsset | null>(null);
|
||||
const [voiceAsset, setVoiceAsset] = useState<ProjectAsset | null>(null);
|
||||
const [imageSelection, setImageSelection] = useState("");
|
||||
const [voiceProfileId, setVoiceProfileId] = useState("");
|
||||
const [voiceInstructions, setVoiceInstructions] = useState("");
|
||||
const [form] = Form.useForm<CharacterForm>();
|
||||
|
||||
const projectAssetIds = new Set(detail.assets.map((asset) => asset.id));
|
||||
const availableAssets = personalAssets.filter((asset) => !projectAssetIds.has(asset.id));
|
||||
const selectedPersonalAsset = personalAssets.find((asset) => asset.id === assetId);
|
||||
const imageAssets = personalAssets.filter((asset): asset is ImageAsset => asset.kind === "image");
|
||||
const pendingCandidates = detail.assetCandidates.filter((candidate) => candidate.category === "character" && candidate.status === "pending_confirmation");
|
||||
const characterAssets = detail.assets.filter((asset) => asset.category === "character" && asset.character);
|
||||
const mediaAssetCount = detail.assets.filter((asset) => asset.category !== "character").length;
|
||||
const visibleAssets = useMemo(() => category === "all" ? detail.assets : detail.assets.filter((asset) => asset.category === category), [category, detail.assets]);
|
||||
const categoryCounts = categories.map((value) => ({ value, count: value === "all" ? detail.assets.length : detail.assets.filter((asset) => asset.category === value).length }));
|
||||
const voices = useQuery({ queryKey: ["voice-profiles"], queryFn: listVoiceProfiles, enabled: Boolean(voiceAsset) });
|
||||
|
||||
const done = (content: string) => { refreshProject(); message.success(content); };
|
||||
const failed = (fallback: string) => (error: unknown) => message.error(error instanceof Error ? error.message : fallback);
|
||||
const addMutation = useMutation({ mutationFn: () => linkProjectAsset(detail.project.id, { assetId, category: assetCategory }), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory, status: asset.status as AssetStatus, primaryVersionId: asset.primaryVersionId }); setAddOpen(false); setAssetId(""); done("资产已加入项目"); }, onError: failed("资产加入失败") });
|
||||
const versionMutation = useMutation({ mutationFn: (id: string) => createProjectAssetVersion(detail.project.id, id, {}), onSuccess: () => done("已创建新版本"), onError: failed("版本创建失败") });
|
||||
const unlinkMutation = useMutation({ mutationFn: (id: string) => unlinkProjectAsset(detail.project.id, id), onSuccess: () => done("资产已移出项目"), onError: failed("资产移除失败") });
|
||||
const categoryMutation = useMutation({ mutationFn: ({ id, next }: { id: string; next: string }) => updateProjectAssetCategory(detail.project.id, id, next), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory }); done("资产分类已更新"); }, onError: failed("资产分类更新失败") });
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: ({ candidateId, targetAssetId }: { candidateId: string; targetAssetId?: string }) => confirmProjectAssetCandidate(detail.project.id, candidateId, targetAssetId),
|
||||
onSuccess: ({ asset }, variables) => { syncPersonalCharacterProjection(asset); done(variables.targetAssetId ? "候选信息已归并到角色新版本" : "角色卡已创建"); },
|
||||
onError: failed("角色确认失败"),
|
||||
});
|
||||
const saveCharacter = useMutation({
|
||||
mutationFn: async (values: CharacterForm) => {
|
||||
const definition = characterDefinition(values);
|
||||
return editorAsset === "new" ? createProjectCharacter(detail.project.id, { name: values.name, definition }) : updateProjectCharacter(detail.project.id, editorAsset!.id, { name: values.name, definition });
|
||||
},
|
||||
onSuccess: (result) => { syncPersonalCharacterProjection(result.asset); setEditorAsset(null); done(editorAsset === "new" ? "角色卡已创建" : "角色设定已保存并生成新版本"); },
|
||||
onError: failed("角色保存失败"),
|
||||
});
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: async (asset: ProjectAsset) => {
|
||||
if (!asset.character) throw new Error("角色版本信息不完整");
|
||||
const model = effectiveConfig.imageModel || effectiveConfig.model;
|
||||
const config = { ...effectiveConfig, model };
|
||||
if (!isAiConfigReady(config, model)) throw new Error("请先在设置中配置可用的图片模型");
|
||||
const representations = await generateCharacterTurnaround({ projectId: detail.project.id, assetId: asset.id, versionId: asset.character.versionId, name: asset.title, definition: asset.character.definition, config });
|
||||
return replaceProjectCharacterRepresentations(detail.project.id, asset.id, representations);
|
||||
},
|
||||
onSuccess: (result) => { syncPersonalCharacterProjection(result.asset); done("三视图已生成并绑定到新角色版本"); },
|
||||
onError: failed("三视图生成失败"),
|
||||
});
|
||||
const bindImagesMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!imageAsset) throw new Error("未选择角色");
|
||||
await saveRemoteUserDataNow();
|
||||
const latest = useAssetStore.getState().assets;
|
||||
const selected = latest.find((asset) => asset.id === imageSelection);
|
||||
if (selected?.kind !== "image") throw new Error("请选择一张包含正面、侧面和背面的三视图设定图");
|
||||
const resourceId = resourceIdFromStorageKey((selected as ImageAsset).data.storageKey);
|
||||
if (!resourceId) throw new Error("所选图片尚未同步到后端资源库");
|
||||
return replaceProjectCharacterRepresentations(detail.project.id, imageAsset.id, [{ role: "turnaround_sheet", resourceId, metadata: { sourceAssetId: selected.id } }, { role: "primary", resourceId, metadata: { source: "turnaround_sheet", sourceAssetId: selected.id } }]);
|
||||
},
|
||||
onSuccess: (result) => { syncPersonalCharacterProjection(result.asset); setImageAsset(null); done("三视图已绑定到新角色版本"); },
|
||||
onError: failed("三视图绑定失败"),
|
||||
});
|
||||
const bindVoiceMutation = useMutation({ mutationFn: () => voiceAsset ? bindProjectCharacterVoice(detail.project.id, voiceAsset.id, { voiceProfileId, instructions: voiceInstructions }) : Promise.reject(new Error("未选择角色")), onSuccess: (result) => { syncPersonalCharacterProjection(result.asset); setVoiceAsset(null); done("声音素材已绑定到新角色版本"); }, onError: failed("声音绑定失败") });
|
||||
const unbindVoiceMutation = useMutation({ mutationFn: () => voiceAsset ? unbindProjectCharacterVoice(detail.project.id, voiceAsset.id) : Promise.reject(new Error("未选择角色")), onSuccess: (result) => { syncPersonalCharacterProjection(result.asset); setVoiceAsset(null); done("声音绑定已解除并生成新角色版本"); }, onError: failed("声音解绑失败") });
|
||||
|
||||
const openCharacterEditor = (asset: ProjectAsset | "new") => {
|
||||
setEditorAsset(asset);
|
||||
const definition = asset === "new" ? {} : asset.character?.definition || {};
|
||||
form.setFieldsValue({ name: asset === "new" ? "" : asset.title, ...Object.fromEntries(characterFields.map(([key]) => [key, fieldValue(definition[key])])) } as CharacterForm);
|
||||
};
|
||||
const openImages = (asset: ProjectAsset) => { setImageAsset(asset); setImageSelection(""); };
|
||||
const openVoice = (asset: ProjectAsset) => { setVoiceAsset(asset); setVoiceProfileId(asset.character?.voice?.profile.id || ""); setVoiceInstructions(asset.character?.voice?.instructions || ""); };
|
||||
return (
|
||||
<div>
|
||||
<header className="flex min-h-[72px] flex-col gap-4 border-b border-border/70 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<WorkspaceSignalIcon variant="assets" size="sm" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
<h2 className="text-[18px] font-semibold leading-6">角色与资产</h2>
|
||||
<span className="rounded bg-foreground/[.055] px-1.5 py-1 text-[10px] font-medium tabular-nums text-foreground/45">{detail.assets.length} 项</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-foreground/48">
|
||||
<span className="inline-flex items-center gap-1.5"><UserRound className="size-3.5" />{characterAssets.length} 个角色</span>
|
||||
<span className="inline-flex items-center gap-1.5"><Box className="size-3.5" />{mediaAssetCount} 项媒体</span>
|
||||
{pendingCandidates.length ? <span className="inline-flex items-center gap-1.5 text-[var(--workspace-accent)]"><Sparkles className="size-3.5" />{pendingCandidates.length} 个待确认</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5 sm:pl-4">
|
||||
<Button type="text" className="!h-9 !px-3" icon={<Link2 className="size-3.5" />} onClick={() => setAddOpen(true)}>引用素材</Button>
|
||||
<Button type="primary" className="!h-9 !px-3.5" icon={<Plus className="size-3.5" />} onClick={() => openCharacterEditor("new")}>新建角色</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-[156px_minmax(0,1fr)]">
|
||||
<nav className="space-y-0.5 border-r border-border/70 pr-2" aria-label="资产分类">{categoryCounts.map((item) => <button key={item.value} type="button" onClick={() => setCategory(item.value)} className={`flex h-11 w-full items-center justify-between rounded-md px-2 text-left text-xs ${category === item.value ? "bg-foreground/[.08] font-medium" : "text-foreground/55 hover:bg-foreground/[.04]"}`}><span>{item.value === "all" ? "全部资产" : categoryLabels[item.value]}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[10px] tabular-nums">{item.count}</span></button>)}</nav>
|
||||
<div className="min-w-0">
|
||||
{(category === "all" || category === "character") && pendingCandidates.length ? <section className="mb-4"><div className="mb-2 flex items-center gap-1.5 text-xs font-medium"><Sparkles className="size-3.5 text-[var(--workspace-accent)]" />剧情识别出的角色</div><div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">{pendingCandidates.map((candidate) => <article key={candidate.id} className="flex min-h-28 items-center gap-3 rounded-lg border border-dashed border-border p-3"><span className="grid size-12 shrink-0 place-items-center rounded-md bg-foreground/[.045] text-foreground/25"><UserRound className="size-5" /></span><div className="min-w-0 flex-1"><div className="truncate text-xs font-semibold">{candidate.name}</div><div className="mt-1 text-[10px] text-foreground/42">待确认角色卡 · 来自章节分析</div><div className="mt-2 flex min-w-0 items-center gap-1"><Button type="text" size="small" icon={<Check className="size-3.5" />} loading={confirmMutation.isPending} onClick={() => confirmMutation.mutate({ candidateId: candidate.id })}>确认新角色</Button>{characterAssets.length ? <Dropdown trigger={["click"]} menu={{ items: characterAssets.map((asset) => ({ key: asset.id, label: asset.title })), onClick: ({ key }) => confirmMutation.mutate({ candidateId: candidate.id, targetAssetId: key }) }}><Button type="text" size="small" disabled={confirmMutation.isPending}>归并到角色<ChevronDown className="size-3" /></Button></Dropdown> : null}</div></div></article>)}</div></section> : null}
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-foreground/45"><span>{category === "all" ? "全部资产" : categoryLabel(category)}</span><span>{visibleAssets.length} 项</span></div>
|
||||
{visibleAssets.length ? <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} generating={generateMutation.isPending && generateMutation.variables?.id === asset.id} removing={unlinkMutation.isPending && unlinkMutation.variables === asset.id} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div> : <WorkspaceState icon="assets" compact title="这个分类还没有资产" description={category === "character" ? "新建角色,或先从剧情章节提取角色信息。" : "可从个人素材库引用,或切换到其他分类查看。"} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal title="引用个人素材" open={addOpen} okText="加入项目" cancelText="取消" okButtonProps={{ disabled: !assetId, loading: addMutation.isPending }} onCancel={() => setAddOpen(false)} onOk={() => addMutation.mutate()} width={480}><div className="grid gap-3"><Select showSearch optionFilterProp="label" value={assetId || undefined} placeholder="选择未加入项目的素材" options={availableAssets.map((asset) => ({ label: `${asset.title} · ${mediaLabel(asset.kind)}`, value: asset.id }))} onChange={(value) => { setAssetId(value); const next = personalAssets.find((asset) => asset.id === value); setAssetCategory(next?.kind === "entity" ? "character" : next?.category || "other"); }} /><Select value={assetCategory} disabled={selectedPersonalAsset?.kind === "entity"} options={selectedPersonalAsset?.kind === "entity" ? [{ value: "character", label: "角色" }] : Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ value, label }))} onChange={setAssetCategory} /></div></Modal>
|
||||
<CharacterEditorModal open={Boolean(editorAsset)} editing={editorAsset !== "new"} form={form} loading={saveCharacter.isPending} onClose={() => setEditorAsset(null)} onSave={() => form.validateFields().then((values) => saveCharacter.mutate(values))} />
|
||||
<Modal title={`绑定单张三视图 · ${imageAsset?.title || ""}`} open={Boolean(imageAsset)} okText="绑定并生成新版本" cancelText="取消" width={720} okButtonProps={{ loading: bindImagesMutation.isPending, disabled: !imageSelection }} onCancel={() => setImageAsset(null)} onOk={() => bindImagesMutation.mutate()}>{imageAssets.length ? <div className="grid max-h-[420px] gap-3 overflow-y-auto sm:grid-cols-3">{imageAssets.map((asset) => <button type="button" key={asset.id} onClick={() => setImageSelection(asset.id)} className={`overflow-hidden rounded-lg border text-left transition ${imageSelection === asset.id ? "border-[var(--workspace-accent)] ring-2 ring-[var(--workspace-accent)]/20" : "border-border/70 hover:border-foreground/35"}`}><div className="relative aspect-[4/3] bg-foreground/[.045]"><img src={asset.data.dataUrl} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-cover" />{imageSelection === asset.id ? <span className="absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-[var(--workspace-accent)] text-white"><Check className="size-4" /></span> : null}</div><div className="truncate px-2 py-2 text-[11px] font-medium">{asset.title}</div></button>)}</div> : <WorkspaceState icon="assets" compact title="个人图片素材为空" description="需要一张包含正面、侧面、背面的角色设定图。" />}</Modal>
|
||||
<Modal title={`选择声音素材 · ${voiceAsset?.title || ""}`} open={Boolean(voiceAsset)} okText="绑定并生成新版本" cancelText="取消" okButtonProps={{ loading: bindVoiceMutation.isPending, disabled: !voiceProfileId }} onCancel={() => setVoiceAsset(null)} onOk={() => bindVoiceMutation.mutate()}><div className="grid gap-3"><Select loading={voices.isLoading} showSearch optionFilterProp="label" value={voiceProfileId || undefined} placeholder="选择声音" options={(voices.data?.profiles || []).map((voice) => ({ label: `${voice.name} · ${voice.language}`, value: voice.id }))} onChange={setVoiceProfileId} /><Input.TextArea rows={3} value={voiceInstructions} placeholder="表演指令,例如:克制、温暖、语速稍慢" onChange={(event) => setVoiceInstructions(event.target.value)} />{voiceAsset?.character && voiceAsset.character.voiceStatus !== "missing" ? <div className="flex items-center justify-between border-t border-border/70 pt-2"><span className="text-[11px] text-foreground/45">当前绑定:{voiceAsset.character.voice?.profile.name || "声音素材不可用"}</span><Popconfirm title="解除当前声音绑定?" description="该操作会保留历史版本,并创建一个未绑定声音的新版本。" okText="解除" cancelText="取消" onConfirm={() => unbindVoiceMutation.mutate()}><Button type="text" danger size="small" loading={unbindVoiceMutation.isPending} icon={<VolumeX className="size-3.5" />}>解除声音</Button></Popconfirm></div> : null}</div></Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }: { open: boolean; editing: boolean; form: FormInstance<CharacterForm>; loading: boolean; onClose: () => void; onSave: () => void }) {
|
||||
const field = (key: CharacterFormKey) => characterFields.find(([name]) => name === key)!;
|
||||
const textArea = (key: CharacterFormKey, rows = 3) => <Form.Item name={key} label={field(key)[1]}><Input.TextArea rows={rows} placeholder={key === "appearance" ? "先描述用户能看到的稳定特征…" : undefined} /></Form.Item>;
|
||||
const input = (key: CharacterFormKey) => <Form.Item name={key} label={field(key)[1]}><Input /></Form.Item>;
|
||||
return <Modal title={null} open={open} forceRender width={860} okText={editing ? "保存角色设定" : "创建角色卡"} cancelText="取消" okButtonProps={{ loading }} onCancel={onClose} onOk={onSave} styles={{ body: { paddingTop: 0 } }}>
|
||||
<div className="mb-1 border-b border-border/70 pb-4"><div className="text-[11px] font-medium text-[var(--workspace-accent)]">角色设定</div><h2 className="mt-1 text-xl font-semibold">{editing ? "调整角色设定" : "建立一张角色卡"}</h2></div>
|
||||
<Form form={form} layout="vertical" requiredMark={false} className="pt-2"><Form.Item name="name" label="角色名称" rules={[{ required: true, message: "请输入角色名称" }]}><Input size="large" placeholder="例如:林默" /></Form.Item><Tabs items={[{ key: "identity", label: "身份与外观", forceRender: true, children: <div className="grid gap-x-5 sm:grid-cols-2">{input("role")}{input("aliases")}{textArea("appearance", 4)}{input("physique")}{input("clothing")}{input("props")}{textArea("consistencyPrompt", 4)}{textArea("multiViewPrompt", 3)}</div> }, { key: "performance", label: "表演与声音", forceRender: true, children: <div className="grid gap-x-5 sm:grid-cols-2">{textArea("personality", 4)}{input("voiceLanguage")}{input("voiceAge")}{input("voiceTimbre")}</div> }]} /></Form>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
type CharacterFormKey = (typeof characterFields)[number][0];
|
||||
|
||||
function MediaAssetCard({ asset, personalAsset, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
|
||||
const cover = personalAsset ? assetCover(personalAsset) : "";
|
||||
return <article className="overflow-hidden rounded-lg border border-border/80 bg-background"><div className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]">{cover ? <img src={cover} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-cover" /> : <div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>}<div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[9px] text-white">{mediaLabel(asset.mediaType)}</span></div></div><div className="p-2.5"><div className="flex items-start justify-between gap-2"><div className="min-w-0"><h3 className="truncate text-xs font-medium">{asset.title}</h3><div className="mt-1 flex items-center gap-1.5 text-[10px] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[10px] text-foreground/50 hover:bg-foreground/[.055]"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span></div></div><Link2 className="mt-0.5 size-3.5 shrink-0 text-[var(--workspace-accent)]" /></div><div className="mt-2 flex items-center justify-between gap-2 border-t border-border/60 pt-1.5"><span className="text-[9px] text-foreground/38">{formatTime(asset.updatedAt)}</span><div className="flex items-center"><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} /></Popconfirm></div></div></div></article>;
|
||||
}
|
||||
|
||||
function characterDefinition(values: CharacterForm) {
|
||||
const definition = Object.fromEntries(characterFields.map(([key]) => [key, values[key]?.trim() || (key === "aliases" ? [] : "")]));
|
||||
definition.aliases = values.aliases?.split(/[,,]/).map((item) => item.trim()).filter(Boolean) || [];
|
||||
return definition;
|
||||
}
|
||||
|
||||
function fieldValue(value: unknown) { return Array.isArray(value) ? value.join(",") : typeof value === "string" ? value : ""; }
|
||||
function syncPersonalCharacterProjection(asset: ProjectAsset) {
|
||||
if (!asset.character) return;
|
||||
const current = useAssetStore.getState().assets;
|
||||
const existing = current.find((item) => item.id === asset.id);
|
||||
const cover = asset.character.representations.find((item) => item.role === "turnaround_sheet") || asset.character.representations.find((item) => item.role === "primary") || asset.character.representations.find((item) => item.role === "front");
|
||||
const projected: EntityAsset = {
|
||||
id: asset.id,
|
||||
kind: "entity",
|
||||
title: asset.title,
|
||||
coverUrl: cover ? resourceFileUrl(cover.resourceId) : existing?.coverUrl || "",
|
||||
tags: existing?.tags || [],
|
||||
category: "character",
|
||||
status: asset.status as AssetStatus,
|
||||
primaryVersionId: asset.primaryVersionId,
|
||||
source: existing?.source || "project-character",
|
||||
createdAt: existing?.createdAt || asset.updatedAt,
|
||||
updatedAt: asset.updatedAt,
|
||||
data: { definition: asset.character.definition },
|
||||
};
|
||||
useAssetStore.getState().replaceAssets([projected, ...current.filter((item) => item.id !== asset.id)]);
|
||||
}
|
||||
function assetCover(asset: Asset) { if (asset.coverUrl) return asset.coverUrl; if (asset.kind === "image") return asset.data.dataUrl; if (asset.kind === "video") return asset.data.url; return ""; }
|
||||
function MediaIcon({ kind }: { kind: string }) { if (kind === "image") return <ImageIcon className="size-10" />; if (kind === "video") return <Video className="size-10" />; if (kind === "audio") return <Music2 className="size-10" />; if (kind === "model") return <Box className="size-10" />; return <FileText className="size-10" />; }
|
||||
79
web/src/pages/projects/detail/canvases.tsx
Normal file
79
web/src/pages/projects/detail/canvases.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { App, Button, Popconfirm, Select, Tooltip } from "antd";
|
||||
import { ArrowUpRight, Film, Link2, Unlink, X } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { linkCanvasUnit, unlinkCanvasProject, unlinkCanvasUnit } from "@/services/api/projects";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
|
||||
import { formatTime, type ProjectDetailViewProps } from "./shared";
|
||||
|
||||
export default function ProjectCanvasesView({ detail, refreshProject }: ProjectDetailViewProps) {
|
||||
const { message } = App.useApp();
|
||||
const [linkingCanvasId, setLinkingCanvasId] = useState("");
|
||||
const localCanvases = useCanvasStore((state) => state.projects);
|
||||
const linkMutation = useMutation({
|
||||
mutationFn: ({ canvasId, unitId }: { canvasId: string; unitId: string }) => linkCanvasUnit(detail.project.id, { canvasId, unitId, role: "storyboard" }),
|
||||
onSuccess: () => { setLinkingCanvasId(""); refreshProject(); message.success("画布已关联章节"); },
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "画布关联失败"),
|
||||
});
|
||||
const unlinkUnitMutation = useMutation({
|
||||
mutationFn: ({ canvasId, unitId }: { canvasId: string; unitId: string }) => unlinkCanvasUnit(detail.project.id, canvasId, unitId),
|
||||
onSuccess: () => { refreshProject(); message.success("已解除章节关联"); },
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "解除章节关联失败"),
|
||||
});
|
||||
const unlinkProjectMutation = useMutation({
|
||||
mutationFn: (canvasId: string) => unlinkCanvasProject(detail.project.id, canvasId),
|
||||
onSuccess: (_, canvasId) => {
|
||||
// 服务端解除后立即同步本地画布归属,避免后续自动保存把旧关系重新写回。
|
||||
useCanvasStore.getState().updateProject(canvasId, { projectId: undefined });
|
||||
refreshProject();
|
||||
message.success("已解除项目关系,画布文档仍保留在创作画布中");
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "解除项目关系失败"),
|
||||
});
|
||||
const linksByCanvas = useMemo(() => detail.canvasUnitLinks.reduce<Record<string, typeof detail.canvasUnitLinks>>((result, link) => { (result[link.canvasId] ||= []).push(link); return result; }, {}), [detail.canvasUnitLinks]);
|
||||
const canvases = useMemo(() => detail.canvases.map((canvas) => {
|
||||
const local = localCanvases.find((item) => item.id === canvas.id && item.projectId === detail.project.id);
|
||||
if (!local || Date.parse(local.updatedAt) < Date.parse(canvas.updatedAt)) return canvas;
|
||||
return { ...canvas, title: local.title, updatedAt: local.updatedAt };
|
||||
}).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)), [detail.canvases, detail.project.id, localCanvases]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canvases.length ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,360px))] justify-start gap-3">
|
||||
{canvases.map((canvas, index) => {
|
||||
const links = linksByCanvas[canvas.id] || [];
|
||||
const linkedUnits = links.map((link) => detail.units.find((unit) => unit.id === link.unitId)).filter(Boolean);
|
||||
const unlinkedUnits = detail.units.filter((unit) => !links.some((link) => link.unitId === unit.id));
|
||||
return (
|
||||
<article key={canvas.id} className="min-w-0 overflow-hidden rounded-lg border border-border/80 bg-background">
|
||||
<Link to={`/canvas/${canvas.id}`} className="group relative block h-24 overflow-hidden border-b border-border/70 bg-[#111827] p-3 text-white">
|
||||
<div className="absolute inset-0 opacity-40" style={{ backgroundImage: "linear-gradient(rgba(255,255,255,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.08) 1px, transparent 1px)", backgroundSize: "20px 20px" }} />
|
||||
<div className="relative flex h-full flex-col justify-between"><div className="flex items-center justify-between"><span className="flex items-center gap-1.5 text-[10px] text-white/55"><Film className="size-3" />画布 {String(index + 1).padStart(2, "0")}</span><ArrowUpRight className="size-3.5 text-white/45 group-hover:text-[var(--workspace-accent)]" /></div><div><div className="truncate text-[13px] font-semibold">{canvas.title}</div><div className="mt-0.5 text-[9px] text-white/48">更新于 {formatTime(canvas.updatedAt)}</div></div></div>
|
||||
</Link>
|
||||
<div className="p-2.5">
|
||||
<div className="flex items-center justify-between"><span className="text-[10px] font-medium text-foreground/48">关联章节</span><span className="text-[9px] tabular-nums text-foreground/38">{linkedUnits.length} 个</span></div>
|
||||
<div className="mt-1.5 flex min-h-6 max-h-12 flex-wrap gap-1 overflow-y-auto">
|
||||
{linkedUnits.length ? linkedUnits.map((unit) => (
|
||||
<span key={unit!.id} className="inline-flex h-5 max-w-full items-center gap-1 rounded bg-[var(--workspace-accent-soft)] pl-1.5 pr-0.5 text-[9px] text-[var(--workspace-accent)]"><span className="truncate">{String(unit!.position + 1).padStart(2, "0")} · {unit!.title}</span><Tooltip title="解除章节关联"><button type="button" className="grid size-4 shrink-0 place-items-center rounded hover:bg-foreground/[.07]" aria-label={`解除${unit!.title}关联`} onClick={() => unlinkUnitMutation.mutate({ canvasId: canvas.id, unitId: unit!.id })}><X className="size-3" /></button></Tooltip></span>
|
||||
)) : <span className="py-0.5 text-[10px] text-foreground/38">尚未关联章节</span>}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5 border-t border-border/60 pt-2">
|
||||
<Select size="small" className="min-w-0 flex-1" placeholder={unlinkedUnits.length ? "关联更多章节" : "全部章节已关联"} disabled={!unlinkedUnits.length} options={unlinkedUnits.map((unit) => ({ label: `${String(unit.position + 1).padStart(2, "0")} · ${unit.title}`, value: unit.id }))} onChange={(unitId) => { setLinkingCanvasId(canvas.id); linkMutation.mutate({ canvasId: canvas.id, unitId }); }} loading={linkMutation.isPending && linkingCanvasId === canvas.id} suffixIcon={<Link2 className="size-3.5" />} />
|
||||
<Popconfirm title="解除画布与项目的关系?" description="画布文档不会删除,之后仍可在“画布”中打开。" okText="解除关系" cancelText="取消" okButtonProps={{ danger: true, loading: unlinkProjectMutation.isPending }} onConfirm={() => unlinkProjectMutation.mutate(canvas.id)}>
|
||||
<Tooltip title="解除项目关系"><Button size="small" type="text" danger icon={<Unlink className="size-3.5" />} aria-label="解除项目关系" /></Tooltip>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <WorkspaceState icon="canvas" title="还没有项目画布" description="使用右上角的新建画布开始创作。" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
554
web/src/pages/projects/detail/chapters.tsx
Normal file
554
web/src/pages/projects/detail/chapters.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
import { useDeferredValue, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { App, Button, Dropdown, Form, Input, InputNumber, Modal, Popconfirm, Tooltip } from "antd";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import Color from "@tiptap/extension-color";
|
||||
import Highlight from "@tiptap/extension-highlight";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignJustify,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Bold,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clapperboard,
|
||||
Code2,
|
||||
Crosshair,
|
||||
Eraser,
|
||||
FileUp,
|
||||
GripVertical,
|
||||
Highlighter,
|
||||
Italic,
|
||||
Link2,
|
||||
List,
|
||||
ListOrdered,
|
||||
Minus,
|
||||
MoreHorizontal,
|
||||
MoveVertical,
|
||||
Plus,
|
||||
Quote,
|
||||
Redo2,
|
||||
Save,
|
||||
Search,
|
||||
Strikethrough,
|
||||
Trash2,
|
||||
Underline,
|
||||
Undo2,
|
||||
UsersRound,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { canvasStylePresets } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { normalizeCharacterName } from "@/lib/canvas/canvas-character-reference";
|
||||
import { decodeNovelText, splitTextIntoChapters } from "@/lib/canvas/canvas-document";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import {
|
||||
createProjectAssetCandidates,
|
||||
createProjectUnit,
|
||||
deleteProjectUnit,
|
||||
getProjectUnit,
|
||||
importProjectUnits,
|
||||
reorderProjectUnits,
|
||||
replaceProjectUnitShots,
|
||||
updateProjectUnit,
|
||||
type ProjectUnit,
|
||||
} from "@/services/api/projects";
|
||||
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
|
||||
import { formatCount, formatTime, statusLabel, type ProjectDetailViewProps } from "./shared";
|
||||
import { extractChapterCharacters, generateChapterStoryboard } from "./project-chapter-ai";
|
||||
|
||||
const CHAPTER_ROW_HEIGHT = 52;
|
||||
const MAX_NOVEL_IMPORT_CHAPTERS = 2500;
|
||||
|
||||
export default function ProjectChaptersView({ detail, refreshProject }: ProjectDetailViewProps) {
|
||||
const { message, modal } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { chapterId = "" } = useParams();
|
||||
const initialSelectedId = detail.units.some((unit) => unit.id === chapterId) ? chapterId : detail.units[0]?.id || "";
|
||||
const [selectedId, setSelectedId] = useState(initialSelectedId);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [moveTargetId, setMoveTargetId] = useState("");
|
||||
const [movePosition, setMovePosition] = useState<number | null>(null);
|
||||
const [orderedIds, setOrderedIds] = useState(() => detail.units.map((unit) => unit.id));
|
||||
const [draggedId, setDraggedId] = useState("");
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [draftHtml, setDraftHtml] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [analysisKind, setAnalysisKind] = useState<"characters" | "storyboard" | "">("");
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery.trim().toLocaleLowerCase("zh-CN"));
|
||||
const orderedUnits = useMemo(() => {
|
||||
const byId = new Map(detail.units.map((unit) => [unit.id, unit]));
|
||||
return orderedIds.map((id) => byId.get(id)).filter((unit): unit is ProjectUnit => Boolean(unit));
|
||||
}, [detail.units, orderedIds]);
|
||||
const selectedUnitSummary = detail.units.find((unit) => unit.id === selectedId) || orderedUnits[0];
|
||||
const selectedUnitQuery = useQuery({
|
||||
queryKey: ["project-unit", detail.project.id, selectedId],
|
||||
queryFn: () => getProjectUnit(detail.project.id, selectedId),
|
||||
enabled: Boolean(selectedId),
|
||||
});
|
||||
const selectedUnit = selectedUnitQuery.data?.unit || selectedUnitSummary;
|
||||
const chapterNumberById = useMemo(() => new Map(orderedUnits.map((unit, index) => [unit.id, index + 1])), [orderedUnits]);
|
||||
const canvasCountByUnitId = useMemo(() => detail.canvasUnitLinks.reduce<Map<string, number>>((result, link) => result.set(link.unitId, (result.get(link.unitId) || 0) + 1), new Map()), [detail.canvasUnitLinks]);
|
||||
const visibleUnits = useMemo(() => {
|
||||
if (!deferredSearchQuery) return orderedUnits;
|
||||
const numericQuery = /^\d+$/.test(deferredSearchQuery) ? deferredSearchQuery.replace(/^0+/, "") || "0" : "";
|
||||
return orderedUnits.filter((unit, index) => (numericQuery && String(index + 1).startsWith(numericQuery)) || unit.title.toLocaleLowerCase("zh-CN").includes(deferredSearchQuery));
|
||||
}, [deferredSearchQuery, orderedUnits]);
|
||||
const chapterVirtualizer = useVirtualizer({
|
||||
count: visibleUnits.length,
|
||||
getScrollElement: () => listRef.current,
|
||||
estimateSize: () => CHAPTER_ROW_HEIGHT,
|
||||
getItemKey: (index) => visibleUnits[index]?.id || index,
|
||||
initialOffset: () => readStoredScroll(`project-chapters:${detail.project.id}`),
|
||||
onChange: (instance, scrolling) => {
|
||||
if (!scrolling && instance.scrollOffset !== null) sessionStorage.setItem(`project-chapters:${detail.project.id}`, String(instance.scrollOffset));
|
||||
},
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setOrderedIds(detail.units.slice().sort((left, right) => left.position - right.position).map((unit) => unit.id));
|
||||
if (!detail.units.some((unit) => unit.id === selectedId)) setSelectedId(detail.units[0]?.id || "");
|
||||
}, [detail.units, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chapterId || chapterId === selectedId || dirty || !detail.units.some((unit) => unit.id === chapterId)) return;
|
||||
setSelectedId(chapterId);
|
||||
}, [chapterId, detail.units, dirty, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chapterId || dirty || detail.units.some((unit) => unit.id === chapterId)) return;
|
||||
const firstId = orderedUnits[0]?.id;
|
||||
navigate(firstId ? `/projects/${detail.project.id}/chapters/${firstId}` : `/projects/${detail.project.id}/chapters`, { replace: true });
|
||||
}, [chapterId, detail.project.id, detail.units, dirty, navigate, orderedUnits]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) sessionStorage.setItem(`project-active-chapter:${detail.project.id}`, selectedId);
|
||||
}, [detail.project.id, selectedId]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => selectedUnitQuery.data?.unit
|
||||
? updateProjectUnit(detail.project.id, selectedUnitQuery.data.unit.id, {
|
||||
title: draftTitle.trim(),
|
||||
sourceText: draftHtml,
|
||||
status: stripHtml(draftHtml) ? "ready" : "draft",
|
||||
})
|
||||
: Promise.reject(new Error("请选择章节")),
|
||||
onSuccess: ({ unit }) => { queryClient.setQueryData(["project-unit", detail.project.id, unit.id], { unit }); setDirty(false); refreshProject(); message.success("章节已保存"); },
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "章节保存失败"),
|
||||
});
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (values: { title: string; sourceText?: string }) => createProjectUnit(detail.project.id, { kind: "chapter", title: values.title, sourceText: plainTextToHtml(values.sourceText || ""), position: detail.units.length }),
|
||||
onSuccess: ({ unit }) => { setCreateOpen(false); setSelectedId(unit.id); refreshProject(); navigate(`/projects/${detail.project.id}/chapters/${unit.id}`); message.success("章节已创建"); },
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "章节创建失败"),
|
||||
});
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (chapters: Array<{ title: string; plainText: string }>) => importProjectUnits(detail.project.id, chapters.map((chapter) => ({ kind: "chapter", title: chapter.title, sourceText: plainTextToHtml(chapter.plainText) }))),
|
||||
onSuccess: ({ units }) => { setImportOpen(false); if (units[0]) { setSelectedId(units[0].id); navigate(`/projects/${detail.project.id}/chapters/${units[0].id}`); } refreshProject(); message.success(`已导入 ${units.length} 章`); },
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "小说导入失败"),
|
||||
});
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (unitIds: string[]) => reorderProjectUnits(detail.project.id, unitIds),
|
||||
onSuccess: () => { refreshProject(); message.success("章节顺序已更新"); },
|
||||
onError: (error) => { refreshProject(); message.error(error instanceof Error ? error.message : "章节排序失败"); },
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (unitId: string) => deleteProjectUnit(detail.project.id, unitId),
|
||||
onSuccess: (_, unitId) => {
|
||||
const index = orderedIds.indexOf(unitId);
|
||||
const remaining = orderedIds.filter((id) => id !== unitId);
|
||||
setOrderedIds(remaining);
|
||||
if (selectedId === unitId) {
|
||||
const nextId = remaining[Math.min(index, remaining.length - 1)] || "";
|
||||
setSelectedId(nextId);
|
||||
navigate(nextId ? `/projects/${detail.project.id}/chapters/${nextId}` : `/projects/${detail.project.id}/chapters`, { replace: true });
|
||||
}
|
||||
refreshProject();
|
||||
message.success("章节已删除");
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "章节删除失败"),
|
||||
});
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: { openOnClick: false, autolink: true } }),
|
||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||
TextStyle,
|
||||
Color.configure({ types: ["textStyle"] }),
|
||||
Highlight.configure({ multicolor: true }),
|
||||
CharacterCount,
|
||||
Placeholder.configure({ placeholder: "从这一章开始写下故事……" }),
|
||||
],
|
||||
content: selectedUnitQuery.data?.unit.sourceText || "",
|
||||
editorProps: { attributes: { class: "project-chapter-editor focus:outline-none" } },
|
||||
onUpdate: ({ editor: nextEditor }) => { setDraftHtml(nextEditor.getHTML()); setDirty(true); },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedUnitSummary) return;
|
||||
setDraftTitle(selectedUnitSummary.title);
|
||||
setDraftHtml("");
|
||||
setDirty(false);
|
||||
}, [selectedUnitSummary?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadedUnit = selectedUnitQuery.data?.unit;
|
||||
if (!loadedUnit || !editor) return;
|
||||
setDraftTitle(loadedUnit.title);
|
||||
setDraftHtml(loadedUnit.sourceText || "");
|
||||
setDirty(false);
|
||||
editor.commands.setContent(loadedUnit.sourceText || "", { emitUpdate: false });
|
||||
// 只在切换章节时装载服务端内容,避免项目刷新覆盖当前未保存正文。
|
||||
}, [editor, selectedUnitQuery.data?.unit]);
|
||||
|
||||
const wordCount = useMemo(() => editor?.storage.characterCount?.characters?.() || stripHtml(draftHtml).length || 0, [draftHtml, editor]);
|
||||
const chapterCanvasCount = (unitId: string) => canvasCountByUnitId.get(unitId) || 0;
|
||||
const chapterShotCount = (unitId: string) => detail.shots.filter((shot) => shot.unitId === unitId).length;
|
||||
const chapterAnalysisInput = () => {
|
||||
const unit = selectedUnitQuery.data?.unit;
|
||||
if (!unit) throw new Error("章节正文尚未加载完成");
|
||||
if (dirty) throw new Error("请先保存当前章节,再运行 AI 分析");
|
||||
const sourceText = editor?.getText().trim() || stripHtml(unit.sourceText);
|
||||
if (!sourceText) throw new Error("当前章节没有可分析的正文");
|
||||
const textModel = effectiveConfig.textModel || effectiveConfig.model;
|
||||
if (!isAiConfigReady(effectiveConfig, textModel)) {
|
||||
navigateToSettings({ continueCreation: true });
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
projectId: detail.project.id,
|
||||
projectName: detail.project.name,
|
||||
chapterId: unit.id,
|
||||
chapterTitle: unit.title,
|
||||
sourceText,
|
||||
projectStyle: canvasStylePresets.find((preset) => preset.id === detail.project.stylePresetId)?.prompt || "",
|
||||
config: effectiveConfig,
|
||||
};
|
||||
};
|
||||
const extractCharacters = async () => {
|
||||
try {
|
||||
const input = chapterAnalysisInput();
|
||||
if (!input) return;
|
||||
setAnalysisKind("characters");
|
||||
const characters = await extractChapterCharacters(input);
|
||||
// 已确认角色允许再次提取并作为候选归并,只有尚待处理的同名候选需要去重。
|
||||
const knownNames = new Set(detail.assetCandidates
|
||||
.filter((candidate) => candidate.category === "character" && candidate.status === "pending_confirmation")
|
||||
.map((candidate) => normalizeCharacterName(candidate.name)));
|
||||
const fresh = characters.filter((character) => ![character.name, ...character.aliases].map(normalizeCharacterName).some((name) => knownNames.has(name)));
|
||||
if (!fresh.length) {
|
||||
message.info("本章角色已存在于待确认列表中");
|
||||
return;
|
||||
}
|
||||
await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId: input.chapterId, name: character.name, category: "character", details: { ...character } })));
|
||||
refreshProject();
|
||||
message.success(`已提取 ${fresh.length} 个角色,请到项目资产确认`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "角色提取失败");
|
||||
} finally {
|
||||
setAnalysisKind("");
|
||||
}
|
||||
};
|
||||
const replaceStoryboard = async () => {
|
||||
try {
|
||||
const input = chapterAnalysisInput();
|
||||
if (!input) return;
|
||||
setAnalysisKind("storyboard");
|
||||
const shots = await generateChapterStoryboard(input);
|
||||
await replaceProjectUnitShots(detail.project.id, input.chapterId, shots);
|
||||
refreshProject();
|
||||
message.success(`已生成 ${shots.length} 个章节分镜`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "分镜生成失败");
|
||||
} finally {
|
||||
setAnalysisKind("");
|
||||
}
|
||||
};
|
||||
const generateStoryboard = () => {
|
||||
const existingCount = selectedUnit ? chapterShotCount(selectedUnit.id) : 0;
|
||||
if (!existingCount) {
|
||||
void replaceStoryboard();
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: "重新生成本章分镜?",
|
||||
content: `当前 ${existingCount} 个镜头会被新分镜整体替换,已关联的镜头素材不会删除,但会解除镜头引用。`,
|
||||
okText: "重新生成",
|
||||
cancelText: "取消",
|
||||
onOk: replaceStoryboard,
|
||||
});
|
||||
};
|
||||
const selectChapter = (unitId: string) => {
|
||||
if (unitId === selectedId) return;
|
||||
if (dirty) { message.warning("请先保存当前章节,再切换章节"); return; }
|
||||
setSelectedId(unitId);
|
||||
sessionStorage.setItem(`project-active-chapter:${detail.project.id}`, unitId);
|
||||
navigate(`/projects/${detail.project.id}/chapters/${unitId}`);
|
||||
};
|
||||
const moveChapter = (targetId: string) => {
|
||||
if (!draggedId || draggedId === targetId || reorderMutation.isPending) return;
|
||||
const next = orderedIds.filter((id) => id !== draggedId);
|
||||
next.splice(next.indexOf(targetId), 0, draggedId);
|
||||
setOrderedIds(next);
|
||||
setDraggedId("");
|
||||
reorderMutation.mutate(next);
|
||||
};
|
||||
const moveChapterToPosition = () => {
|
||||
if (!moveTargetId || !movePosition || reorderMutation.isPending) return;
|
||||
const next = orderedIds.filter((id) => id !== moveTargetId);
|
||||
const targetIndex = Math.min(Math.max(movePosition - 1, 0), next.length);
|
||||
next.splice(targetIndex, 0, moveTargetId);
|
||||
setOrderedIds(next);
|
||||
setMoveTargetId("");
|
||||
setMovePosition(null);
|
||||
reorderMutation.mutate(next);
|
||||
window.setTimeout(() => {
|
||||
const index = next.indexOf(moveTargetId);
|
||||
const visibleIndex = visibleUnits.findIndex((unit) => unit.id === moveTargetId);
|
||||
if (!deferredSearchQuery && index >= 0) chapterVirtualizer.scrollToIndex(index, { align: "center" });
|
||||
else if (visibleIndex >= 0) chapterVirtualizer.scrollToIndex(visibleIndex, { align: "center" });
|
||||
}, 0);
|
||||
};
|
||||
const revealSelectedChapter = () => {
|
||||
if (!selectedId) return;
|
||||
setSearchQuery("");
|
||||
window.setTimeout(() => {
|
||||
const index = orderedIds.indexOf(selectedId);
|
||||
if (index >= 0) chapterVirtualizer.scrollToIndex(index, { align: "center" });
|
||||
}, 0);
|
||||
};
|
||||
const handleListDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||
if (!draggedId || deferredSearchQuery) return;
|
||||
event.preventDefault();
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const edge = 48;
|
||||
if (event.clientY < bounds.top + edge) event.currentTarget.scrollBy({ top: -24 });
|
||||
else if (event.clientY > bounds.bottom - edge) event.currentTarget.scrollBy({ top: 24 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid h-full min-h-0 min-w-0 w-full grid-rows-[minmax(180px,34vh)_minmax(0,1fr)] overflow-hidden lg:grid-cols-[232px_minmax(0,1fr)] lg:grid-rows-1">
|
||||
<aside className="flex min-h-0 min-w-0 w-full flex-col border-b border-border/70 bg-background/28 lg:border-b-0 lg:border-r">
|
||||
<div className="flex h-11 shrink-0 items-center justify-between border-b border-border/70 px-2.5">
|
||||
<div className="text-xs font-medium text-foreground/55">章节 <span className="ml-1 tabular-nums text-foreground/35">{detail.units.length.toLocaleString("zh-CN")}</span></div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{selectedId ? <Tooltip title="回到当前章节"><Button type="text" size="small" icon={<Crosshair className="size-3.5" />} aria-label="回到当前章节" onClick={revealSelectedChapter} /></Tooltip> : null}
|
||||
<Tooltip title="导入小说"><Button type="text" size="small" icon={<FileUp className="size-3.5" />} aria-label="导入小说" onClick={() => setImportOpen(true)} /></Tooltip>
|
||||
<Tooltip title="添加章节"><Button type="text" size="small" icon={<Plus className="size-4" />} aria-label="添加章节" onClick={() => setCreateOpen(true)} /></Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 border-b border-border/60 p-2">
|
||||
<label className="flex h-8 items-center gap-1.5 rounded-md border border-border/75 bg-background/55 px-2 focus-within:border-[var(--workspace-accent)] focus-within:ring-2 focus-within:ring-[var(--workspace-accent-soft)]">
|
||||
<Search className="size-3.5 shrink-0 text-foreground/32" />
|
||||
<input value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && visibleUnits[0]) selectChapter(visibleUnits[0].id); }} className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-foreground/28" placeholder="搜索标题或章节序号" aria-label="搜索章节" />
|
||||
{searchQuery ? <button type="button" onClick={() => setSearchQuery("")} className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-foreground/[.06]" aria-label="清空章节搜索"><X className="size-3" /></button> : null}
|
||||
</label>
|
||||
{deferredSearchQuery ? <div className="mt-1 px-0.5 text-[9px] tabular-nums text-foreground/35">找到 {visibleUnits.length.toLocaleString("zh-CN")} 章 · 搜索时使用“移动到”调整顺序</div> : null}
|
||||
</div>
|
||||
{orderedUnits.length ? (
|
||||
<div ref={listRef} onDragOver={handleListDragOver} className="thin-scrollbar min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain p-1.5">
|
||||
{visibleUnits.length ? <div className="relative w-full" style={{ height: chapterVirtualizer.getTotalSize() }}>
|
||||
{chapterVirtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const unit = visibleUnits[virtualItem.index];
|
||||
if (!unit) return null;
|
||||
const chapterNumber = chapterNumberById.get(unit.id) || virtualItem.index + 1;
|
||||
return (
|
||||
<div key={unit.id} className="absolute left-0 top-0 w-full" style={{ height: virtualItem.size, transform: `translateY(${virtualItem.start}px)` }}>
|
||||
<div draggable={!dirty && !reorderMutation.isPending && !deferredSearchQuery} onDragStart={(event) => { setDraggedId(unit.id); event.dataTransfer.effectAllowed = "move"; }} onDragOver={(event) => { if (deferredSearchQuery) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={() => moveChapter(unit.id)} onDragEnd={() => setDraggedId("")} className={`group flex h-[50px] items-start rounded-md transition-colors duration-100 ${unit.id === selectedUnit?.id ? "bg-foreground/[.08]" : "hover:bg-foreground/[.04]"} ${draggedId === unit.id ? "opacity-45" : ""}`}>
|
||||
<button type="button" disabled={Boolean(deferredSearchQuery)} className="mt-2 grid size-6 shrink-0 cursor-grab place-items-center text-foreground/25 active:cursor-grabbing disabled:cursor-default disabled:opacity-35" aria-label={`拖动第 ${chapterNumber} 章排序`}><GripVertical className="size-3.5" /></button>
|
||||
<button type="button" onClick={() => selectChapter(unit.id)} className="flex min-w-0 flex-1 items-start gap-2 px-0 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]">
|
||||
<span className={`w-8 shrink-0 pt-0.5 text-[10px] tabular-nums ${unit.id === selectedUnit?.id ? "font-semibold text-[var(--workspace-accent)]" : "text-foreground/35"}`}>{String(chapterNumber).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span>
|
||||
<span className="min-w-0 flex-1"><span className={`block truncate text-[13px] ${unit.id === selectedUnit?.id ? "font-medium text-foreground" : "text-foreground/65"}`}>{unit.title}</span><span className="mt-0.5 flex items-center gap-1 text-[10px] text-foreground/38"><span>{statusLabel(unit.status)}</span>{chapterCanvasCount(unit.id) ? <><span>·</span><span>{chapterCanvasCount(unit.id)} 画布</span></> : null}</span></span>
|
||||
</button>
|
||||
<Dropdown trigger={["click"]} placement="bottomRight" menu={{ items: [{ key: "move", icon: <MoveVertical className="size-3.5" />, label: "移动到…" }], onClick: () => { setMoveTargetId(unit.id); setMovePosition(chapterNumber); } }}>
|
||||
<button type="button" className="mt-2 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-foreground/[.06] hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100" aria-label={`${unit.title}更多操作`}><MoreHorizontal className="size-3.5" /></button>
|
||||
</Dropdown>
|
||||
<Popconfirm title="删除此章节?" description="章节内容及关联制作记录将被删除,已关联的画布不会删除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true, loading: deleteMutation.isPending }} onConfirm={() => deleteMutation.mutate(unit.id)}>
|
||||
<button type="button" className="mr-1 mt-2 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-red-500/10 hover:text-red-500 group-hover:opacity-100 focus-visible:opacity-100" aria-label={`删除${unit.title}`}><Trash2 className="size-3.5" /></button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div> : <div className="px-3 py-8 text-center text-xs text-foreground/40">没有匹配的章节</div>}
|
||||
</div>
|
||||
) : <WorkspaceState icon="projects" compact className="flex-1 px-4" title="还没有章节" description="添加章节后可继续编写正文和关联画布。" action={<Button size="small" type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>添加章节</Button>} />}
|
||||
</aside>
|
||||
|
||||
<section className="min-h-0 min-w-0 w-full bg-background/18 p-2.5 sm:p-3.5">
|
||||
{selectedUnit ? (
|
||||
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden rounded-lg border border-border/80 bg-background">
|
||||
<header className="flex shrink-0 items-start gap-3 border-b border-border/70 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 text-[10px] font-medium tabular-nums text-foreground/38">第 {String(orderedUnits.findIndex((unit) => unit.id === selectedUnit.id) + 1).padStart(2, "0")} 章</div>
|
||||
<Input variant="borderless" value={draftTitle} disabled={!selectedUnitQuery.data?.unit} onChange={(event) => { setDraftTitle(event.target.value); setDirty(true); }} className="!h-auto !px-0 !py-0 !text-xl !font-semibold !leading-tight disabled:!cursor-wait disabled:!text-foreground" placeholder="章节标题" />
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[10px] text-foreground/38"><span>{dirty ? "有未保存修改" : `保存于 ${formatTime(selectedUnit.updatedAt)}`}</span><span>·</span><span>{formatCount(wordCount)} 字</span><span>·</span><span>{chapterCanvasCount(selectedUnit.id)} 个画布</span></div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || Boolean(analysisKind)} loading={analysisKind === "characters"} onClick={() => void extractCharacters()}>提取角色</Button>
|
||||
<Button size="small" icon={<Clapperboard className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || Boolean(analysisKind)} loading={analysisKind === "storyboard"} onClick={generateStoryboard}>{chapterShotCount(selectedUnit.id) ? "重新生成分镜" : "生成分镜"}</Button>
|
||||
<Button size="small" type={dirty ? "primary" : "default"} icon={dirty ? <Save className="size-3.5" /> : <Check className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || !dirty || !draftTitle.trim() || saveMutation.isPending} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存" : "已保存"}</Button>
|
||||
</div>
|
||||
</header>
|
||||
<EditorToolbar editor={editor} />
|
||||
<div className="project-chapter-editor-scroll thin-scrollbar min-h-0 flex-1 overflow-y-auto bg-foreground/[.012]">
|
||||
{selectedUnitQuery.isLoading ? <WorkspaceState icon="loading" compact className="h-full" title="正在读取章节正文" description="正文准备完成后会自动显示。" /> : selectedUnitQuery.isError ? <WorkspaceErrorState compact title="章节正文读取失败" description={selectedUnitQuery.error instanceof Error ? selectedUnitQuery.error.message : "请检查网络连接后重试。"} onRetry={() => void selectedUnitQuery.refetch()} /> : <div className="project-chapter-editor-wrap min-h-full"><EditorContent editor={editor} /></div>}
|
||||
</div>
|
||||
</div>
|
||||
) : <WorkspaceState icon="projects" compact className="h-full" title="请选择章节" description="从左侧章节列表选择一章开始编辑。" />}
|
||||
</section>
|
||||
<CreateChapterModal open={createOpen} onClose={() => setCreateOpen(false)} loading={createMutation.isPending} onSubmit={(values) => createMutation.mutate(values)} />
|
||||
<ImportNovelModal open={importOpen} loading={importMutation.isPending} onClose={() => setImportOpen(false)} onImport={(chapters) => importMutation.mutate(chapters)} />
|
||||
<Modal title="移动章节" open={Boolean(moveTargetId)} width={400} okText="移动" cancelText="取消" okButtonProps={{ disabled: !movePosition || movePosition < 1 || movePosition > orderedUnits.length, loading: reorderMutation.isPending }} onCancel={() => { setMoveTargetId(""); setMovePosition(null); }} onOk={moveChapterToPosition} styles={{ body: { paddingTop: 12 } }}>
|
||||
<div className="text-xs leading-5 text-foreground/50">输入目标章节位置。适合上千章项目的长距离调整,移动后其他章节会自动顺延。</div>
|
||||
<label className="mt-3 flex items-center gap-2 text-sm"><span className="shrink-0">移动到第</span><InputNumber min={1} max={orderedUnits.length} precision={0} value={movePosition} onChange={setMovePosition} className="min-w-0 flex-1" /><span className="shrink-0">章</span></label>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorToolbar({ editor }: { editor: ReturnType<typeof useEditor> }) {
|
||||
const setLink = () => {
|
||||
if (!editor) return;
|
||||
const current = String(editor.getAttributes("link").href || "");
|
||||
const href = window.prompt("输入链接地址", current);
|
||||
if (href === null) return;
|
||||
if (!href.trim()) editor.chain().focus().unsetLink().run();
|
||||
else editor.chain().focus().extendMarkRange("link").setLink({ href: href.trim() }).run();
|
||||
};
|
||||
const setColor = () => {
|
||||
const color = window.prompt("输入文字颜色,例如 #d97706", String(editor?.getAttributes("textStyle").color || "#d97706"));
|
||||
if (color?.trim()) editor?.chain().focus().setColor(color.trim()).run();
|
||||
};
|
||||
const setHighlight = () => {
|
||||
const color = window.prompt("输入高亮颜色,例如 #fef3c7", String(editor?.getAttributes("highlight").color || "#fef3c7"));
|
||||
if (color?.trim()) editor?.chain().focus().toggleHighlight({ color: color.trim() }).run();
|
||||
};
|
||||
const blockLabel = editor?.isActive("heading", { level: 1 }) ? "标题 1" : editor?.isActive("heading", { level: 2 }) ? "标题 2" : editor?.isActive("heading", { level: 3 }) ? "标题 3" : "正文";
|
||||
const alignment = editor?.isActive({ textAlign: "center" }) ? "center" : editor?.isActive({ textAlign: "right" }) ? "right" : editor?.isActive({ textAlign: "justify" }) ? "justify" : "left";
|
||||
const alignmentIcon = alignment === "center" ? <AlignCenter className="size-3.5" /> : alignment === "right" ? <AlignRight className="size-3.5" /> : alignment === "justify" ? <AlignJustify className="size-3.5" /> : <AlignLeft className="size-3.5" />;
|
||||
return (
|
||||
<div className="hide-scrollbar flex h-10 shrink-0 items-center gap-0.5 overflow-x-auto border-b border-border/70 px-2">
|
||||
<EditorTool editor={editor} label="撤销" icon={<Undo2 className="size-3.5" />} onClick={() => editor?.chain().focus().undo().run()} />
|
||||
<EditorTool editor={editor} label="重做" icon={<Redo2 className="size-3.5" />} onClick={() => editor?.chain().focus().redo().run()} />
|
||||
<ToolbarDivider />
|
||||
<Dropdown trigger={["click"]} menu={{ selectedKeys: [blockLabel], items: ["正文", "标题 1", "标题 2", "标题 3"].map((key) => ({ key, label: key })), onClick: ({ key }) => key === "正文" ? editor?.chain().focus().setParagraph().run() : editor?.chain().focus().toggleHeading({ level: Number(key.slice(-1)) as 1 | 2 | 3 }).run() }}>
|
||||
<button type="button" className="flex h-7 items-center gap-1 rounded px-2 text-xs text-foreground/60 hover:bg-foreground/[.06]" aria-label="段落格式">{blockLabel}<ChevronDown className="size-3" /></button>
|
||||
</Dropdown>
|
||||
<EditorTool editor={editor} label="粗体" icon={<Bold className="size-3.5" />} onClick={() => editor?.chain().focus().toggleBold().run()} active={Boolean(editor?.isActive("bold"))} />
|
||||
<EditorTool editor={editor} label="斜体" icon={<Italic className="size-3.5" />} onClick={() => editor?.chain().focus().toggleItalic().run()} active={Boolean(editor?.isActive("italic"))} />
|
||||
<EditorTool editor={editor} label="下划线" icon={<Underline className="size-3.5" />} onClick={() => editor?.chain().focus().toggleUnderline().run()} active={Boolean(editor?.isActive("underline"))} />
|
||||
<EditorTool editor={editor} label="删除线" icon={<Strikethrough className="size-3.5" />} onClick={() => editor?.chain().focus().toggleStrike().run()} active={Boolean(editor?.isActive("strike"))} />
|
||||
<ToolbarDivider />
|
||||
<Dropdown trigger={["click"]} menu={{ selectedKeys: [alignment], items: [{ key: "left", icon: <AlignLeft className="size-3.5" />, label: "左对齐" }, { key: "center", icon: <AlignCenter className="size-3.5" />, label: "居中" }, { key: "right", icon: <AlignRight className="size-3.5" />, label: "右对齐" }, { key: "justify", icon: <AlignJustify className="size-3.5" />, label: "两端对齐" }], onClick: ({ key }) => editor?.chain().focus().setTextAlign(key).run() }}>
|
||||
<button type="button" className="grid size-7 place-items-center rounded text-foreground/60 hover:bg-foreground/[.06]" aria-label="文字对齐">{alignmentIcon}</button>
|
||||
</Dropdown>
|
||||
<EditorTool editor={editor} label="项目符号" icon={<List className="size-3.5" />} onClick={() => editor?.chain().focus().toggleBulletList().run()} active={Boolean(editor?.isActive("bulletList"))} />
|
||||
<EditorTool editor={editor} label="编号列表" icon={<ListOrdered className="size-3.5" />} onClick={() => editor?.chain().focus().toggleOrderedList().run()} active={Boolean(editor?.isActive("orderedList"))} />
|
||||
<EditorTool editor={editor} label="引用" icon={<Quote className="size-3.5" />} onClick={() => editor?.chain().focus().toggleBlockquote().run()} active={Boolean(editor?.isActive("blockquote"))} />
|
||||
<EditorTool editor={editor} label="链接" icon={<Link2 className="size-3.5" />} onClick={setLink} active={Boolean(editor?.isActive("link"))} />
|
||||
<Dropdown trigger={["click"]} placement="bottomRight" menu={{ items: [{ key: "color", icon: <span className="text-[11px] font-bold text-amber-600">A</span>, label: "文字颜色" }, { key: "highlight", icon: <Highlighter className="size-3.5" />, label: "高亮颜色" }, { type: "divider" }, { key: "code", icon: <Code2 className="size-3.5" />, label: "行内代码" }, { key: "rule", icon: <Minus className="size-3.5" />, label: "分隔线" }, { key: "clear", icon: <Eraser className="size-3.5" />, label: "清除格式" }], onClick: ({ key }) => { if (key === "color") setColor(); else if (key === "highlight") setHighlight(); else if (key === "code") editor?.chain().focus().toggleCode().run(); else if (key === "rule") editor?.chain().focus().setHorizontalRule().run(); else if (key === "clear") editor?.chain().focus().clearNodes().unsetAllMarks().run(); } }}>
|
||||
<button type="button" className="grid size-7 place-items-center rounded text-foreground/60 hover:bg-foreground/[.06]" aria-label="更多格式"><MoreHorizontal className="size-4" /></button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorTool({ editor, label, icon, active = false, onClick }: { editor: ReturnType<typeof useEditor>; label: string; icon: ReactNode; active?: boolean; onClick: () => void }) {
|
||||
return <Tooltip title={label}><button type="button" aria-label={label} className={`grid size-7 shrink-0 place-items-center rounded ${active ? "bg-foreground/[.08] text-[var(--workspace-accent)]" : "text-foreground/55 hover:bg-foreground/[.06] hover:text-foreground"}`} disabled={!editor} onClick={onClick}>{icon}</button></Tooltip>;
|
||||
}
|
||||
|
||||
function ToolbarDivider() {
|
||||
return <span className="mx-1 h-4 w-px shrink-0 bg-border" />;
|
||||
}
|
||||
|
||||
function CreateChapterModal({ open, onClose, loading, onSubmit }: { open: boolean; onClose: () => void; loading: boolean; onSubmit: (values: { title: string; sourceText?: string }) => void }) {
|
||||
return <Modal title="添加章节" open={open} footer={null} destroyOnHidden onCancel={onClose} width={480} styles={{ body: { paddingTop: 12 } }}><Form layout="vertical" onFinish={onSubmit}><Form.Item name="title" label="章节标题" rules={[{ required: true, whitespace: true, message: "请输入章节标题" }]}><Input autoFocus placeholder="例如:雨夜归城" /></Form.Item><Form.Item name="sourceText" label="正文(可选)"><Input.TextArea rows={4} placeholder="创建后仍可继续编辑和排版" /></Form.Item><div className="flex justify-end gap-2"><Button onClick={onClose}>取消</Button><Button type="primary" htmlType="submit" loading={loading}>创建章节</Button></div></Form></Modal>;
|
||||
}
|
||||
|
||||
function ImportNovelModal({ open, loading, onClose, onImport }: { open: boolean; loading: boolean; onClose: () => void; onImport: (chapters: Array<{ title: string; plainText: string }>) => void }) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [fileName, setFileName] = useState("");
|
||||
const deferredText = useDeferredValue(text);
|
||||
const chapters = useMemo(() => deferredText.trim() ? splitTextIntoChapters(deferredText).map((chapter) => ({ title: chapter.title, plainText: chapter.plainText })) : [], [deferredText]);
|
||||
useEffect(() => { if (!open) { setText(""); setFileName(""); } }, [open]);
|
||||
const readFile = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
setFileName(file.name);
|
||||
setText(decodeNovelText(await file.arrayBuffer()));
|
||||
};
|
||||
return (
|
||||
<Modal title={null} open={open} footer={null} destroyOnHidden onCancel={onClose} width={760} styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}>
|
||||
<div className="flex min-h-[478px] flex-col">
|
||||
<header className="flex h-12 shrink-0 items-center border-b border-border px-4"><div><h2 className="text-sm font-semibold">导入小说</h2><p className="mt-0.5 text-[10px] text-foreground/42">自动识别章节标题,确认后追加到当前项目</p></div></header>
|
||||
<div className="grid min-h-[430px] flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_240px]">
|
||||
<div className="border-b border-border p-3 md:border-b-0 md:border-r">
|
||||
<div className="mb-2 flex items-center justify-between gap-2"><div><div className="text-sm font-medium">小说正文</div><div className="mt-0.5 text-[11px] text-foreground/45">识别章节标题后追加到现有章节</div></div><Button size="small" icon={<FileUp className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>{fileName || "选择 TXT"}</Button></div>
|
||||
<input ref={fileInputRef} type="file" accept=".txt,.md,text/plain,text/markdown" className="hidden" onChange={(event) => void readFile(event)} />
|
||||
<Input.TextArea value={text} onChange={(event) => setText(event.target.value)} rows={16} placeholder={'也可以直接粘贴小说正文,例如:\n\n第一章 雨夜来信\n正文……\n\n第二章 灯塔以北\n正文……'} className="!resize-none" />
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="flex h-11 shrink-0 items-center justify-between border-b border-border px-3 text-xs"><span className="font-medium">拆分预览</span><span className="tabular-nums text-foreground/45">{chapters.length} 章</span></div>
|
||||
<ImportChapterPreview chapters={chapters} />
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border p-3"><span className={`text-[10px] ${chapters.length > MAX_NOVEL_IMPORT_CHAPTERS ? "text-red-500" : "text-foreground/38"}`}>{chapters.length > MAX_NOVEL_IMPORT_CHAPTERS ? `最多一次导入 ${MAX_NOVEL_IMPORT_CHAPTERS.toLocaleString("zh-CN")} 章` : `支持最多 ${MAX_NOVEL_IMPORT_CHAPTERS.toLocaleString("zh-CN")} 章`}</span><div className="flex gap-2"><Button size="small" onClick={onClose}>取消</Button><Button size="small" type="primary" disabled={!chapters.length || chapters.length > MAX_NOVEL_IMPORT_CHAPTERS} loading={loading} onClick={() => onImport(chapters)}>导入 {chapters.length || ""} 章</Button></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportChapterPreview({ chapters }: { chapters: Array<{ title: string; plainText: string }> }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: chapters.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 49,
|
||||
overscan: 10,
|
||||
});
|
||||
return (
|
||||
<div ref={scrollRef} className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{chapters.length ? <div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const chapter = chapters[virtualItem.index];
|
||||
return <div key={`${chapter.title}-${virtualItem.index}`} className="absolute left-0 top-0 flex w-full gap-2 border-b border-border/60 px-1.5 py-2" style={{ height: virtualItem.size, transform: `translateY(${virtualItem.start}px)` }}><span className="w-8 shrink-0 pt-0.5 text-[10px] tabular-nums text-foreground/35">{String(virtualItem.index + 1).padStart(Math.max(2, String(chapters.length).length), "0")}</span><div className="min-w-0"><div className="truncate text-xs font-medium">{chapter.title}</div><div className="mt-0.5 text-[10px] text-foreground/40">{formatCount(chapter.plainText.length)} 字</div></div></div>;
|
||||
})}
|
||||
</div> : <div className="grid h-full place-items-center px-4 text-center text-xs leading-5 text-foreground/40">选择 TXT 文件或粘贴正文后,这里会显示拆分结果</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function plainTextToHtml(value: string) {
|
||||
const escaped = value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
return escaped.split(/\n{2,}/).map((paragraph) => `<p>${paragraph.replace(/\n/g, "<br>")}</p>`).join("");
|
||||
}
|
||||
|
||||
function stripHtml(value: string) {
|
||||
return value.replace(/<[^>]+>/g, "").replace(/ /g, " ").trim();
|
||||
}
|
||||
|
||||
function readStoredScroll(key: string) {
|
||||
const value = Number(sessionStorage.getItem(key) || 0);
|
||||
return Number.isFinite(value) && value > 0 ? value : 0;
|
||||
}
|
||||
122
web/src/pages/projects/detail/overview.tsx
Normal file
122
web/src/pages/projects/detail/overview.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { ArrowRight, CheckCircle2, CircleAlert, Clock3 } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { projectAttentionCount, projectContinueTarget, projectDetailStage, projectNextActions, projectUnitStages, type ProjectStageCell, type ProjectWorkbenchAction } from "@/lib/project-workbench";
|
||||
|
||||
import { formatTime, type ProjectDetailViewProps } from "./shared";
|
||||
|
||||
export default function ProjectOverviewView({ detail }: ProjectDetailViewProps) {
|
||||
const { project, units, canvases, shots } = detail;
|
||||
const completedUnits = units.filter((unit) => unit.status === "completed").length;
|
||||
const attentionCount = projectAttentionCount(detail);
|
||||
const completion = units.length ? Math.round((completedUnits / units.length) * 100) : 0;
|
||||
const stage = projectDetailStage(detail);
|
||||
const actions = projectNextActions(detail, 3);
|
||||
const primaryAction = actions[0];
|
||||
const secondaryActions = actions.slice(1);
|
||||
const continueTarget = projectContinueTarget(detail);
|
||||
const unitStages = projectUnitStages(detail);
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section className="overflow-hidden rounded-lg border border-border/75 bg-background/80 shadow-sm">
|
||||
<div className="grid lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div className="min-w-0 p-5 sm:p-6 lg:p-7">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span className="font-medium text-[var(--workspace-accent)]">当前任务</span>
|
||||
<span className="text-foreground/20" aria-hidden>/</span>
|
||||
<span className="text-foreground/45">{stage.label}</span>
|
||||
{attentionCount ? <span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">{attentionCount} 项待处理</span> : null}
|
||||
</div>
|
||||
<h2 className="mt-3 max-w-[680px] text-2xl font-semibold leading-8 text-balance">{primaryAction.title}</h2>
|
||||
<p className="mt-2 max-w-[680px] text-sm leading-6 text-foreground/52 text-pretty">{primaryAction.description}</p>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<Link to={primaryAction.href} className="inline-flex h-10 max-w-full items-center gap-2 rounded-md bg-[var(--workspace-accent)] px-4 text-sm font-medium text-white transition-[filter] hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<span className="truncate">{primaryAction.actionLabel}</span><ArrowRight className="size-4 shrink-0" />
|
||||
</Link>
|
||||
{continueTarget.href !== primaryAction.href ? <Link to={continueTarget.href} className="inline-flex h-10 items-center gap-2 px-1 text-xs font-medium text-foreground/48 hover:text-foreground">继续最近工作<ArrowRight className="size-3.5" /></Link> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="border-t border-border/70 bg-foreground/[.018] p-5 lg:border-l lg:border-t-0 lg:p-6" aria-label="项目进度">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div><div className="text-[10px] font-medium text-foreground/38">章节进度</div><div className="mt-1 text-lg font-semibold tabular-nums">{completedUnits}<span className="mx-1 text-sm font-normal text-foreground/28">/</span>{units.length}</div></div>
|
||||
<span className="text-xs font-medium tabular-nums text-foreground/42">{completion}%</span>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-foreground/[.08]" aria-label={`章节完成度 ${completion}%`}><div className="h-full rounded-full bg-[var(--workspace-accent)] transition-[width]" style={{ width: `${completion}%` }} /></div>
|
||||
<dl className="mt-5 grid grid-cols-2 gap-x-5 gap-y-3 text-xs">
|
||||
<ProjectFact label="当前阶段" value={stage.label} />
|
||||
<ProjectFact label="分镜镜头" value={`${shots.length} 个`} />
|
||||
<ProjectFact label="项目画布" value={`${canvases.length} 张`} />
|
||||
<ProjectFact label="需要处理" value={`${attentionCount} 项`} attention={attentionCount > 0} />
|
||||
</dl>
|
||||
{secondaryActions.length ? (
|
||||
<div className="mt-5 border-t border-border/65 pt-4">
|
||||
<div className="text-[10px] font-medium text-foreground/38">随后处理</div>
|
||||
<div className="mt-2 space-y-1">{secondaryActions.map((action) => <SecondaryAction key={action.id} action={action} />)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-foreground/38">制作流水线</div>
|
||||
<h2 className="mt-1 text-lg font-semibold">章节进度</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/46">从内容确认到项目画布,每章只显示当前真实状态。</p>
|
||||
</div>
|
||||
<Link to={`/projects/${project.id}/chapters`} className="inline-flex h-8 items-center gap-1.5 text-xs font-medium text-foreground/48 hover:text-foreground">查看全部章节<ArrowRight className="size-3.5" /></Link>
|
||||
</div>
|
||||
|
||||
{unitStages.length ? (
|
||||
<div className="mt-4 overflow-hidden rounded-lg border border-border/75 bg-background/70">
|
||||
<div className="divide-y divide-border/65">
|
||||
{unitStages.map((item) => (
|
||||
<Link key={item.unit.id} to={`/projects/${project.id}/chapters/${item.unit.id}`} className="group grid gap-4 p-4 transition-colors hover:bg-foreground/[.025] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-foreground/20 sm:p-5 lg:grid-cols-[minmax(220px,.75fr)_minmax(480px,1.35fr)_24px] lg:items-center">
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.055] text-[10px] font-semibold tabular-nums text-foreground/45">{String(item.unit.position + 1).padStart(2, "0")}</span>
|
||||
<span className="min-w-0"><span className="block truncate text-sm font-medium">{item.unit.title}</span><span className="mt-1 block text-[10px] text-foreground/36">更新于 {formatTime(item.unit.updatedAt)}</span></span>
|
||||
</span>
|
||||
<StagePipeline content={item.content} assets={item.assets} storyboard={item.storyboard} canvas={item.canvas} />
|
||||
<ArrowRight className="hidden size-4 text-foreground/22 transition group-hover:translate-x-0.5 group-hover:text-foreground/55 lg:block" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : <div className="mt-4 overflow-hidden rounded-lg border border-border/75 bg-background/70 p-2"><WorkspaceState icon="projects" compact title="还没有剧情章节" description="添加章节后,这里会显示内容、资产、分镜和画布的制作进度。" /></div>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectFact({ label, value, attention = false }: { label: string; value: string; attention?: boolean }) {
|
||||
return <div className="min-w-0"><dt className="text-[10px] text-foreground/36">{label}</dt><dd className={`mt-1 truncate font-medium ${attention ? "text-amber-600 dark:text-amber-400" : "text-foreground/72"}`}>{value}</dd></div>;
|
||||
}
|
||||
|
||||
function SecondaryAction({ action }: { action: ProjectWorkbenchAction }) {
|
||||
const Icon = action.tone === "danger" ? CircleAlert : action.tone === "attention" ? Clock3 : CheckCircle2;
|
||||
return <Link to={action.href} className="group flex min-w-0 items-center gap-2 rounded px-1 py-1.5 text-[11px] text-foreground/52 hover:bg-foreground/[.04] hover:text-foreground"><Icon className={`size-3.5 shrink-0 ${action.tone === "danger" ? "text-red-500" : action.tone === "attention" ? "text-amber-500" : "text-foreground/30"}`} /><span className="min-w-0 flex-1 truncate">{action.title}</span><ArrowRight className="size-3 shrink-0 text-foreground/25 transition group-hover:text-foreground/55" /></Link>;
|
||||
}
|
||||
|
||||
function StagePipeline({ content, assets, storyboard, canvas }: { content: ProjectStageCell; assets: ProjectStageCell; storyboard: ProjectStageCell; canvas: ProjectStageCell }) {
|
||||
const stages = [{ label: "内容", cell: content }, { label: "资产", cell: assets }, { label: "分镜", cell: storyboard }, { label: "画布", cell: canvas }];
|
||||
return (
|
||||
<span className="grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{stages.map(({ label, cell }) => <StageStep key={label} label={label} cell={cell} />)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StageStep({ label, cell }: { label: string; cell: ProjectStageCell }) {
|
||||
const bar = cell.state === "completed" ? "bg-emerald-500" : cell.state === "attention" ? "bg-amber-500" : cell.state === "active" ? "bg-[var(--workspace-accent)]" : "bg-foreground/10";
|
||||
return (
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[10px] font-medium text-foreground/34">{label}</span>
|
||||
<span className={`mt-2 block h-1 rounded-full ${bar}`} />
|
||||
<span className="mt-1.5 block truncate text-[10px] text-foreground/48">{cell.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
87
web/src/pages/projects/detail/project-chapter-ai.ts
Normal file
87
web/src/pages/projects/detail/project-chapter-ai.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
|
||||
import { parseCharacterBreakdown, type CharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
export type ChapterStoryboardDraft = {
|
||||
title: string;
|
||||
description: string;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
type ChapterAnalysisInput = {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
chapterId: string;
|
||||
chapterTitle: string;
|
||||
sourceText: string;
|
||||
projectStyle: string;
|
||||
config: AiConfig;
|
||||
};
|
||||
|
||||
export async function extractChapterCharacters(input: ChapterAnalysisInput): Promise<CharacterBreakdown[]> {
|
||||
const prompt = [
|
||||
`请从短剧项目《${input.projectName}》的章节“${input.chapterTitle}”中提取需要建立可复用视觉资产的角色。`,
|
||||
"提取本章实际出场、发言或对剧情产生明确作用,并且后续制作中需要保持视觉或声音一致的角色;忽略系统播报、纯物件、无身份群众、不影响剧情的一次性路人,以及只在单个镜头出现且没有持续角色价值的匿名录像或历史影像人物。合并同一角色的姓名、专属称谓和别名。",
|
||||
"每个角色必须填写剧情定位,以及至少三项可执行的稳定设定;正文未明确的信息要明确写“正文未明确”,不得留空,也不能改变人物关系和时代背景。角色名称必须来自正文中的姓名、昵称或稳定称谓,不得自行编造编号式名称。",
|
||||
`【项目画风】\n${input.projectStyle || "项目尚未指定画风,保持视觉描述中性、可执行。"}`,
|
||||
"只返回 JSON,不要 Markdown 或解释。JSON 结构必须严格为:",
|
||||
'{"characters":[{"name":"角色名","aliases":["唯一别名"],"role":"剧情定位与人物关系","appearance":"年龄、脸型、五官、肤色、发型等稳定外貌","clothing":"固定服装版型、颜色、纹样和材质","physique":"身高、头身、体型和体态","personality":"稳定气质与表演基线","props":"固定道具及佩戴位置,没有则为空字符串","consistencyPrompt":"跨图片和镜头必须保持不变的角色约束","multiViewPrompt":"正面、侧面、背面转面展示需要强调的结构细节","voiceLanguage":"语言、口音和表达习惯","voiceAge":"适合选角的声音年龄感","voiceTimbre":"音色、语速、力度和声音气质"}]}',
|
||||
"【章节正文】",
|
||||
input.sourceText,
|
||||
].join("\n\n");
|
||||
const result = await runProjectTextTask(input, "chapter_character_breakdown", prompt);
|
||||
return parseCharacterBreakdown(result);
|
||||
}
|
||||
|
||||
export async function generateChapterStoryboard(input: ChapterAnalysisInput): Promise<ChapterStoryboardDraft[]> {
|
||||
const prompt = [
|
||||
`请把短剧项目《${input.projectName}》的章节“${input.chapterTitle}”拆成可直接制作的分镜镜头。`,
|
||||
"镜头顺序必须忠于正文事件顺序;每个镜头只承担一个清楚的叙事动作。描述必须包含主体、动作、场景、景别、机位、镜头运动、光线和必要对白,不要写抽象评价。单镜头建议 3 至 8 秒。",
|
||||
`【项目画风】\n${input.projectStyle || "项目尚未指定画风,保持镜头描述中性、可执行。"}`,
|
||||
"只返回 JSON,不要 Markdown 或解释。JSON 结构必须严格为:",
|
||||
'{"shots":[{"title":"镜头名称","description":"可直接用于分镜制作的完整镜头描述","durationSeconds":5}]}',
|
||||
"【章节正文】",
|
||||
input.sourceText,
|
||||
].join("\n\n");
|
||||
const result = await runProjectTextTask(input, "chapter_storyboard", prompt);
|
||||
return parseChapterStoryboard(result);
|
||||
}
|
||||
|
||||
async function runProjectTextTask(input: ChapterAnalysisInput, operation: string, prompt: string) {
|
||||
const model = input.config.textModel || input.config.model;
|
||||
const result = await runBackendCanvasGenerationTask({
|
||||
projectId: input.projectId,
|
||||
nodeId: `${operation}:${input.chapterId}`,
|
||||
mode: "text",
|
||||
prompt,
|
||||
config: { ...input.config, model },
|
||||
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation },
|
||||
});
|
||||
if (!result.text?.trim()) throw new Error("模型没有返回可用结果");
|
||||
return result.text;
|
||||
}
|
||||
|
||||
function parseChapterStoryboard(raw: string): ChapterStoryboardDraft[] {
|
||||
const unfenced = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
|
||||
const start = unfenced.indexOf("{");
|
||||
const end = unfenced.lastIndexOf("}");
|
||||
if (start < 0 || end < start) throw new Error("分镜生成没有返回可识别的 JSON");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(unfenced.slice(start, end + 1));
|
||||
} catch (error) {
|
||||
throw new Error(`分镜结果格式不正确:${error instanceof Error ? error.message : "无法解析 JSON"}`);
|
||||
}
|
||||
const shots = parsed && typeof parsed === "object" ? (parsed as { shots?: unknown }).shots : undefined;
|
||||
if (!Array.isArray(shots)) throw new Error("分镜结果缺少 shots 数组");
|
||||
const result = shots.flatMap((item, index) => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const value = item as Record<string, unknown>;
|
||||
const description = String(value.description || "").trim();
|
||||
if (!description) return [];
|
||||
const durationSeconds = Math.min(30, Math.max(1, Number(value.durationSeconds) || 5));
|
||||
return [{ title: String(value.title || `镜头 ${index + 1}`).trim(), description, durationMs: Math.round(durationSeconds * 1000) }];
|
||||
});
|
||||
if (!result.length) throw new Error("分镜结果中没有可用镜头");
|
||||
return result;
|
||||
}
|
||||
55
web/src/pages/projects/detail/project-character-card.tsx
Normal file
55
web/src/pages/projects/detail/project-character-card.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Button, Popconfirm } from "antd";
|
||||
import { Image as ImageIcon, Pencil, Sparkles, Trash2, UserRound, Volume2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { resourceFileUrl } from "@/services/api/resources";
|
||||
import type { ProjectAsset } from "@/services/api/projects";
|
||||
|
||||
export function ProjectCharacterCard({ asset, generating, removing, onEdit, onGenerate, onBindImages, onBindVoice, onRemove }: {
|
||||
asset: ProjectAsset;
|
||||
generating: boolean;
|
||||
removing: boolean;
|
||||
onEdit: () => void;
|
||||
onGenerate: () => void;
|
||||
onBindImages: () => void;
|
||||
onBindVoice: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const character = asset.character;
|
||||
const cover = character?.representations.find((item) => item.role === "turnaround_sheet") || character?.representations.find((item) => item.role === "primary") || character?.representations.find((item) => item.role === "front") || character?.representations[0];
|
||||
const role = textValue(character?.definition.role) || "未填写剧情定位";
|
||||
const appearance = textValue(character?.definition.appearance) || textValue(character?.definition.consistencyPrompt) || "角色设定待完善";
|
||||
const imageStatus = character?.visualStatus === "ready" ? "图片已绑定" : character?.visualStatus === "partial" ? "图片待补全" : "图片未绑定";
|
||||
const voiceStatus = character?.voiceStatus === "ready" ? `声音已绑定 · ${character.voice?.profile.name}` : character?.voiceStatus === "unavailable" ? "声音需处理" : "声音未绑定";
|
||||
const ready = character?.visualStatus === "ready" && character.voiceStatus === "ready";
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-lg border border-border/80 bg-background shadow-[0_10px_30px_rgba(0,0,0,.04)]">
|
||||
<div className="relative aspect-[3/2] overflow-hidden bg-foreground/[.045]">
|
||||
{cover ? <img src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
|
||||
<div className="absolute inset-x-2 top-2 flex items-center justify-between gap-2">
|
||||
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[9px] font-medium text-white">角色卡 · v{character?.version || 1}</span>
|
||||
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[9px] text-white">{ready ? "可直接用于生成" : "设定进行中"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><h3 className="truncate text-sm font-semibold">{asset.title}</h3><p className="mt-0.5 truncate text-[11px] text-foreground/48">{role}</p></div><Button type="text" size="small" className="!h-7 !px-1.5" icon={<Pencil className="size-3.5" />} onClick={onEdit} aria-label={`编辑 ${asset.title}`} /></div>
|
||||
<p className="mt-2 line-clamp-2 min-h-9 text-[11px] leading-[18px] text-foreground/55">{appearance}</p>
|
||||
<div className="mt-2 grid gap-1.5">
|
||||
<StatusLine icon={<ImageIcon className="size-3.5" />} ready={character?.visualStatus === "ready"} label={imageStatus} action={character?.visualStatus === "ready" ? "更换" : "初始化"} onClick={character?.visualStatus === "ready" ? onBindImages : onGenerate} />
|
||||
<StatusLine icon={<Volume2 className="size-3.5" />} ready={character?.voiceStatus === "ready"} label={voiceStatus} action={character?.voiceStatus === "ready" ? "调整" : "选择"} onClick={onBindVoice} />
|
||||
</div>
|
||||
<div className="mt-3 flex min-w-0 gap-2 border-t border-border/60 pt-2">
|
||||
<Button size="small" className="min-w-0 flex-1" icon={<Sparkles className="size-3.5" />} loading={generating} disabled={removing} onClick={onGenerate}>{character?.visualStatus === "missing" ? "初始化三视图" : "重新生成三视图"}</Button><Popconfirm title="移出项目角色?" description="已有画布或镜头引用时将无法移出。" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" loading={removing} disabled={generating} icon={<Trash2 className="size-3.5" />} aria-label={`移出 ${asset.title}`} /></Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusLine({ icon, ready, label, action, onClick }: { icon: ReactNode; ready: boolean; label: string; action: string; onClick: () => void }) {
|
||||
return <div className={`flex min-w-0 items-center gap-2 rounded-md border px-2 py-1.5 text-[10px] ${ready ? "border-emerald-500/20 bg-emerald-500/[.06] text-emerald-700 dark:text-emerald-300" : "border-border/70 bg-foreground/[.025] text-foreground/48"}`}><span className="shrink-0">{icon}</span><span className="min-w-0 flex-1 truncate">{label}</span><button type="button" className="shrink-0 font-medium text-[var(--workspace-accent)] hover:underline" onClick={onClick}>{action}</button></div>;
|
||||
}
|
||||
|
||||
function textValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
42
web/src/pages/projects/detail/project-character-media.ts
Normal file
42
web/src/pages/projects/detail/project-character-media.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
export async function generateCharacterTurnaround(input: { projectId: string; assetId: string; versionId: string; name: string; definition: Record<string, unknown>; config: AiConfig }) {
|
||||
const prompt = characterTurnaroundPrompt(input.name, input.definition);
|
||||
const result = await runBackendCanvasGenerationTask({
|
||||
projectId: input.projectId,
|
||||
nodeId: `character-turnaround:${input.assetId}`,
|
||||
mode: "image",
|
||||
prompt,
|
||||
config: { ...input.config, model: input.config.imageModel || input.config.model, count: "1" },
|
||||
metadata: { operation: "character_turnaround", characterAssetId: input.assetId, resolvedCharacterVersions: [{ assetId: input.assetId, versionId: input.versionId }] },
|
||||
});
|
||||
const image = result.images?.[0];
|
||||
if (!image?.dataUrl) throw new Error("三视图任务没有返回图片");
|
||||
const sheet = image.storageKey
|
||||
? { url: image.dataUrl, storageKey: image.storageKey }
|
||||
: await uploadImage(image.dataUrl);
|
||||
const sheetResourceId = resourceIdFromStorageKey(sheet.storageKey);
|
||||
if (!sheetResourceId) throw new Error("三视图原图尚未保存到后端资源库");
|
||||
return [
|
||||
{ role: "turnaround_sheet", resourceId: sheetResourceId, metadata: { prompt } },
|
||||
{ role: "primary", resourceId: sheetResourceId, metadata: { source: "turnaround_sheet" } },
|
||||
];
|
||||
}
|
||||
|
||||
export function characterTurnaroundPrompt(name: string, definition: Record<string, unknown>) {
|
||||
const visual = [definition.role, definition.appearance, definition.physique, definition.clothing, definition.personality, definition.props, definition.consistencyPrompt, definition.multiViewPrompt]
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean)
|
||||
.join(";");
|
||||
if (!visual) throw new Error("请先填写剧情定位、角色外貌、体型或服装,再初始化三视图");
|
||||
return [
|
||||
`为角色“${name}”制作专业人物三视图设定表。`,
|
||||
"画面严格分成三个等宽竖向区域,从左到右依次为正面全身、右侧面全身、背面全身。",
|
||||
"三个视角必须是同一个人、同一服装、同一发型、同一体型和同一比例,站立中性姿势,完整显示头顶到脚底。",
|
||||
"纯净浅灰背景,均匀棚拍光线,不添加文字、边框、道具说明、表情变化或额外人物。",
|
||||
`角色设定:${visual}`,
|
||||
].join("\n");
|
||||
}
|
||||
65
web/src/pages/projects/detail/settings.tsx
Normal file
65
web/src/pages/projects/detail/settings.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { App, Button, Input, InputNumber, Modal, Select, Tooltip } from "antd";
|
||||
import { Archive, Check, Save, ShieldAlert } from "lucide-react";
|
||||
|
||||
import { canvasStylePresets } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { updateProject } from "@/services/api/projects";
|
||||
|
||||
import type { ProjectDetailViewProps } from "./shared";
|
||||
|
||||
export default function ProjectSettingsView({ detail, refreshProject }: ProjectDetailViewProps) {
|
||||
const { message } = App.useApp();
|
||||
const { project } = detail;
|
||||
const [name, setName] = useState(project.name);
|
||||
const [description, setDescription] = useState(project.description || "");
|
||||
const [aspectRatio, setAspectRatio] = useState(project.aspectRatio);
|
||||
const [sourceType, setSourceType] = useState(project.sourceType);
|
||||
const [stylePresetId, setStylePresetId] = useState(project.stylePresetId || "");
|
||||
const [activeTaskLimit, setActiveTaskLimit] = useState(project.activeTaskLimit || 3);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
useEffect(() => { setName(project.name); setDescription(project.description || ""); setAspectRatio(project.aspectRatio); setSourceType(project.sourceType); setStylePresetId(project.stylePresetId || ""); setActiveTaskLimit(project.activeTaskLimit || 3); }, [project]);
|
||||
const dirty = useMemo(() => name.trim() !== project.name || description !== (project.description || "") || aspectRatio !== project.aspectRatio || sourceType !== project.sourceType || stylePresetId !== (project.stylePresetId || "") || activeTaskLimit !== (project.activeTaskLimit || 3), [activeTaskLimit, aspectRatio, description, name, project, sourceType, stylePresetId]);
|
||||
const saveMutation = useMutation({ mutationFn: () => updateProject(project.id, { name: name.trim(), description, aspectRatio, sourceType, stylePresetId, activeTaskLimit }), onSuccess: () => { refreshProject(); message.success("项目设置已保存"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目设置保存失败") });
|
||||
const archiveMutation = useMutation({ mutationFn: () => updateProject(project.id, { status: project.status === "archived" ? "active" : "archived" }), onSuccess: () => { setArchiveOpen(false); refreshProject(); message.success(project.status === "archived" ? "项目已恢复" : "项目已归档"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目状态更新失败") });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="flex items-end justify-between gap-3 border-b border-border/70 pb-3"><div><h2 className="text-lg font-semibold">项目设置</h2><p className="mt-1 text-xs text-foreground/48">基础信息、项目画风与运行限额</p></div><Button type={dirty ? "primary" : "default"} icon={dirty ? <Save className="size-3.5" /> : <Check className="size-3.5" />} disabled={!dirty || !name.trim()} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存设置" : "已保存"}</Button></header>
|
||||
|
||||
<section className="border-b border-border/70 py-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">基础设置</h3>
|
||||
<div className="grid gap-x-4 gap-y-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Field label="项目名称" className="xl:col-span-2"><Input value={name} onChange={(event) => setName(event.target.value)} /></Field>
|
||||
<Field label="默认画幅"><Select className="w-full" value={aspectRatio} options={[{ label: "9:16 · 竖屏短剧", value: "9:16" }, { label: "16:9 · 横屏", value: "16:9" }, { label: "1:1 · 方形", value: "1:1" }]} onChange={setAspectRatio} /></Field>
|
||||
<Field label="内容来源"><Select className="w-full" value={sourceType} options={[{ label: "空白开始", value: "blank" }, { label: "导入小说", value: "novel" }, { label: "粘贴文本", value: "text" }]} onChange={setSourceType} /></Field>
|
||||
<Field label="项目简介" className="md:col-span-2 xl:col-span-3"><Input value={description} onChange={(event) => setDescription(event.target.value)} placeholder="一句话说明项目目标" /></Field>
|
||||
<Field label="活跃任务上限(个)"><InputNumber className="!w-full" min={1} max={20} value={activeTaskLimit} onChange={(value) => setActiveTaskLimit(value || 1)} /></Field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-border/70 py-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3"><div><h3 className="text-sm font-semibold">项目画风</h3><p className="mt-0.5 text-[11px] text-foreground/45">选择后会同步到项目画布中的画风节点</p></div>{stylePresetId ? <span className="text-[11px] text-[var(--workspace-accent)]">已选择</span> : <span className="text-[11px] text-foreground/40">未设置</span>}</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{canvasStylePresets.map((preset) => {
|
||||
const active = preset.id === stylePresetId;
|
||||
return <Tooltip key={preset.id} title={preset.description}><button type="button" onClick={() => setStylePresetId(preset.id)} className={`group grid min-w-0 grid-cols-[72px_minmax(0,1fr)_24px] items-center gap-2 overflow-hidden rounded-lg border p-1.5 text-left transition-colors ${active ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent-soft)]" : "border-border/80 hover:border-foreground/25"}`}><img src={preset.imageUrl} alt="" loading="lazy" className="h-11 w-[72px] rounded object-cover" /><span className="min-w-0"><span className="block truncate text-xs font-medium">{preset.title}</span><span className="mt-0.5 block truncate text-[10px] text-foreground/42">{preset.description}</span></span><span className={`grid size-5 place-items-center rounded-full ${active ? "bg-[var(--workspace-accent)] text-white" : "border border-border text-transparent"}`}><Check className="size-3" /></span></button></Tooltip>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-4">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-red-500/20 bg-red-500/[.025] px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-2.5"><span className="grid size-7 shrink-0 place-items-center rounded bg-red-500/10 text-red-500"><Archive className="size-3.5" /></span><div className="min-w-0"><h3 className="text-sm font-medium">{project.status === "archived" ? "恢复项目" : "归档项目"}</h3><p className="mt-0.5 text-[11px] text-foreground/48">{project.status === "archived" ? "恢复后可继续创建章节、画布和生成任务" : "保留全部章节、画布和资产,停止项目内新建与生成"}</p></div></div>
|
||||
<Button size="small" danger={project.status !== "archived"} icon={project.status === "archived" ? <Check className="size-3.5" /> : <ShieldAlert className="size-3.5" />} onClick={() => setArchiveOpen(true)}>{project.status === "archived" ? "恢复项目" : "归档项目"}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Modal title={project.status === "archived" ? "恢复项目" : "归档项目"} open={archiveOpen} okText={project.status === "archived" ? "确认恢复" : "确认归档"} cancelText="取消" okButtonProps={{ danger: project.status !== "archived", loading: archiveMutation.isPending }} onCancel={() => setArchiveOpen(false)} onOk={() => archiveMutation.mutate()} width={440} styles={{ body: { paddingTop: 12 } }}><p className="m-0 text-sm leading-6 text-foreground/65">{project.status === "archived" ? "恢复后项目会重新进入可编辑状态。" : "归档不会删除章节、画布或资产,画布文档仍可在创作画布中打开。"}</p></Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, className = "", children }: { label: string; className?: string; children: ReactNode }) {
|
||||
return <label className={`grid gap-1.5 text-xs ${className}`}><span className="font-medium text-foreground/62">{label}</span>{children}</label>;
|
||||
}
|
||||
121
web/src/pages/projects/detail/shared.tsx
Normal file
121
web/src/pages/projects/detail/shared.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Tag } from "antd";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { ProjectDetail, ProjectUnit } from "@/services/api/projects";
|
||||
|
||||
export type ProjectDetailViewProps = {
|
||||
detail: ProjectDetail;
|
||||
refreshProject: () => void;
|
||||
onCreateCanvas: () => void;
|
||||
};
|
||||
|
||||
export const categoryLabels: Record<string, string> = {
|
||||
character: "角色",
|
||||
environment: "场景",
|
||||
wardrobe: "服饰",
|
||||
prop: "道具",
|
||||
weapon: "武器",
|
||||
style: "画风",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
export const mediaLabels: Record<string, string> = {
|
||||
image: "图片",
|
||||
video: "视频",
|
||||
audio: "音频",
|
||||
text: "文本",
|
||||
model: "3D 模型",
|
||||
entity: "角色卡",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
active: "进行中",
|
||||
archived: "已归档",
|
||||
draft: "草稿",
|
||||
ready: "待制作",
|
||||
completed: "已完成",
|
||||
review: "待审核",
|
||||
confirmed: "已确认",
|
||||
pending: "待处理",
|
||||
pending_confirmation: "待确认",
|
||||
running: "进行中",
|
||||
failed: "失败",
|
||||
ignored: "已忽略",
|
||||
skipped: "已跳过",
|
||||
cancelled: "已取消",
|
||||
succeeded: "已完成",
|
||||
disabled: "已停用",
|
||||
idle: "待开始",
|
||||
loading: "处理中",
|
||||
queued: "排队中",
|
||||
success: "已完成",
|
||||
error: "异常",
|
||||
deleted: "已删除",
|
||||
reserved: "已冻结",
|
||||
settled: "已结算",
|
||||
refunded: "已退款",
|
||||
uncertain: "待核对",
|
||||
};
|
||||
|
||||
const sourceTypeLabels: Record<string, string> = {
|
||||
blank: "空白开始",
|
||||
novel: "导入小说",
|
||||
text: "粘贴文本",
|
||||
};
|
||||
|
||||
export function categoryLabel(value: string) {
|
||||
return categoryLabels[value] || "其他";
|
||||
}
|
||||
|
||||
export function statusLabel(value: string) {
|
||||
return statusLabels[value] || "未知状态";
|
||||
}
|
||||
|
||||
export function mediaLabel(value: string) {
|
||||
return mediaLabels[value] || "其他类型";
|
||||
}
|
||||
|
||||
export function sourceTypeLabel(value: string) {
|
||||
return sourceTypeLabels[value] || "其他来源";
|
||||
}
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
const color = status === "completed" || status === "confirmed" || status === "succeeded" ? "success" : status === "failed" ? "error" : status === "running" || status === "active" ? "processing" : status === "review" || status === "pending_confirmation" ? "warning" : "default";
|
||||
return <Tag color={color} className="m-0 !rounded-full !px-2 !text-[11px]">{statusLabel(status)}</Tag>;
|
||||
}
|
||||
|
||||
export function SectionTitle({ eyebrow, title, description, action }: { eyebrow?: string; title: string; description?: string; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-b border-border/70 pb-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{eyebrow ? <div className="mb-1 text-[10px] font-semibold text-foreground/40">{eyebrow}</div> : null}
|
||||
<h2 className="text-lg font-semibold tracking-normal">{title}</h2>
|
||||
{description ? <p className="mt-1 text-sm leading-5 text-foreground/55">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricTile({ label, value, detail, accent = false }: { label: string; value: string | number; detail?: string; accent?: boolean }) {
|
||||
return (
|
||||
<div className={`overflow-hidden rounded-lg border px-3 py-3 ${accent ? "border-[color-mix(in_srgb,var(--workspace-accent)_30%,transparent)] bg-[var(--workspace-accent-soft)]" : "border-border/80 bg-background/70"}`}>
|
||||
<div className="text-xs text-foreground/50">{label}</div>
|
||||
<div className="mt-2 flex items-end gap-2"><strong className="text-2xl font-semibold tracking-normal">{value}</strong>{detail ? <span className="pb-0.5 text-xs text-foreground/45">{detail}</span> : null}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UnitProgress({ unit }: { unit: ProjectUnit }) {
|
||||
const progress = unit.status === "completed" ? 100 : unit.status === "ready" ? 66 : 24;
|
||||
return <div className="h-1.5 w-20 overflow-hidden rounded-full bg-foreground/10"><div className="h-full rounded-full bg-[var(--workspace-accent)] transition-[width] duration-200" style={{ width: `${progress}%` }} /></div>;
|
||||
}
|
||||
|
||||
export function formatTime(value?: string) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function formatCount(value: number) {
|
||||
return new Intl.NumberFormat("zh-CN").format(value);
|
||||
}
|
||||
141
web/src/pages/projects/index.tsx
Normal file
141
web/src/pages/projects/index.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Form, Input, Modal, Select } from "antd";
|
||||
import { ArrowRight, BookOpenText, FolderKanban, Images, LayoutGrid, Plus, Search } from "lucide-react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { ListToolbar, PageHeader, TableSurface, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench";
|
||||
import { createProject, listProjects, type ProjectSummary } from "@/services/api/projects";
|
||||
|
||||
import { sourceTypeLabel } from "./detail/shared";
|
||||
|
||||
type ProjectForm = { name: string; aspectRatio: string; sourceType: string };
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { message } = App.useApp();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState<"all" | "active" | "archived">("all");
|
||||
const [sort, setSort] = useState<"updated" | "progress" | "name">("updated");
|
||||
const createOpen = searchParams.get("create") === "1";
|
||||
const setCreateOpen = (open: boolean) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (open) next.set("create", "1");
|
||||
else next.delete("create");
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
const query = useQuery({ queryKey: ["projects"], queryFn: listProjects });
|
||||
const mutation = useMutation({
|
||||
mutationFn: createProject,
|
||||
onSuccess: ({ project }) => {
|
||||
setCreateOpen(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
navigate(`/projects/${project.id}/overview`);
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "项目创建失败"),
|
||||
});
|
||||
const rows = useMemo(() => {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
return [...(query.data?.projects || [])]
|
||||
.filter(({ project }) => status === "all" || project.status === status)
|
||||
.filter(({ project }) => !normalizedKeyword || `${project.name} ${project.description} ${project.stylePresetId}`.toLowerCase().includes(normalizedKeyword))
|
||||
.sort((left, right) => {
|
||||
if (sort === "name") return left.project.name.localeCompare(right.project.name, "zh-CN");
|
||||
if (sort === "progress") return projectSummaryCompletion(right) - projectSummaryCompletion(left);
|
||||
return right.project.updatedAt.localeCompare(left.project.updatedAt);
|
||||
});
|
||||
}, [keyword, query.data, sort, status]);
|
||||
|
||||
return (
|
||||
<WorkspacePage>
|
||||
<PageHeader
|
||||
icon="projects"
|
||||
title="短剧创作"
|
||||
description="按制作阶段查看故事项目,继续最近工作或处理未完成章节。"
|
||||
meta={<span className="text-xs text-foreground/45">{rows.length} 个</span>}
|
||||
actions={<><Button icon={<LayoutGrid className="size-3.5" />} onClick={() => navigate("/canvas")}>画布</Button><Button type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>创建项目</Button></>}
|
||||
/>
|
||||
<ListToolbar active={Boolean(keyword || status !== "all" || sort !== "updated")} onReset={() => { setKeyword(""); setStatus("all"); setSort("updated"); }}>
|
||||
<Input allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索项目、简介或画风" onChange={(event) => setKeyword(event.target.value)} />
|
||||
<Select className="w-32" value={status} onChange={setStatus} options={[{ label: "全部状态", value: "all" }, { label: "进行中", value: "active" }, { label: "已归档", value: "archived" }]} />
|
||||
<Select className="w-32" value={sort} onChange={setSort} options={[{ label: "最近更新", value: "updated" }, { label: "章节进度", value: "progress" }, { label: "项目名称", value: "name" }]} />
|
||||
</ListToolbar>
|
||||
|
||||
{query.isError ? <WorkspaceErrorState description={query.error instanceof Error ? query.error.message : "项目列表加载失败"} onRetry={() => void query.refetch()} /> : null}
|
||||
{query.isLoading ? <WorkspaceLoadingState label="正在整理项目" detail="读取章节、画布与资产进度" /> : null}
|
||||
{!query.isLoading && rows.length ? (
|
||||
<TableSurface>
|
||||
<div className="hidden h-10 grid-cols-[minmax(240px,1.2fr)_minmax(150px,.75fr)_minmax(150px,.7fr)_minmax(180px,.8fr)_120px_28px] items-center gap-4 border-b border-border/70 bg-foreground/[.025] px-4 text-[11px] font-medium text-foreground/42 lg:grid">
|
||||
<span>项目</span><span>当前阶段</span><span>章节进度</span><span>内容</span><span>最近更新</span><span />
|
||||
</div>
|
||||
<div className="divide-y divide-border/65">
|
||||
{rows.map((row) => <ProjectRow key={row.project.id} row={row} />)}
|
||||
</div>
|
||||
</TableSurface>
|
||||
) : null}
|
||||
{!query.isLoading && !rows.length && !query.isError ? (
|
||||
<WorkspaceState
|
||||
icon="projects"
|
||||
title={keyword || status !== "all" ? "没有匹配的项目" : "创建第一个故事项目"}
|
||||
description={keyword || status !== "all" ? "调整搜索词或状态筛选后再试。" : "项目会集中保存章节、项目画布、角色场景和制作进度。自由试图可从画布开始。"}
|
||||
action={!keyword && status === "all" ? <Button type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>创建项目</Button> : undefined}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal title="创建短剧项目" open={createOpen} footer={null} destroyOnHidden onCancel={() => setCreateOpen(false)} width={500} styles={{ body: { paddingTop: 12 } }}>
|
||||
<Form<ProjectForm> layout="vertical" initialValues={{ aspectRatio: "9:16", sourceType: "blank" }} onFinish={(values) => mutation.mutate({ ...values, type: "short-drama" })}>
|
||||
<Form.Item name="name" label="项目名称" rules={[{ required: true, whitespace: true, message: "请输入项目名称" }]}><Input autoFocus placeholder="例如:长安夜行" /></Form.Item>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Form.Item name="aspectRatio" label="默认画幅"><Select options={[{ label: "9:16 竖屏", value: "9:16" }, { label: "16:9 横屏", value: "16:9" }, { label: "1:1 方形", value: "1:1" }]} /></Form.Item>
|
||||
<Form.Item name="sourceType" label="内容来源"><Select options={[{ label: "空白开始", value: "blank" }, { label: "导入小说", value: "novel" }, { label: "粘贴文本", value: "text" }]} /></Form.Item>
|
||||
</div>
|
||||
<p className="-mt-1 mb-5 text-xs leading-5 text-foreground/48">创建后先进入项目概览。章节、画风和参考资产可以逐步补充。</p>
|
||||
<div className="flex justify-end gap-2"><Button onClick={() => setCreateOpen(false)}>取消</Button><Button type="primary" htmlType="submit" loading={mutation.isPending}>创建项目</Button></div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</WorkspacePage>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectRow({ row }: { row: ProjectSummary }) {
|
||||
const completion = projectSummaryCompletion(row);
|
||||
const stage = projectSummaryStage(row);
|
||||
return (
|
||||
<Link to={`/projects/${row.project.id}/overview`} className="group block min-w-0 px-3 py-3 transition-colors hover:bg-foreground/[.025] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-foreground/20 sm:px-4 lg:grid lg:min-h-[76px] lg:grid-cols-[minmax(240px,1.2fr)_minmax(150px,.75fr)_minmax(150px,.7fr)_minmax(180px,.8fr)_120px_28px] lg:items-center lg:gap-4 lg:py-2.5">
|
||||
<span className="flex min-w-0 items-start gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.06] text-foreground/50"><FolderKanban className="size-4" /></span>
|
||||
<span className="min-w-0"><span className="flex min-w-0 items-center gap-2"><strong className="truncate text-sm font-semibold">{row.project.name}</strong>{row.project.status === "archived" ? <span className="shrink-0 rounded bg-foreground/[.07] px-1.5 py-0.5 text-[10px] text-foreground/45">已归档</span> : null}</span><span className="mt-1 block truncate text-[11px] text-foreground/42">{row.project.stylePresetId || "未设置画风"} · {row.project.aspectRatio} · {sourceTypeLabel(row.project.sourceType)}</span></span>
|
||||
</span>
|
||||
|
||||
<span className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] items-start gap-2 lg:mt-0 lg:block">
|
||||
<span className="text-[10px] text-foreground/38 lg:hidden">当前阶段</span>
|
||||
<span className="min-w-0"><span className="block text-xs font-medium text-foreground/78">{stage.label}</span><span className="mt-1 line-clamp-1 block text-[10px] text-foreground/40">{stage.detail}</span></span>
|
||||
</span>
|
||||
|
||||
<span className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] items-center gap-2 lg:mt-0 lg:block">
|
||||
<span className="text-[10px] text-foreground/38 lg:hidden">章节进度</span>
|
||||
<span className="min-w-0"><span className="flex items-center justify-between text-[10px] text-foreground/48"><span>{row.completedUnitCount}/{row.unitCount} 章</span><span>{completion}%</span></span><span className="mt-1.5 block h-1.5 overflow-hidden rounded-full bg-foreground/[.08]"><span className="block h-full rounded-full bg-[var(--workspace-accent)] transition-[width]" style={{ width: `${completion}%` }} /></span></span>
|
||||
</span>
|
||||
|
||||
<span className="mt-3 flex items-center gap-4 text-[11px] text-foreground/50 lg:mt-0">
|
||||
<ProjectCount icon={<BookOpenText className="size-3.5" />} label="章节" value={row.unitCount} />
|
||||
<ProjectCount icon={<LayoutGrid className="size-3.5" />} label="画布" value={row.canvasCount} />
|
||||
<ProjectCount icon={<Images className="size-3.5" />} label="资产" value={row.assetCount} />
|
||||
</span>
|
||||
<span className="mt-3 block text-[11px] tabular-nums text-foreground/42 lg:mt-0">{formatProjectTime(row.project.updatedAt)}</span>
|
||||
<ArrowRight className="hidden size-4 text-foreground/25 transition-transform group-hover:translate-x-0.5 group-hover:text-foreground/60 lg:block" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCount({ icon, label, value }: { icon: ReactNode; label: string; value: number }) {
|
||||
return <span className="inline-flex items-center gap-1.5" title={`${value} ${label}`}><span className="text-foreground/32">{icon}</span><strong className="font-medium tabular-nums text-foreground/65">{value}</strong><span className="hidden 2xl:inline">{label}</span></span>;
|
||||
}
|
||||
|
||||
function formatProjectTime(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
Reference in New Issue
Block a user