feat: persist canvas detail archives and extend detail metadata
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-19 13:14:05 +08:00
parent 2332a5d6c8
commit 81c1e955cb
7 changed files with 1071 additions and 83 deletions

View File

@ -0,0 +1,163 @@
import { NextRequest, NextResponse } from "next/server";
import { and, desc, eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasDetailArchive, canvasProject } from "@/lib/db/schema";
type DetailArchiveBrief = {
productSpu?: string;
demandType?: string;
productName?: string;
targetAudience?: string;
mainUser?: string;
usageScenario?: string;
triggerMoment?: string;
coreNeed?: string;
riskConcern?: string;
expectedResult?: string;
coreSellingPoint?: string;
positioningStatement?: string;
userVoice?: string;
competitorReference?: string;
priceBand?: string;
brandTone?: string;
bannedWords?: string;
};
type DetailArchiveSlot = {
url: string;
name?: string;
contentType?: string;
} | null;
function sanitizeBrief(input: unknown): DetailArchiveBrief {
const raw = (input || {}) as Record<string, unknown>;
const normalize = (key: string) => String(raw[key] || "").replace(/\s+/g, " ").trim();
return {
productSpu: normalize("productSpu"),
demandType: normalize("demandType"),
productName: normalize("productName"),
targetAudience: normalize("targetAudience"),
mainUser: normalize("mainUser"),
usageScenario: normalize("usageScenario"),
triggerMoment: normalize("triggerMoment"),
coreNeed: normalize("coreNeed"),
riskConcern: normalize("riskConcern"),
expectedResult: normalize("expectedResult"),
coreSellingPoint: normalize("coreSellingPoint"),
positioningStatement: normalize("positioningStatement"),
userVoice: normalize("userVoice"),
competitorReference: normalize("competitorReference"),
priceBand: normalize("priceBand"),
brandTone: normalize("brandTone"),
bannedWords: normalize("bannedWords"),
};
}
function sanitizeSlots(input: unknown): DetailArchiveSlot[] {
if (!Array.isArray(input)) return [];
return input.slice(0, 16).map((item) => {
if (!item || typeof item !== "object") return null;
const raw = item as Record<string, unknown>;
const url = String(raw.url || "").trim();
if (!url) return null;
return {
url,
name: String(raw.name || "").trim() || undefined,
contentType: String(raw.contentType || "").trim() || "image/jpeg",
};
});
}
async function ensureProjectOwnership(projectId: string, userId: string) {
const [project] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId)))
.limit(1);
return Boolean(project);
}
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id: projectId } = await params;
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
if (!ownsProject) {
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
}
const archives = await db
.select()
.from(canvasDetailArchive)
.where(
and(
eq(canvasDetailArchive.projectId, projectId),
eq(canvasDetailArchive.userId, session.user.id)
)
)
.orderBy(desc(canvasDetailArchive.createdAt))
.limit(30);
return NextResponse.json({ success: true, data: archives });
} catch (error) {
console.error("[CanvasDetailArchive] GET failed:", error);
return NextResponse.json({ success: false, error: "获取详情存档失败" }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id: projectId } = await params;
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
if (!ownsProject) {
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
}
const body = await request.json();
const archiveRaw = (body?.archive || {}) as Record<string, unknown>;
const title = String(archiveRaw.title || "").trim() || "AI详情项目";
const prompt = String(archiveRaw.prompt || "").replace(/\s+/g, " ").trim();
const summary = String(archiveRaw.summary || "").trim();
const imageCount = Number(archiveRaw.imageCount);
const coverUrl = String(archiveRaw.coverUrl || "").trim();
const brief = sanitizeBrief(archiveRaw.brief);
const slots = sanitizeSlots(archiveRaw.slots);
const [saved] = await db
.insert(canvasDetailArchive)
.values({
projectId,
userId: session.user.id,
title,
prompt,
brief,
slots,
summary: summary || null,
imageCount: Number.isFinite(imageCount) ? Math.max(0, Math.round(imageCount)) : null,
coverUrl: coverUrl || null,
})
.returning();
return NextResponse.json({ success: true, data: saved });
} catch (error) {
console.error("[CanvasDetailArchive] POST failed:", error);
return NextResponse.json({ success: false, error: "保存详情存档失败" }, { status: 500 });
}
}

View File

@ -19,6 +19,8 @@ type DetailPlanRequest = {
modelImageUrl?: string;
bundleImageUrls?: string[];
brief?: {
productSpu?: string;
demandType?: string;
productName?: string;
targetAudience?: string;
mainUser?: string;
@ -38,6 +40,8 @@ type DetailPlanRequest = {
};
type DetailBrief = {
productSpu: string;
demandType: string;
productName: string;
targetAudience: string;
mainUser: string;
@ -188,6 +192,8 @@ function normalizeDetailBrief(value: unknown): DetailBrief {
const raw = (value || {}) as Record<string, unknown>;
const normalize = (key: string) => String(raw[key] || "").replace(/\s+/g, " ").trim();
return {
productSpu: normalize("productSpu"),
demandType: normalize("demandType"),
productName: normalize("productName"),
targetAudience: normalize("targetAudience"),
mainUser: normalize("mainUser"),
@ -220,6 +226,8 @@ function buildBriefText(brief: DetailBrief, language: "zh" | "en") {
const lines =
language === "en"
? [
brief.productSpu ? `Product SPU: ${brief.productSpu}` : "",
brief.demandType ? `Demand type: ${brief.demandType}` : "",
brief.productName ? `Product: ${brief.productName}` : "",
`Primary buyer audience: ${brief.targetAudience}`,
brief.mainUser ? `Primary user: ${brief.mainUser}` : "",
@ -237,6 +245,8 @@ function buildBriefText(brief: DetailBrief, language: "zh" | "en") {
brief.bannedWords ? `Banned words: ${brief.bannedWords}` : "",
]
: [
brief.productSpu ? `商品SPU:${brief.productSpu}` : "",
brief.demandType ? `需求类型:${brief.demandType}` : "",
brief.productName ? `产品:${brief.productName}` : "",
`主购买人群:${brief.targetAudience}`,
brief.mainUser ? `主使用者:${brief.mainUser}` : "",

View File

@ -285,6 +285,8 @@ interface CanvasChatSession {
}
type DetailBriefForm = {
productSpu: string;
demandType: string;
productName: string;
targetAudience: string;
mainUser: string;
@ -382,6 +384,11 @@ type DetailPlanResponse = {
const DETAIL_TARGET_WIDTH = 790;
const DETAIL_TARGET_HEIGHT = 1300;
const DETAIL_REVIEW_COUNT = 3;
const DETAIL_REVIEW_REGEN_COUNT = 2;
const DETAIL_REVIEW_IMAGE_COUNT = 3;
const DETAIL_REVIEW_IMAGE_WIDTH = 420;
const DETAIL_REVIEW_IMAGE_HEIGHT = 420;
const DETAIL_SLOT_CONFIGS: DetailSlotConfig[] = [
{ key: "product-1", label: "产品图1", role: "product" },
{ key: "product-2", label: "产品图2", role: "product" },
@ -401,6 +408,8 @@ const DETAIL_BUNDLE_SLOT_INDEXES = DETAIL_SLOT_CONFIGS.map((slot, index) =>
slot.role === "bundle" ? index : -1
).filter((index) => index >= 0);
const DETAIL_FIELD_EXAMPLES: Record<keyof DetailBriefForm, string> = {
productSpu: "示例:SPU-889901",
demandType: "示例:清洁除尘 / 户外通勤 / 礼赠",
productName: "示例:Buyi-AI 无线手持吸尘器",
targetAudience: "示例:25-40岁养宠家庭、宝妈",
mainUser: "示例:家庭主理人、租房上班族",
@ -428,10 +437,6 @@ function getSessionStorageKey(canvasId: string): string {
return `canvas-chat-sessions:${canvasId}`;
}
function getDetailArchiveStorageKey(canvasId: string): string {
return `canvas-detail-archives:${canvasId}`;
}
function deriveSessionTitleFromMessages(messages: ChatMessage[]): string {
const firstUserMessage = messages.find((message) => message.role === "user");
if (!firstUserMessage) return "";
@ -452,6 +457,109 @@ function extractDetailFrameCount(text: string): number {
return Math.min(12, Math.max(5, Math.round(value)));
}
type DetailReviewPlan = {
index: number;
title: string;
reviewText: string;
imagePrompt: string;
};
type DetailReviewGroup = {
reviewText: string;
images: Array<{ originalUrl: string; thumbnailUrl: string }>;
};
function splitDetailKeywords(value: string, limit = 3): string[] {
const raw = String(value || "")
.split(/[\n,,、;;|]/)
.map((item) => item.replace(/\s+/g, " ").trim())
.filter(Boolean);
if (raw.length > 0) return raw.slice(0, limit);
return [];
}
function buildDetailReviewPlans(params: {
brief: DetailBriefForm;
summary?: string;
language: "zh" | "en";
count?: number;
}): DetailReviewPlan[] {
const count = Math.max(1, Math.min(5, Math.round(params.count || DETAIL_REVIEW_COUNT)));
const audience = params.brief.targetAudience || (params.language === "en" ? "target users" : "目标用户");
const scenario = params.brief.usageScenario || (params.language === "en" ? "daily life scenario" : "日常场景");
const coreNeed = params.brief.coreNeed || (params.language === "en" ? "core need" : "核心需求");
const productName = params.brief.productName || (params.language === "en" ? "the product" : "该产品");
const expectedResult =
params.brief.expectedResult || (params.language === "en" ? "clear and reliable improvement" : "效果明显且稳定");
const riskConcern =
params.brief.riskConcern || (params.language === "en" ? "quality and usability concerns" : "担心效果不稳定");
const keywords = splitDetailKeywords(params.brief.coreSellingPoint, 3);
const firstKeyword = keywords[0] || (params.language === "en" ? "stable performance" : "稳定好用");
const secondKeyword = keywords[1] || (params.language === "en" ? "easy operation" : "上手简单");
const thirdKeyword = keywords[2] || (params.language === "en" ? "value for money" : "性价比高");
const summaryHint = String(params.summary || "").replace(/\s+/g, " ").trim();
if (params.language === "en") {
const templates = [
{
title: "Real Need Solved",
reviewText: `I am part of ${audience}. In ${scenario}, my biggest issue was ${coreNeed}. After using ${productName}, the result is ${expectedResult}. It feels ${firstKeyword} and I am willing to repurchase.`,
imagePrompt: `UGC buyer photo set for ${productName}, scenario: ${scenario}, highlight ${coreNeed} solved, realistic lifestyle composition, clear product details, natural lighting, authentic user-generated look`,
},
{
title: "From Concern to Trust",
reviewText: `Before purchase I worried about ${riskConcern}. After using it for a while, ${productName} stays ${secondKeyword} and reliable. The overall user experience is much better than expected.`,
imagePrompt: `UGC buyer experience photo set for ${productName}, before-after feeling in ${scenario}, close-up and handheld usage moments, realistic texture, social proof style, commercial clarity`,
},
{
title: "Conversion Testimonial",
reviewText: `For users like ${audience}, ${productName} is a practical choice. It balances ${firstKeyword}, ${secondKeyword}, and ${thirdKeyword}. The final outcome is ${expectedResult}.`,
imagePrompt: `UGC testimonial photo set for ${productName}, mixed angles including scene shot, detail shot and usage shot, trust-driven ecommerce style, clean but real, high conversion mood`,
},
];
const fallback = templates[templates.length - 1]!;
return Array.from({ length: count }, (_, index) => {
const template = templates[index] || fallback;
const summaryText = summaryHint ? ` ${summaryHint}` : "";
return {
index: index + 1,
title: `${template.title} ${index + 1}`,
reviewText: `${template.reviewText}${summaryText}`.trim(),
imagePrompt: template.imagePrompt,
};
});
}
const templates = [
{
title: "真实需求反馈",
reviewText: `我属于${audience},在${scenario}里最头疼的是${coreNeed}。用了${productName}后,明显感受到${expectedResult},整体体验${firstKeyword},确实值得回购。`,
imagePrompt: `${productName}买家真实晒单,场景:${scenario},突出“${coreNeed}被解决”的使用结果,生活化拍摄,真实自然光,产品细节清晰,电商高转化氛围`,
},
{
title: "顾虑消除反馈",
reviewText: `下单前我最担心${riskConcern},实际使用后发现${productName}${secondKeyword},稳定性也不错。连续使用一段时间后效果依旧在线。`,
imagePrompt: `${productName}买家体验晒单,表达“从担心到放心”的转变,含近景细节、手持使用、场景实拍,真实用户视角,质感干净`,
},
{
title: "转化口碑反馈",
reviewText: `如果你也是${audience},并且在${scenario}下有${coreNeed},这款${productName}真的很合适。它兼顾了${firstKeyword}、${secondKeyword}和${thirdKeyword},结果就是${expectedResult}。`,
imagePrompt: `${productName}买家口碑晒单,三张图分别体现场景图、细节图、结果图,构图统一,真实可信,强化转化购买意愿`,
},
];
const fallback = templates[templates.length - 1]!;
return Array.from({ length: count }, (_, index) => {
const template = templates[index] || fallback;
const summaryText = summaryHint ? ` ${summaryHint}` : "";
return {
index: index + 1,
title: `${template.title}${index + 1}`,
reviewText: `${template.reviewText}${summaryText}`.trim(),
imagePrompt: template.imagePrompt,
};
});
}
function buildDetailResultMarkdown(params: {
summary?: string;
images: Array<{ originalUrl: string; thumbnailUrl: string }>;
@ -498,6 +606,7 @@ function buildDetailResultMarkdown(params: {
e?: string;
};
}>;
reviewGroups?: DetailReviewGroup[];
}): string {
const lines: string[] = [];
lines.push("### AI详情生成完成");
@ -581,6 +690,41 @@ function buildDetailResultMarkdown(params: {
);
lines.push("");
});
if (params.reviewGroups?.length) {
lines.push("### 买家评价与晒图");
params.reviewGroups.forEach((group, index) => {
lines.push(`评价 ${index + 1}:${group.reviewText}`);
group.images.forEach((image) => {
lines.push(
`![thumb|||${image.originalUrl}|||2K](${image.thumbnailUrl || image.originalUrl})`
);
});
lines.push("");
});
}
return lines.join("\n").trim();
}
function buildDetailReviewAppendMarkdown(params: {
groups: DetailReviewGroup[];
failedCount?: number;
}): string {
const lines: string[] = [];
lines.push("### 买家评价与晒图已追加");
lines.push(`本次新增 ${params.groups.length} 条评价,每条 ${DETAIL_REVIEW_IMAGE_COUNT} 张晒图。`);
if (params.failedCount && params.failedCount > 0) {
lines.push(`失败 ${params.failedCount} 条,已跳过。`);
}
lines.push("");
params.groups.forEach((group, index) => {
lines.push(`评价 ${index + 1}:${group.reviewText}`);
group.images.forEach((image) => {
lines.push(
`![thumb|||${image.originalUrl}|||2K](${image.thumbnailUrl || image.originalUrl})`
);
});
lines.push("");
});
return lines.join("\n").trim();
}
@ -614,6 +758,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState<number | null>(null);
const [detailArchives, setDetailArchives] = useState<DetailArchive[]>([]);
const [detailBrief, setDetailBrief] = useState<DetailBriefForm>({
productSpu: "",
demandType: "",
productName: "",
targetAudience: "",
mainUser: "",
@ -1483,6 +1629,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const normalizedDetailBrief = useMemo(() => {
const normalizeValue = (value: string) => value.replace(/\s+/g, " ").trim();
return {
productSpu: normalizeValue(detailBrief.productSpu),
demandType: normalizeValue(detailBrief.demandType),
productName: normalizeValue(detailBrief.productName),
targetAudience: normalizeValue(detailBrief.targetAudience),
mainUser: normalizeValue(detailBrief.mainUser),
@ -1514,77 +1662,97 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
return issues;
}, [detailSlots, normalizedDetailBrief]);
const hasGeneratedDetailImages = useMemo(
() =>
nodes.some(
(node) =>
node.type === "image" &&
Boolean((node.data as any)?.metadata?.detailSectionIndex)
),
[nodes]
);
const toggleDetailSection = useCallback((key: DetailSectionKey) => {
setDetailSections((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
try {
const raw = localStorage.getItem(getDetailArchiveStorageKey(canvasId));
if (!raw) {
setDetailArchives([]);
return;
}
const parsed = JSON.parse(raw) as DetailArchive[];
const sanitized = Array.isArray(parsed)
? parsed
.filter((item) => item?.id && item?.createdAt)
.map((item) => ({
id: String(item.id),
title: String(item.title || "未命名详情项目"),
prompt: String(item.prompt || ""),
brief: {
productName: String(item.brief?.productName || ""),
targetAudience: String(item.brief?.targetAudience || ""),
mainUser: String(item.brief?.mainUser || ""),
usageScenario: String(item.brief?.usageScenario || ""),
triggerMoment: String(item.brief?.triggerMoment || ""),
coreNeed: String(item.brief?.coreNeed || ""),
riskConcern: String(item.brief?.riskConcern || ""),
expectedResult: String(item.brief?.expectedResult || ""),
coreSellingPoint: String(item.brief?.coreSellingPoint || ""),
positioningStatement: String(item.brief?.positioningStatement || ""),
userVoice: String(item.brief?.userVoice || ""),
competitorReference: String(item.brief?.competitorReference || ""),
priceBand: String(item.brief?.priceBand || ""),
brandTone: String(item.brief?.brandTone || ""),
bannedWords: String(item.brief?.bannedWords || ""),
},
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
const slot = item.slots?.[index];
if (!slot?.url) return null;
return {
url: String(slot.url),
name: String(slot.name || DETAIL_SLOT_CONFIGS[index]?.label || `素材${index + 1}`),
contentType: String(slot.contentType || "image/jpeg"),
} as PickedObjectAttachment;
}),
createdAt: Number(item.createdAt) || Date.now(),
summary: item.summary ? String(item.summary) : undefined,
imageCount: Number(item.imageCount || 0) || undefined,
coverUrl: item.coverUrl ? String(item.coverUrl) : undefined,
}))
.sort((a, b) => b.createdAt - a.createdAt)
: [];
setDetailArchives(sanitized.slice(0, 30));
} catch (error) {
console.error("[CanvasChat] Failed to load detail archives:", error);
setDetailArchives([]);
}
}, [canvasId]);
let isCancelled = false;
useEffect(() => {
if (typeof window === "undefined") return;
try {
localStorage.setItem(
getDetailArchiveStorageKey(canvasId),
JSON.stringify(detailArchives.slice(0, 30))
);
} catch (error) {
console.error("[CanvasChat] Failed to save detail archives:", error);
}
}, [canvasId, detailArchives]);
const loadDetailArchives = async () => {
try {
const response = await fetch(`/api/canvas/${canvasId}/detail-archives`, {
method: "GET",
cache: "no-store",
});
const result = await response.json().catch(() => ({}));
if (isCancelled) return;
if (!response.ok || !result?.success) {
setDetailArchives([]);
return;
}
const parsed = Array.isArray(result.data) ? (result.data as DetailArchive[]) : [];
const sanitized = parsed
.filter((item) => item?.id && item?.createdAt)
.map((item) => ({
id: String(item.id),
title: String(item.title || "未命名详情项目"),
prompt: String(item.prompt || ""),
brief: {
productSpu: String(item.brief?.productSpu || ""),
demandType: String(item.brief?.demandType || ""),
productName: String(item.brief?.productName || ""),
targetAudience: String(item.brief?.targetAudience || ""),
mainUser: String(item.brief?.mainUser || ""),
usageScenario: String(item.brief?.usageScenario || ""),
triggerMoment: String(item.brief?.triggerMoment || ""),
coreNeed: String(item.brief?.coreNeed || ""),
riskConcern: String(item.brief?.riskConcern || ""),
expectedResult: String(item.brief?.expectedResult || ""),
coreSellingPoint: String(item.brief?.coreSellingPoint || ""),
positioningStatement: String(item.brief?.positioningStatement || ""),
userVoice: String(item.brief?.userVoice || ""),
competitorReference: String(item.brief?.competitorReference || ""),
priceBand: String(item.brief?.priceBand || ""),
brandTone: String(item.brief?.brandTone || ""),
bannedWords: String(item.brief?.bannedWords || ""),
},
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
const slot = item.slots?.[index];
if (!slot?.url) return null;
return {
url: String(slot.url),
name: String(slot.name || DETAIL_SLOT_CONFIGS[index]?.label || `素材${index + 1}`),
contentType: String(slot.contentType || "image/jpeg"),
} as PickedObjectAttachment;
}),
createdAt:
typeof item.createdAt === "number"
? item.createdAt
: (() => {
const timestamp = Date.parse(String(item.createdAt || ""));
return Number.isFinite(timestamp) ? timestamp : Date.now();
})(),
summary: item.summary ? String(item.summary) : undefined,
imageCount: Number(item.imageCount || 0) || undefined,
coverUrl: item.coverUrl ? String(item.coverUrl) : undefined,
}))
.sort((a, b) => b.createdAt - a.createdAt);
setDetailArchives(sanitized.slice(0, 30));
} catch (error) {
if (isCancelled) return;
console.error("[CanvasChat] Failed to load detail archives:", error);
setDetailArchives([]);
}
};
void loadDetailArchives();
return () => {
isCancelled = true;
};
}, [canvasId]);
useEffect(() => {
if (videoFrameSlots.length === 0) return;
@ -2378,6 +2546,27 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
[updateDetailSlotAttachment]
);
const saveDetailArchiveToDatabase = useCallback(
async (archive: DetailArchive) => {
try {
const response = await fetch(`/api/canvas/${canvasId}/detail-archives`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ archive }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.success) {
console.error("[CanvasChat] Failed to persist detail archive:", result?.error || response.statusText);
}
} catch (error) {
console.error("[CanvasChat] Failed to persist detail archive:", error);
}
},
[canvasId]
);
const saveDetailArchiveSnapshot = useCallback(
(params: {
summary?: string;
@ -2389,16 +2578,21 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}) => {
const archiveId = generateUUID();
const promptText = params.prompt.replace(/\s+/g, " ").trim();
const titleSeed =
params.brief.productName ||
params.brief.coreNeed ||
params.brief.usageScenario ||
"AI详情项目";
const spuSeed = String(params.brief.productSpu || "").replace(/\s+/g, "").trim();
const demandSeed = String(
params.brief.demandType || params.brief.coreNeed || params.brief.usageScenario || ""
)
.replace(/\s+/g, " ")
.trim();
const createdAt = Date.now();
const archiveTitle =
spuSeed && demandSeed
? `${spuSeed}+${demandSeed}`
: spuSeed || demandSeed || params.brief.productName || "AI详情项目";
const archive: DetailArchive = {
id: archiveId,
title: `${titleSeed}${new Date(createdAt).toLocaleString()}`,
title: archiveTitle,
prompt: promptText,
brief: { ...params.brief },
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
@ -2418,8 +2612,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
};
setDetailArchives((prev) => [archive, ...prev].slice(0, 30));
void saveDetailArchiveToDatabase(archive);
},
[]
[saveDetailArchiveToDatabase]
);
const loadDetailArchive = useCallback((archive: DetailArchive) => {
@ -2487,9 +2682,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const basePrompt = brief.productName
? `${brief.productName}详情页生成`
: "电商详情页生成";
const demandHint = brief.demandType ? `,需求类型:${brief.demandType}` : "";
const detailPrompt =
userText ||
`${basePrompt},面向${brief.targetAudience},在${brief.usageScenario}场景解决${brief.coreNeed}`;
`${basePrompt},面向${brief.targetAudience},在${brief.usageScenario}场景解决${brief.coreNeed}${demandHint}`;
const archiveSnapshot = {
prompt: detailPrompt,
brief: { ...detailBrief },
@ -2529,6 +2725,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
: "未上传搭配产品图:不强制搭配展示屏。";
const detailBriefText = [
"【AI详情生成输入】",
brief.productSpu ? `商品SPU:${brief.productSpu}` : "",
brief.demandType ? `需求类型:${brief.demandType}` : "",
brief.productName ? `产品:${brief.productName}` : "",
`主购买人群:${brief.targetAudience}`,
brief.mainUser ? `主使用者:${brief.mainUser}` : "",
@ -2551,13 +2749,14 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
]
.filter(Boolean)
.join("\n");
const outputLanguage: "zh" | "en" = /[a-zA-Z]/.test(detailPrompt) ? "en" : "zh";
setIsDetailGenerating(true);
setDetailProgress({
status: "running",
stage: "Agent 正在解析需求并规划详情结构...",
current: 0,
total: Math.max(1, detailCount + 1),
total: Math.max(1, detailCount + 1 + DETAIL_REVIEW_COUNT),
});
try {
const detailReferenceAttachments = DETAIL_SLOT_CONFIGS.map((config, index) => {
@ -2615,7 +2814,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
referenceImageNotes,
modelImageUrl: modelAttachment.url,
bundleImageUrls: bundleAttachments.map((item) => item.url),
outputLanguage: /[a-zA-Z]/.test(detailPrompt) ? "en" : "zh",
outputLanguage,
brief,
}),
});
@ -2633,7 +2832,13 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
throw new Error("未获取到有效分镜提示词");
}
const screenSpecs = Array.isArray(planData.screens) ? planData.screens : [];
const totalSteps = prompts.length + 1;
const reviewPlans = buildDetailReviewPlans({
brief,
summary: planData.summary,
language: outputLanguage,
count: DETAIL_REVIEW_COUNT,
});
const totalSteps = prompts.length + 1 + reviewPlans.length;
const qualityIssues = Array.isArray(planData.qualityChecks?.issues)
? planData.qualityChecks?.issues.filter(Boolean)
: [];
@ -2801,6 +3006,129 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
});
}
const reviewGroups: DetailReviewGroup[] = [];
const reviewFailures: string[] = [];
const detailRows = Math.ceil(prompts.length / 3);
const detailStartX = 120;
const detailStartY = 120;
const detailSpacing = 24;
const reviewStartY =
detailStartY + detailRows * (DETAIL_TARGET_HEIGHT + detailSpacing) + 120;
for (let reviewIndex = 0; reviewIndex < reviewPlans.length; reviewIndex += 1) {
const reviewPlan = reviewPlans[reviewIndex]!;
const reviewStepCurrent = prompts.length + 2 + reviewIndex;
setDetailProgress({
status: "running",
stage: `正在生成买家评价与晒图 ${reviewIndex + 1}/${reviewPlans.length}`,
current: reviewStepCurrent,
total: totalSteps,
});
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: `### AI详情生成\n详情图已完成,正在生成买家评价与晒图 ${reviewIndex + 1}/${reviewPlans.length}...`,
},
],
}
: message,
),
);
const reviewPrompt = [
reviewPlan.imagePrompt,
`评价文本:${reviewPlan.reviewText}`,
"输出要求:生成真实电商买家晒单风格图片,避免夸张广告质感。",
`同一条评价连续输出 ${DETAIL_REVIEW_IMAGE_COUNT} 张图片,场景和构图要保持同一叙事主题但角度不同。`,
"图片内容要看得见产品主体和真实使用痕迹,适合放在详情页评价区。",
]
.filter(Boolean)
.join("\n\n");
const reviewReferenceImages = [
...productAttachments.map((item) => item.url),
...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []),
modelAttachment.url,
].filter(Boolean);
const reviewResponse = await fetch("/api/canvas/generate/image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: reviewPrompt,
model: modelForGeneration,
aspectRatio: "1:1",
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: reviewReferenceImages,
}),
});
const reviewResult = await reviewResponse.json();
const reviewImagesRaw = Array.isArray(reviewResult?.data?.images)
? reviewResult.data.images
: [];
if (!reviewResponse.ok || !reviewResult?.success || reviewImagesRaw.length === 0) {
reviewFailures.push(`评价${reviewIndex + 1}生成失败`);
continue;
}
const reviewImages = reviewImagesRaw
.slice(0, DETAIL_REVIEW_IMAGE_COUNT)
.map((item: any) => ({
originalUrl: String(item?.originalUrl || item?.thumbnailUrl || "").trim(),
thumbnailUrl: String(item?.thumbnailUrl || item?.originalUrl || "").trim(),
}))
.filter((item: { originalUrl: string; thumbnailUrl: string }) => Boolean(item.originalUrl));
if (reviewImages.length === 0) {
reviewFailures.push(`评价${reviewIndex + 1}未返回有效图片`);
continue;
}
reviewGroups.push({
reviewText: reviewPlan.reviewText,
images: reviewImages,
});
reviewImages.forEach((image: { originalUrl: string; thumbnailUrl: string }, imageIndex: number) => {
const x =
detailStartX +
imageIndex * (DETAIL_REVIEW_IMAGE_WIDTH + 20);
const y =
reviewStartY +
reviewIndex * (DETAIL_REVIEW_IMAGE_HEIGHT + 72);
addNode({
type: "image",
position: { x, y },
size: {
width: DETAIL_REVIEW_IMAGE_WIDTH,
height: DETAIL_REVIEW_IMAGE_HEIGHT,
},
data: {
src: image.originalUrl || image.thumbnailUrl,
status: "completed",
prompt: reviewPlan.imagePrompt,
title: `买家晒图-${reviewPlan.index}-${imageIndex + 1}`,
note: reviewPlan.reviewText,
metadata: {
detailReviewIndex: reviewPlan.index,
detailReviewTitle: reviewPlan.title,
detailReviewText: reviewPlan.reviewText,
detailReviewImageIndex: imageIndex + 1,
detailReviewImageCount: DETAIL_REVIEW_IMAGE_COUNT,
},
},
connections: [],
});
});
}
const finalMarkdown = buildDetailResultMarkdown({
summary: planData.summary,
images: generatedImages,
@ -2809,6 +3137,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
qualityChecks: planData.qualityChecks,
globalStyle: planData.globalStyle,
screens: screenSpecs,
reviewGroups,
});
setMessages((prev) =>
prev.map((message) =>
@ -2827,7 +3156,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
);
setDetailProgress({
status: "done",
stage: `已完成 ${generatedImages.length} 张详情图`,
stage:
reviewFailures.length > 0
? `详情图完成 ${generatedImages.length} 张,评价晒图部分失败 ${reviewFailures.length} 条`
: `已完成 ${generatedImages.length} 张详情图 + ${reviewGroups.length} 条评价晒图`,
current: totalSteps,
total: totalSteps,
});
@ -2840,7 +3172,15 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
slots: archiveSnapshot.slots,
});
toast.success(`AI详情生成完成,共 ${generatedImages.length} 张`);
if (reviewFailures.length > 0) {
toast.warning(
`详情图完成 ${generatedImages.length} 张,买家评价晒图成功 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条`
);
} else {
toast.success(
`AI详情生成完成:详情图 ${generatedImages.length} 张,评价晒图 ${reviewGroups.length} 条`
);
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : "AI详情生成失败";
setDetailProgress((prev) => ({
@ -2884,6 +3224,283 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
siteSettings?.canvasDefaultModelId,
]);
const handleGenerateMoreDetailReviews = useCallback(async () => {
if (isDetailGenerating) return;
if (!hasGeneratedDetailImages) {
toast.error("请先完成一轮AI详情生成,再追加评价晒图");
return;
}
const brief = normalizedDetailBrief;
if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) {
toast.error("请先补充 AI详情生成必填项后再追加评价晒图");
return;
}
const productAttachments = DETAIL_PRODUCT_SLOT_INDEXES
.map((index) => detailSlots[index])
.filter((item): item is PickedObjectAttachment => Boolean(item?.url));
if (productAttachments.length < 5) {
toast.error("请先补齐5张产品图,再追加评价晒图");
return;
}
const modelAttachment =
DETAIL_MODEL_SLOT_INDEX >= 0 ? detailSlots[DETAIL_MODEL_SLOT_INDEX] : null;
if (!modelAttachment?.url) {
toast.error("请先补齐模特图,再追加评价晒图");
return;
}
const bundleAttachments = DETAIL_BUNDLE_SLOT_INDEXES
.map((index) => detailSlots[index])
.filter((item): item is PickedObjectAttachment => Boolean(item?.url));
const languageSeed = [
brief.productName,
brief.targetAudience,
brief.usageScenario,
brief.coreNeed,
]
.filter(Boolean)
.join(" ");
const outputLanguage: "zh" | "en" = /[a-zA-Z]/.test(languageSeed) ? "en" : "zh";
const reviewPlans = buildDetailReviewPlans({
brief,
language: outputLanguage,
count: DETAIL_REVIEW_REGEN_COUNT,
});
if (reviewPlans.length === 0) {
toast.error("未生成可用的评价方案");
return;
}
const assistantMessageId = generateUUID();
const modelForGeneration =
selectedModelId !== "auto"
? selectedModelId
: siteSettings?.canvasAutoModeDefaultModelId ||
siteSettings?.canvasDefaultModelId ||
undefined;
const existingImageBottom = nodes
.filter((node) => node.type === "image")
.reduce((max, node) => {
const currentBottom = Number(node.position?.y || 0) + Number(node.size?.height || 0);
return Math.max(max, currentBottom);
}, 120);
const reviewStartY = existingImageBottom + 120;
const reviewStartX = 120;
const reviewGroups: DetailReviewGroup[] = [];
const reviewFailures: string[] = [];
const batchId = generateUUID();
setIsDetailGenerating(true);
setDetailProgress({
status: "running",
stage: "正在追加买家评价与晒图...",
current: 0,
total: reviewPlans.length,
});
setMessages((prev) => [
...prev,
{
id: assistantMessageId,
role: "assistant",
parts: [
{
type: "text",
text: `### 买家评价与晒图\n正在生成新的 ${reviewPlans.length} 条评价,每条 ${DETAIL_REVIEW_IMAGE_COUNT} 张...`,
},
],
} as any,
]);
try {
for (let reviewIndex = 0; reviewIndex < reviewPlans.length; reviewIndex += 1) {
const reviewPlan = reviewPlans[reviewIndex]!;
setDetailProgress({
status: "running",
stage: `正在追加第 ${reviewIndex + 1}/${reviewPlans.length} 条评价晒图`,
current: reviewIndex + 1,
total: reviewPlans.length,
});
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: `### 买家评价与晒图\n正在追加第 ${reviewIndex + 1}/${reviewPlans.length} 条...`,
},
],
}
: message
)
);
const reviewPrompt = [
reviewPlan.imagePrompt,
`评价文本:${reviewPlan.reviewText}`,
"输出要求:生成真实电商买家晒单风格图片,避免夸张广告质感。",
`同一条评价连续输出 ${DETAIL_REVIEW_IMAGE_COUNT} 张图片,场景和构图保持同一叙事主题但角度不同。`,
"图片内容需要清晰展示产品主体与使用场景,可直接用于详情页评价区。",
]
.filter(Boolean)
.join("\n\n");
const reviewReferenceImages = [
...productAttachments.map((item) => item.url),
...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []),
modelAttachment.url,
].filter(Boolean);
const reviewResponse = await fetch("/api/canvas/generate/image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: reviewPrompt,
model: modelForGeneration,
aspectRatio: "1:1",
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: reviewReferenceImages,
}),
});
const reviewResult = await reviewResponse.json();
const reviewImagesRaw = Array.isArray(reviewResult?.data?.images)
? reviewResult.data.images
: [];
if (!reviewResponse.ok || !reviewResult?.success || reviewImagesRaw.length === 0) {
reviewFailures.push(`第${reviewIndex + 1}条生成失败`);
continue;
}
const reviewImages = reviewImagesRaw
.slice(0, DETAIL_REVIEW_IMAGE_COUNT)
.map((item: any) => ({
originalUrl: String(item?.originalUrl || item?.thumbnailUrl || "").trim(),
thumbnailUrl: String(item?.thumbnailUrl || item?.originalUrl || "").trim(),
}))
.filter((item: { originalUrl: string; thumbnailUrl: string }) =>
Boolean(item.originalUrl)
);
if (reviewImages.length === 0) {
reviewFailures.push(`第${reviewIndex + 1}条无有效图片`);
continue;
}
const rowIndex = reviewGroups.length;
reviewGroups.push({
reviewText: reviewPlan.reviewText,
images: reviewImages,
});
reviewImages.forEach((image: { originalUrl: string; thumbnailUrl: string }, imageIndex: number) => {
const x = reviewStartX + imageIndex * (DETAIL_REVIEW_IMAGE_WIDTH + 20);
const y = reviewStartY + rowIndex * (DETAIL_REVIEW_IMAGE_HEIGHT + 72);
addNode({
type: "image",
position: { x, y },
size: {
width: DETAIL_REVIEW_IMAGE_WIDTH,
height: DETAIL_REVIEW_IMAGE_HEIGHT,
},
data: {
src: image.originalUrl || image.thumbnailUrl,
status: "completed",
prompt: reviewPlan.imagePrompt,
title: `买家晒图-${reviewPlan.index}-${imageIndex + 1}`,
note: reviewPlan.reviewText,
metadata: {
detailReviewIndex: reviewPlan.index,
detailReviewTitle: reviewPlan.title,
detailReviewText: reviewPlan.reviewText,
detailReviewImageIndex: imageIndex + 1,
detailReviewImageCount: DETAIL_REVIEW_IMAGE_COUNT,
detailReviewBatchId: batchId,
detailReviewBatchType: "manual-append",
},
},
connections: [],
});
});
}
const finalText = buildDetailReviewAppendMarkdown({
groups: reviewGroups,
failedCount: reviewFailures.length,
});
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: finalText,
},
],
}
: message
)
);
setDetailProgress({
status: "done",
stage:
reviewFailures.length > 0
? `已追加 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条`
: `已追加 ${reviewGroups.length} 条买家评价晒图`,
current: reviewPlans.length,
total: reviewPlans.length,
});
if (reviewFailures.length > 0) {
toast.warning(`追加成功 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条`);
} else {
toast.success(`已追加 ${reviewGroups.length} 条买家评价晒图`);
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : "追加买家评价与晒图失败";
setDetailProgress({
status: "error",
stage: errMsg,
current: 0,
total: reviewPlans.length,
});
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: `### 买家评价与晒图追加失败\n${errMsg}`,
},
],
}
: message
)
);
toast.error(errMsg);
} finally {
setIsDetailGenerating(false);
}
}, [
addNode,
detailSlots,
hasGeneratedDetailImages,
imageParams.negativePrompt,
isDetailGenerating,
nodes,
normalizedDetailBrief,
selectedModelId,
setMessages,
siteSettings?.canvasAutoModeDefaultModelId,
siteSettings?.canvasDefaultModelId,
]);
// 处理发送(图片已在上传时添加到画布,这里只发送消息)
const handleSend = useCallback(async () => {
if (isDetailGenerationMode && aiDetailGenerationEnabled) {
@ -3962,6 +4579,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
</button>
{detailSections.required ? (
<div className="grid grid-cols-2 gap-2 border-t border-black/10 p-3 xl:grid-cols-4">
<input
value={detailBrief.productSpu}
onChange={(event) => updateDetailBriefField("productSpu", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.productSpu}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="商品SPU(建议填写)"
/>
<input
value={detailBrief.demandType}
onChange={(event) => updateDetailBriefField("demandType", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.demandType}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="需求类型(建议填写)"
/>
<input
value={detailBrief.targetAudience}
onChange={(event) => updateDetailBriefField("targetAudience", event.target.value)}
@ -4303,6 +4934,34 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
</div>
)}
</div>
{hasGeneratedDetailImages ? (
<div className="mt-2 flex items-center justify-between gap-2 rounded-xl border border-violet-200 bg-violet-50/70 px-3 py-2">
<div className="min-w-0">
<p className="text-[11px] font-medium text-violet-700">
评价晒图可继续追加
</p>
<p className="text-[10px] text-violet-600/80">
点击后仅新增2条评价,每条3张晒图,不会重跑详情主图。
</p>
</div>
<button
type="button"
className={cn(
"shrink-0 rounded-md border px-2.5 py-1 text-[11px] font-medium",
isSubmitting
? "cursor-not-allowed border-violet-200 bg-violet-100 text-violet-500/70"
: "border-violet-300 bg-white text-violet-700 hover:bg-violet-100"
)}
onClick={() => {
void handleGenerateMoreDetailReviews();
}}
disabled={isSubmitting}
>
再生成2条
</button>
</div>
) : null}
</div>
)}
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}

View File

@ -12,7 +12,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { imageGenerationService } from "@/services/canvas";
import { Minus, Plus } from "lucide-react";
import { Download, Minus, Plus } from "lucide-react";
// 性能优化:判断节点是否在视口内
function isNodeInViewport(
@ -36,6 +36,48 @@ function isNodeInViewport(
);
}
function sanitizeDownloadFileName(value: string, fallback: string): string {
const cleaned = String(value || "")
.replace(/\.[a-zA-Z0-9]{2,6}$/g, "")
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return cleaned || fallback;
}
async function triggerImageDownload(url: string, filename: string): Promise<void> {
if (!url) throw new Error("missing_url");
const clickLink = (href: string) => {
const link = document.createElement("a");
link.href = href;
link.download = filename;
link.rel = "noopener noreferrer";
link.target = "_blank";
document.body.appendChild(link);
link.click();
link.remove();
};
try {
const response = await fetch(url, {
method: "GET",
credentials: "omit",
mode: "cors",
});
if (!response.ok) {
throw new Error(`http_${response.status}`);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
clickLink(objectUrl);
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500);
return;
} catch (_error) {
clickLink(url);
}
}
export function CanvasWorkspace() {
const containerRef = useRef<HTMLDivElement>(null);
const [isPanning, setIsPanning] = useState(false);
@ -140,6 +182,17 @@ export function CanvasWorkspace() {
selectedNodeIds.includes(node.id) // 选中的节点总是渲染
);
}, [nodes, viewport, containerSize, selectedNodeIds]);
const selectedImageNodes = useMemo(
() =>
nodes.filter(
(node) =>
selectedNodeIds.includes(node.id) &&
node.type === "image" &&
Boolean((node.data as any)?.src)
),
[nodes, selectedNodeIds]
);
// Store actions
const setViewport = useCanvasStore((state) => state.setViewport);
@ -499,6 +552,45 @@ export function CanvasWorkspace() {
}
}, [isFileDrag, processDroppedFiles]);
const handleDownloadSelectedImages = useCallback(async () => {
if (selectedImageNodes.length === 0) {
toast.error("请先框选要下载的图片");
return;
}
let successCount = 0;
let failedCount = 0;
for (let index = 0; index < selectedImageNodes.length; index += 1) {
const node = selectedImageNodes[index]!;
const nodeData = node.data as any;
const imageUrl = String(nodeData?.src || "").trim();
if (!imageUrl) {
failedCount += 1;
continue;
}
const titleSeed =
String(nodeData?.title || "").trim() ||
String(nodeData?.prompt || "").trim() ||
`canvas-image-${index + 1}`;
const filename = `${sanitizeDownloadFileName(titleSeed, `canvas-image-${index + 1}`)}.png`;
try {
await triggerImageDownload(imageUrl, filename);
successCount += 1;
} catch (_error) {
failedCount += 1;
}
}
if (failedCount > 0) {
toast.warning(`已下载 ${successCount} 张,失败 ${failedCount} 张`);
return;
}
toast.success(`已开始下载 ${successCount} 张图片`);
}, [selectedImageNodes]);
// 渲染节点
const renderNode = (node: CanvasNode) => {
const isSelected = selectedNodeIds.includes(node.id);
@ -706,6 +798,21 @@ export function CanvasWorkspace() {
)}
{/* 缩放控制器 */}
{selectedImageNodes.length > 0 && (
<div className="absolute bottom-16 right-4 z-20">
<Button
variant="secondary"
size="sm"
className="h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60"
onClick={handleDownloadSelectedImages}
title="下载当前框选图片"
>
<Download className="h-3.5 w-3.5" />
下载选中({selectedImageNodes.length})
</Button>
</div>
)}
<div className="absolute bottom-4 right-4 bg-background/80 backdrop-blur-sm rounded-lg flex items-center gap-1 px-1 py-1 shadow-sm border border-border/50">
<Button
variant="ghost"

View File

@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS "CanvasDetailArchive" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"projectId" uuid NOT NULL REFERENCES "CanvasProject"("id") ON DELETE cascade,
"userId" uuid NOT NULL REFERENCES "User"("id") ON DELETE cascade,
"title" varchar(256) NOT NULL DEFAULT 'AI详情项目',
"prompt" text NOT NULL DEFAULT '',
"brief" jsonb NOT NULL,
"slots" jsonb NOT NULL,
"summary" text,
"imageCount" integer,
"coverUrl" text,
"createdAt" timestamp NOT NULL DEFAULT now(),
"updatedAt" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "canvas_detail_archive_project_created_idx"
ON "CanvasDetailArchive" ("projectId", "createdAt" DESC);
CREATE INDEX IF NOT EXISTS "canvas_detail_archive_user_created_idx"
ON "CanvasDetailArchive" ("userId", "createdAt" DESC);

View File

@ -169,6 +169,13 @@
"when": 1771317600000,
"tag": "0037_add_ai_model_avatar",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1771502400000,
"tag": "0038_add_canvas_detail_archives",
"breakpoints": true
}
]
}

View File

@ -476,6 +476,28 @@ export const canvasProject = pgTable("CanvasProject", {
export type CanvasProject = InferSelectModel<typeof canvasProject>;
// AI详情项目存档表
export const canvasDetailArchive = pgTable("CanvasDetailArchive", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
projectId: uuid("projectId")
.notNull()
.references(() => canvasProject.id, { onDelete: "cascade" }),
userId: uuid("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
title: varchar("title", { length: 256 }).notNull().default("AI详情项目"),
prompt: text("prompt").notNull().default(""),
brief: jsonb("brief").notNull(),
slots: jsonb("slots").notNull(),
summary: text("summary"),
imageCount: integer("imageCount"),
coverUrl: text("coverUrl"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type CanvasDetailArchive = InferSelectModel<typeof canvasDetailArchive>;
// 画布节点表(可选,用于更细粒度的查询)
export const canvasNode = pgTable("CanvasNode", {
id: uuid("id").primaryKey().notNull().defaultRandom(),