feat(*): 生成工作台 - 新增项目画风配置与图片模型能力裁剪
This commit is contained in:
1 parent
2b0d56f8e3
commit
1973afc356
52 files changed
+3309
-361
No files matched your search
@@ -7,7 +7,7 @@ import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
|
||||
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import {
|
||||
bindProjectCharacterVoice,
|
||||
@@ -121,7 +121,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const model = effectiveConfig.imageModel || effectiveConfig.model;
|
||||
const config = { ...effectiveConfig, model };
|
||||
if (!isAiConfigReady(config, model)) throw new Error("请先在设置中配置可用的图片模型");
|
||||
const projectStyle = resolveCanvasStylePreset(detail.project.stylePresetId);
|
||||
const projectStyle = resolveProjectCanvasStyle(detail.project.stylePresetId, detail.project.styleProfileJson);
|
||||
await generateCharacterTurnaround({ projectId: detail.project.id, assetId: asset.id, versionId: asset.character.versionId, name: asset.title, definition: asset.character.definition, projectStyle, config });
|
||||
return getProjectCharacter(detail.project.id, asset.id);
|
||||
},
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { normalizeCharacterName } from "@/lib/canvas/canvas-character-reference";
|
||||
import { decodeNovelText, splitTextIntoChapters } from "@/lib/canvas/canvas-document";
|
||||
import { upsertProjectChapterStoryboard } from "@/lib/canvas/project-chapter-storyboard";
|
||||
@@ -249,7 +249,7 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
|
||||
chapterId: unit.id,
|
||||
chapterTitle: unit.title,
|
||||
sourceText,
|
||||
projectStyle: resolveCanvasStylePreset(detail.project.stylePresetId)?.prompt || "",
|
||||
projectStyle: resolveProjectCanvasStyle(detail.project.stylePresetId, detail.project.styleProfileJson)?.prompt || "",
|
||||
config: effectiveConfig,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { resolveStyleExecutionPlan, serializeStyleProfile, type StyleProfileSnapshot } from "@/lib/canvas/style-profile";
|
||||
import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
type ProjectStylePrompt = { id: string; title: string; prompt: string };
|
||||
type ProjectStylePrompt = { id: string; title: string; prompt: string; profile?: StyleProfileSnapshot };
|
||||
|
||||
export async function generateCharacterTurnaround(input: { projectId: string; assetId: string; versionId: string; name: string; definition: Record<string, unknown>; projectStyle?: ProjectStylePrompt; config: AiConfig }) {
|
||||
const promptTemplateVariables = characterTurnaroundVariables(input.name, input.definition, input.projectStyle);
|
||||
const requestConfig = resolveModelRequestConfig(input.config, input.config.imageModel || input.config.model);
|
||||
const styleExecutionPlan = input.projectStyle?.profile ? resolveStyleExecutionPlan(input.projectStyle.profile, { mode: "image", model: requestConfig.model, interfaceType: requestConfig.interfaceType || requestConfig.apiFormat }) : undefined;
|
||||
if (styleExecutionPlan?.status === "blocked") throw new Error(`项目画风与当前图片模型不兼容:${styleExecutionPlan.warnings.join(";")}`);
|
||||
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", promptTemplateOperation: "character_turnaround", promptTemplateVariables, characterAssetId: input.assetId, stylePresetId: input.projectStyle?.id, resolvedCharacterVersions: [{ assetId: input.assetId, versionId: input.versionId }] },
|
||||
metadata: { operation: "character_turnaround", promptTemplateOperation: "character_turnaround", promptTemplateVariables, characterAssetId: input.assetId, stylePresetId: input.projectStyle?.id, styleProfileJson: input.projectStyle?.profile ? serializeStyleProfile(input.projectStyle.profile) : undefined, styleExecutionPlan, resolvedCharacterVersions: [{ assetId: input.assetId, versionId: input.versionId }] },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { App, Button, Input, Modal, Select } from "antd";
|
||||
import { Archive, Check, Eye, Palette, Save, ShieldAlert } from "lucide-react";
|
||||
import { Archive, Check, Eye, Palette, Pencil, Save, ShieldAlert } from "lucide-react";
|
||||
|
||||
import { CanvasStyleDetailModal, CanvasStylePickerModal, resolveCanvasStylePreset, type CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { CanvasStyleDetailModal, CanvasStylePickerModal, resolveProjectCanvasStyle, type CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { createStyleProfileSnapshot, parseStyleProfile, resolveStyleExecutionPlan, serializeStyleProfile } from "@/lib/canvas/style-profile";
|
||||
import { updateProject } from "@/services/api/projects";
|
||||
import { resolveModelRequestConfig, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
|
||||
import type { ProjectDetailViewProps } from "./shared";
|
||||
|
||||
export default function ProjectSettingsView({ detail, refreshProject }: ProjectDetailViewProps) {
|
||||
const { message } = App.useApp();
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
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 [styleProfileJson, setStyleProfileJson] = useState(project.styleProfileJson || "");
|
||||
const [styleDetail, setStyleDetail] = useState<CanvasStylePreset | null>(null);
|
||||
const [stylePickerOpen, setStylePickerOpen] = useState(false);
|
||||
const [styleEditorRequested, setStyleEditorRequested] = useState(false);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
useEffect(() => { setName(project.name); setDescription(project.description || ""); setAspectRatio(project.aspectRatio); setSourceType(project.sourceType); setStylePresetId(project.stylePresetId || ""); }, [project]);
|
||||
const dirty = useMemo(() => name.trim() !== project.name || description !== (project.description || "") || aspectRatio !== project.aspectRatio || sourceType !== project.sourceType || stylePresetId !== (project.stylePresetId || ""), [aspectRatio, description, name, project, sourceType, stylePresetId]);
|
||||
const selectedStyle = useMemo(() => resolveCanvasStylePreset(stylePresetId), [stylePresetId]);
|
||||
const saveMutation = useMutation({ mutationFn: () => updateProject(project.id, { name: name.trim(), description, aspectRatio, sourceType, stylePresetId }), onSuccess: () => { refreshProject(); message.success("项目设置已保存"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目设置保存失败") });
|
||||
useEffect(() => { setName(project.name); setDescription(project.description || ""); setAspectRatio(project.aspectRatio); setSourceType(project.sourceType); setStylePresetId(project.stylePresetId || ""); setStyleProfileJson(project.styleProfileJson || ""); }, [project]);
|
||||
const dirty = useMemo(() => name.trim() !== project.name || description !== (project.description || "") || aspectRatio !== project.aspectRatio || sourceType !== project.sourceType || stylePresetId !== (project.stylePresetId || "") || styleProfileJson !== (project.styleProfileJson || ""), [aspectRatio, description, name, project, sourceType, stylePresetId, styleProfileJson]);
|
||||
const selectedStyle = useMemo(() => resolveProjectCanvasStyle(stylePresetId, styleProfileJson), [stylePresetId, styleProfileJson]);
|
||||
const styleProfile = useMemo(() => parseStyleProfile(styleProfileJson) || selectedStyle?.profile || (selectedStyle ? createStyleProfileSnapshot(selectedStyle) : null), [selectedStyle, styleProfileJson]);
|
||||
const enabledStyleAssets = styleProfile?.assets.filter((asset) => asset.enabled !== false) || [];
|
||||
const styleExecutionPlans = useMemo(() => {
|
||||
if (!styleProfile) return null;
|
||||
const imageConfig = resolveModelRequestConfig(effectiveConfig, effectiveConfig.imageModel || effectiveConfig.model);
|
||||
const videoConfig = resolveModelRequestConfig(effectiveConfig, effectiveConfig.videoModel || effectiveConfig.model);
|
||||
return {
|
||||
image: resolveStyleExecutionPlan(styleProfile, { mode: "image", model: imageConfig.model, interfaceType: imageConfig.interfaceType || imageConfig.apiFormat }),
|
||||
video: resolveStyleExecutionPlan(styleProfile, { mode: "video", model: videoConfig.model, interfaceType: videoConfig.interfaceType || videoConfig.apiFormat }),
|
||||
};
|
||||
}, [effectiveConfig, styleProfile]);
|
||||
const saveMutation = useMutation({ mutationFn: () => updateProject(project.id, { name: name.trim(), description, aspectRatio, sourceType, stylePresetId, styleProfileJson }), 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 (
|
||||
@@ -40,30 +56,45 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
</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-[var(--fs-label)] text-foreground/45">选择后会同步到项目画布中的画风节点</p></div>{stylePresetId ? <span className="text-[var(--fs-label)] text-[var(--workspace-accent)]">已选择</span> : <span className="text-[var(--fs-label)] text-foreground/40">未设置</span>}</div>
|
||||
<div className="flex flex-col gap-3 border-y border-border/70 py-3 sm:flex-row sm:items-center">
|
||||
{selectedStyle ? <img src={selectedStyle.imageUrl} width="160" height="90" alt={`${selectedStyle.title}画风示意`} className="aspect-video w-40 shrink-0 rounded-md object-cover" /> : <span className="grid aspect-video w-40 shrink-0 place-items-center rounded-md bg-foreground/[.04] text-foreground/35"><Palette className="size-5" /></span>}
|
||||
<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-[var(--fs-label)] text-foreground/45">项目保存当前版本快照;修改“我的风格”不会自动改写历史项目</p></div>{styleProfile ? <span className="text-[var(--fs-label)] text-[var(--workspace-accent)]">{styleProfile.source === "user" ? "来自我的风格" : styleProfile.source === "external" ? "外部导入" : "系统预设"}</span> : <span className="text-[var(--fs-label)] text-foreground/40">未设置</span>}</div>
|
||||
<div className="flex flex-col gap-3 border-y border-border/70 py-3 lg:flex-row lg:items-center">
|
||||
{selectedStyle ? <img src={selectedStyle.imageUrl} width="160" height="90" alt={`${selectedStyle.title}画风示意`} className="aspect-video w-40 shrink-0 rounded-md object-cover" /> : <span className="grid aspect-video w-40 shrink-0 place-items-center rounded-md bg-foreground/5 text-foreground/35"><Palette className="size-5" /></span>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold">{selectedStyle?.title || "尚未设置项目画风"}</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-foreground/48">{selectedStyle?.description || "选择题材世界、叙事气质、视觉媒介和角色造型。"}</p>
|
||||
{selectedStyle ? <div className="mt-2 flex flex-wrap gap-1">{selectedStyle.tags.map((tag) => <span key={tag} className="rounded bg-foreground/[.06] px-1.5 py-0.5 text-[var(--fs-tiny)] text-foreground/55">{tag}</span>)}</div> : null}
|
||||
<div className="text-sm font-semibold">{styleProfile?.title || selectedStyle?.title || "尚未设置项目画风"}</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-foreground/48">{styleProfile?.description || selectedStyle?.description || "从系统风格开始,或创建可自由编辑的项目视觉规范。"}</p>
|
||||
{styleProfile ? <div className="mt-2 flex flex-wrap gap-1">{styleProfile.tags.map((tag) => <span key={tag} className="rounded bg-foreground/10 px-1.5 py-0.5 text-[var(--fs-tiny)] text-foreground/55">{tag}</span>)}</div> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2"><Button icon={<Eye className="size-3.5" />} disabled={!selectedStyle} onClick={() => setStyleDetail(selectedStyle || null)}>查看规范</Button><Button icon={<Palette className="size-3.5" />} onClick={() => setStylePickerOpen(true)}>{selectedStyle ? "更换画风" : "选择画风"}</Button></div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2"><Button icon={<Eye className="size-3.5" />} disabled={!selectedStyle} onClick={() => setStyleDetail(selectedStyle || null)}>查看规范</Button><Button type={styleProfile ? "primary" : "default"} icon={<Pencil className="size-3.5" />} disabled={!styleProfile} onClick={() => { setStyleEditorRequested(true); setStylePickerOpen(true); }}>编辑画风</Button><Button icon={<Palette className="size-3.5" />} onClick={() => { setStyleEditorRequested(false); setStylePickerOpen(true); }}>{selectedStyle ? "更换画风" : "选择画风"}</Button></div>
|
||||
</div>
|
||||
{styleProfile ? <div className="grid grid-cols-2 border-b border-border text-xs sm:grid-cols-5"><StyleMetric label="执行策略" value={styleProfile.executionPolicy === "strict-assets" ? "严格校验" : "兼容降级"} /><StyleMetric label="绑定资产" value={`${styleProfile.assets.length} 个`} /><StyleMetric label="已启用" value={`${enabledStyleAssets.length} 个`} /><StyleMetric label="图片执行" value={styleExecutionStatusLabel(styleExecutionPlans?.image.status)} /><StyleMetric label="视频执行" value={styleExecutionStatusLabel(styleExecutionPlans?.video.status)} /></div> : null}
|
||||
{styleExecutionPlans && (styleExecutionPlans.image.warnings.length || styleExecutionPlans.video.warnings.length) ? <div className="grid gap-1 border-b border-border px-3 py-2 text-[var(--fs-label)] leading-5 text-amber-600 dark:text-amber-400">{styleExecutionPlans.image.warnings.length ? <p>图片:{styleExecutionPlans.image.warnings.join(";")}</p> : null}{styleExecutionPlans.video.warnings.length ? <p>视频:{styleExecutionPlans.video.warnings.join(";")}</p> : null}</div> : null}
|
||||
</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 flex-col gap-3 rounded-lg border border-red-500/20 bg-red-500/5 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-[var(--fs-label)] 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>
|
||||
<CanvasStylePickerModal open={stylePickerOpen} value={stylePresetId} onClose={() => setStylePickerOpen(false)} onSelect={(preset) => { setStylePresetId(preset.id); setStylePickerOpen(false); }} />
|
||||
<CanvasStyleDetailModal open={Boolean(styleDetail)} preset={styleDetail} selected={styleDetail?.id === stylePresetId} onClose={() => setStyleDetail(null)} onSelect={(preset) => { setStylePresetId(preset.id); setStyleDetail(null); }} />
|
||||
<CanvasStylePickerModal open={stylePickerOpen} value={stylePresetId} currentProfile={styleProfile} startInEditor={styleEditorRequested} onClose={() => { setStylePickerOpen(false); setStyleEditorRequested(false); }} onSelect={(preset) => { applyStyle(preset); setStylePickerOpen(false); setStyleEditorRequested(false); }} />
|
||||
<CanvasStyleDetailModal open={Boolean(styleDetail)} preset={styleDetail} selected={styleDetail?.id === stylePresetId} onClose={() => setStyleDetail(null)} onSelect={(preset) => { applyStyle(preset); setStyleDetail(null); }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
function applyStyle(preset: CanvasStylePreset) {
|
||||
setStylePresetId(preset.id);
|
||||
setStyleProfileJson(serializeStyleProfile(preset.profile || createStyleProfileSnapshot(preset)));
|
||||
}
|
||||
}
|
||||
|
||||
function StyleMetric({ label, value }: { label: string; value: string }) {
|
||||
return <div className="min-w-0 border-r border-t border-border px-3 py-2 first:border-l sm:border-t-0"><span className="block text-[var(--fs-tiny)] text-foreground/40">{label}</span><span className="mt-0.5 block truncate font-medium text-foreground/65">{value}</span></div>;
|
||||
}
|
||||
|
||||
function styleExecutionStatusLabel(status?: "ready" | "degraded" | "blocked") {
|
||||
return status === "blocked" ? "不可执行" : status === "degraded" ? "降级执行" : "完整执行";
|
||||
}
|
||||
|
||||
function Field({ label, className = "", children }: { label: string; className?: string; children: ReactNode }) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { CollectionGrid, ListToolbar, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { parseStyleProfile } from "@/lib/canvas/style-profile";
|
||||
import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench";
|
||||
import { createProject, listProjects, type ProjectSummary } from "@/services/api/projects";
|
||||
|
||||
@@ -43,7 +44,7 @@ export default function ProjectsPage() {
|
||||
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} ${resolveCanvasStylePreset(project.stylePresetId)?.title || ""}`.toLowerCase().includes(normalizedKeyword))
|
||||
.filter(({ project }) => !normalizedKeyword || `${project.name} ${project.description} ${project.stylePresetId} ${parseStyleProfile(project.styleProfileJson)?.title || resolveCanvasStylePreset(project.stylePresetId)?.title || ""}`.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);
|
||||
@@ -106,7 +107,7 @@ export default function ProjectsPage() {
|
||||
function ProjectRow({ row }: { row: ProjectSummary }) {
|
||||
const completion = projectSummaryCompletion(row);
|
||||
const stage = projectSummaryStage(row);
|
||||
const styleTitle = resolveCanvasStylePreset(row.project.stylePresetId)?.title || (row.project.stylePresetId ? "自定义画风" : "未设置画风");
|
||||
const styleTitle = parseStyleProfile(row.project.styleProfileJson)?.title || resolveCanvasStylePreset(row.project.stylePresetId)?.title || (row.project.stylePresetId ? "自定义画风" : "未设置画风");
|
||||
return (
|
||||
<Link to={`/projects/${row.project.id}/overview`} className="library-card project-library-card group">
|
||||
<span className="project-library-cover"><span className="project-library-cover-icon"><FolderKanban className="size-7" /></span><span className="project-library-cover-ratio">{row.project.aspectRatio}</span><span className="project-library-cover-stage">{stage.label}</span></span>
|
||||
|
||||
Reference in new issue
Block a user