Compare commits
No commits in common. "main" and "codex/canvas-image-429-deploy-1001" have entirely different histories.
main
...
codex/canv
@ -153,9 +153,8 @@ PRODUCT_PLANNING_DB_SSL=false
|
|||||||
# Home Performance / Queues
|
# Home Performance / Queues
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
# Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled.
|
CANVAS_IMAGE_TASK_CONCURRENCY=3
|
||||||
CANVAS_IMAGE_TASK_CONCURRENCY=0
|
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
|
||||||
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
|
|
||||||
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
|
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
|
||||||
EXTERNAL_IMAGE_API_MAX_QUEUE=20
|
EXTERNAL_IMAGE_API_MAX_QUEUE=20
|
||||||
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000
|
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000
|
||||||
|
|||||||
20
.gitattributes
vendored
20
.gitattributes
vendored
@ -1,20 +0,0 @@
|
|||||||
# Keep source checkouts consistent across Windows and Linux.
|
|
||||||
.gitattributes text eol=lf
|
|
||||||
.gitignore text eol=lf
|
|
||||||
*.ts text eol=lf
|
|
||||||
*.tsx text eol=lf
|
|
||||||
*.js text eol=lf
|
|
||||||
*.jsx text eol=lf
|
|
||||||
*.mjs text eol=lf
|
|
||||||
*.cjs text eol=lf
|
|
||||||
*.json text eol=lf
|
|
||||||
*.jsonc text eol=lf
|
|
||||||
*.yaml text eol=lf
|
|
||||||
*.yml text eol=lf
|
|
||||||
*.css text eol=lf
|
|
||||||
*.html text eol=lf
|
|
||||||
*.md text eol=lf
|
|
||||||
*.sh text eol=lf
|
|
||||||
*.py text eol=lf
|
|
||||||
*.bat text eol=crlf
|
|
||||||
*.cmd text eol=crlf
|
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -13,7 +13,6 @@ coverage
|
|||||||
.next/
|
.next/
|
||||||
.next-build/
|
.next-build/
|
||||||
.next-previous/
|
.next-previous/
|
||||||
/.next-*/
|
|
||||||
out/
|
out/
|
||||||
build
|
build
|
||||||
|
|
||||||
@ -68,11 +67,6 @@ migration-backup-complete/
|
|||||||
migration-*.log
|
migration-*.log
|
||||||
migration-*.txt
|
migration-*.txt
|
||||||
|
|
||||||
# Local release backups, model credentials and migration scratch data.
|
|
||||||
/.model-config-backups/
|
|
||||||
/backups/
|
|
||||||
/tmp/
|
|
||||||
|
|
||||||
# 本地抓包/竞品参考产物,不能提交
|
# 本地抓包/竞品参考产物,不能提交
|
||||||
artifacts/liblib-*/
|
artifacts/liblib-*/
|
||||||
analysis/qiaodou-extract/
|
analysis/qiaodou-extract/
|
||||||
@ -134,7 +128,3 @@ services/video-upscale/vendor/__pycache__/
|
|||||||
|
|
||||||
/services/audio-processing/dist/
|
/services/audio-processing/dist/
|
||||||
/services/audio-processing/__pycache__/
|
/services/audio-processing/__pycache__/
|
||||||
|
|
||||||
# Python release-tool and test bytecode.
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|||||||
@ -1078,15 +1078,139 @@ export function LocalAiRuntimeSections({
|
|||||||
</Button>
|
</Button>
|
||||||
</LocalAiSectionShell>
|
</LocalAiSectionShell>
|
||||||
|
|
||||||
|
{/* 全站生图单模型并发 */}
|
||||||
<LocalAiSectionShell
|
<LocalAiSectionShell
|
||||||
description="生图任务直接发起,图片先显示,后台转存到存储桶。"
|
description={
|
||||||
|
<>
|
||||||
|
控制全站生图链路中,同一个模型允许同时运行的任务数,以及排队后下一批任务的发起间隔。配置保存到数据库,异步队列和直调生成都会自动读取,无需重启服务。
|
||||||
|
</>
|
||||||
|
}
|
||||||
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
|
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
|
||||||
id="local-ai-section-canvas-image-task-queue"
|
id="local-ai-section-canvas-image-task-queue"
|
||||||
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
|
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
|
||||||
summary={<LocalAiSectionSummary items={[{ label: "并发不限", tone: "enabled" }, { label: "即时发起", tone: "enabled" }]} />}
|
summary={
|
||||||
title={<span className="flex items-center gap-2"><ImageIcon className="h-5 w-5" />生图任务执行</span>}
|
<LocalAiSectionSummary
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: `${canvasImageTaskQueueConfig.concurrency} 并发/模型`,
|
||||||
|
tone: "neutral",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${(
|
||||||
|
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
|
||||||
|
).toFixed(1)} 秒补位`,
|
||||||
|
tone: "neutral",
|
||||||
|
},
|
||||||
|
{ label: "全站生图生效", tone: "enabled" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="h-5 w-5" />
|
||||||
|
全站生图单模型并发与批次间隔
|
||||||
|
</span>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<p className="text-muted-foreground text-sm">已取消模型及单用户并发限制,任务无需等待其他图片生成完成。任务状态、超时处理和失败重试继续保留。</p>
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(220px,320px)]">
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 text-sm">
|
||||||
|
<div className="font-medium">生效范围</div>
|
||||||
|
<div className="mt-2 space-y-1 text-amber-800 text-xs leading-5">
|
||||||
|
<p>
|
||||||
|
•
|
||||||
|
影响画布异步生图、智能体直调生图、视频分镜高清预览等全站生图入口。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
•
|
||||||
|
多用户共享同一套并发限制,但会按模型分别计数,不同模型之间不再互相阻塞。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
• 当前配置的是单模型并发,不是全站总并发,也不是单用户并发。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
•
|
||||||
|
批次间隔只控制排队补位的“下一批”发起节奏,不影响首批任务即时启动。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
• 数值过高可能触发上游模型 429、超时或 504,建议配合模型 Key
|
||||||
|
池继续细分。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="canvasImageTaskQueueConcurrency">
|
||||||
|
单模型最大同时运行任务数
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="canvasImageTaskQueueConcurrency"
|
||||||
|
max={MAX_CANVAS_IMAGE_TASK_CONCURRENCY}
|
||||||
|
min={MIN_CANVAS_IMAGE_TASK_CONCURRENCY}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCanvasImageTaskQueueConfig(
|
||||||
|
normalizeCanvasImageTaskQueueConfig({
|
||||||
|
...canvasImageTaskQueueConfig,
|
||||||
|
concurrency: e.target.value,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="number"
|
||||||
|
value={canvasImageTaskQueueConfig.concurrency}
|
||||||
|
/>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
可配置范围 {MIN_CANVAS_IMAGE_TASK_CONCURRENCY}-
|
||||||
|
{MAX_CANVAS_IMAGE_TASK_CONCURRENCY},当前为{" "}
|
||||||
|
{canvasImageTaskQueueConfig.concurrency} 并发,默认值为{" "}
|
||||||
|
{DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY}。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="canvasImageTaskBatchDispatchIntervalSeconds">
|
||||||
|
下一批排队任务发起间隔(秒)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="canvasImageTaskBatchDispatchIntervalSeconds"
|
||||||
|
max={MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
|
||||||
|
min={MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCanvasImageTaskQueueConfig(
|
||||||
|
normalizeCanvasImageTaskQueueConfig({
|
||||||
|
...canvasImageTaskQueueConfig,
|
||||||
|
batchDispatchIntervalMs: Math.round(
|
||||||
|
Number.parseFloat(e.target.value || "0") * 1000
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
step="0.5"
|
||||||
|
type="number"
|
||||||
|
value={String(
|
||||||
|
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
可配置范围 {MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}-
|
||||||
|
{MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000} 秒,当前为{" "}
|
||||||
|
{(
|
||||||
|
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
|
||||||
|
).toFixed(1)}{" "}
|
||||||
|
秒,默认值为{" "}
|
||||||
|
{(
|
||||||
|
DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000
|
||||||
|
).toFixed(1)}{" "}
|
||||||
|
秒。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSaveCanvasImageTaskQueueConfig}>
|
||||||
|
<Save className="mr-2 h-4 w-4" /> 保存并发与间隔
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</LocalAiSectionShell>
|
</LocalAiSectionShell>
|
||||||
|
|
||||||
{/* 批量处理配置 */}
|
{/* 批量处理配置 */}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,7 @@ import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-c
|
|||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { convertMessagesWithImages } from "./image-media";
|
import { convertMessagesWithImages } from "./image-media";
|
||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
|
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
|
||||||
|
|
||||||
const chatApiLogger = createLogger("chat.imageFailover");
|
const chatApiLogger = createLogger("chat.imageFailover");
|
||||||
|
|
||||||
@ -326,7 +326,7 @@ export function buildImageFallbackCandidates(
|
|||||||
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (isImageFallbackModelCoolingDown(item.model.id) || isImageModelBalanceBlocked(item.model)) {
|
if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
|
||||||
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
||||||
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||||
return false;
|
return false;
|
||||||
@ -488,7 +488,7 @@ export function getImageCrossProviderCandidates(
|
|||||||
}
|
}
|
||||||
if (m.model.id === options.currentModelId) return false;
|
if (m.model.id === options.currentModelId) return false;
|
||||||
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
|
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
|
||||||
if (isImageFallbackModelCoolingDown(m.model.id) || isImageModelBalanceBlocked(m.model)) {
|
if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) {
|
||||||
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
||||||
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
import { saveMessages } from "@/lib/db/queries";
|
|
||||||
import type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status";
|
|
||||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
|
||||||
import { safeWriteStreamChunk, type StreamWriter, writeAssistantTextChunk, writeStructuredDataChunk } from "./stream";
|
|
||||||
|
|
||||||
export async function reportImageGenerationFailure(params: {
|
|
||||||
writer: StreamWriter;
|
|
||||||
chatId: string;
|
|
||||||
userMessageId: string;
|
|
||||||
messageId: string;
|
|
||||||
error: unknown;
|
|
||||||
label: string;
|
|
||||||
}) {
|
|
||||||
const failure = getRequestFailureDetails(params.error, "图片生成失败");
|
|
||||||
const status: ImageGenerationStatusStreamPayload = {
|
|
||||||
phase: "failed",
|
|
||||||
userMessageId: params.userMessageId,
|
|
||||||
requestId: params.messageId,
|
|
||||||
failure,
|
|
||||||
};
|
|
||||||
const text = formatRequestFailure(failure, params.messageId);
|
|
||||||
const parts = [
|
|
||||||
{ type: "text", text },
|
|
||||||
{ type: "data-imageGenerationStatus", data: status },
|
|
||||||
];
|
|
||||||
writeStructuredDataChunk(params.writer, "data-imageGenerationStatus", status, `${params.label}:status`);
|
|
||||||
safeWriteStreamChunk(params.writer, {
|
|
||||||
type: "data-appendMessage",
|
|
||||||
data: JSON.stringify({ id: params.messageId, role: "assistant", parts }),
|
|
||||||
}, params.label);
|
|
||||||
writeAssistantTextChunk(params.writer, "<!-- IMAGE_GENERATION_END -->", `${params.label}:end`);
|
|
||||||
// History carries the same terminal state when the browser loses the stream.
|
|
||||||
await saveMessages({ messages: [{
|
|
||||||
id: params.messageId,
|
|
||||||
chatId: params.chatId,
|
|
||||||
role: "assistant",
|
|
||||||
parts,
|
|
||||||
attachments: [],
|
|
||||||
createdAt: new Date(),
|
|
||||||
videoStorageStatus: null,
|
|
||||||
temporaryVideoUrl: null,
|
|
||||||
permanentVideoUrl: null,
|
|
||||||
archivedAt: null,
|
|
||||||
videoMetadata: null,
|
|
||||||
}] });
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
@ -208,7 +208,6 @@ export function writeImageGenerationStatusChunk(
|
|||||||
writer: StreamWriter,
|
writer: StreamWriter,
|
||||||
params: {
|
params: {
|
||||||
phase: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
phase: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
||||||
userMessageId?: string;
|
|
||||||
providerType?: string | null;
|
providerType?: string | null;
|
||||||
modelName?: string | null;
|
modelName?: string | null;
|
||||||
imageCount?: number | null;
|
imageCount?: number | null;
|
||||||
@ -224,7 +223,6 @@ export function writeImageGenerationStatusChunk(
|
|||||||
"data-imageGenerationStatus",
|
"data-imageGenerationStatus",
|
||||||
{
|
{
|
||||||
phase: params.phase,
|
phase: params.phase,
|
||||||
userMessageId: params.userMessageId,
|
|
||||||
providerType: normalizeImageLoadingProviderType(params.providerType),
|
providerType: normalizeImageLoadingProviderType(params.providerType),
|
||||||
modelName: String(params.modelName || "").trim() || undefined,
|
modelName: String(params.modelName || "").trim() || undefined,
|
||||||
imageCount:
|
imageCount:
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access";
|
|
||||||
import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol";
|
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
|
||||||
import { geolocation } from "@vercel/functions";
|
import { geolocation } from "@vercel/functions";
|
||||||
import {
|
import {
|
||||||
convertToModelMessages,
|
convertToModelMessages,
|
||||||
createUIMessageStream as createAiUIMessageStream,
|
createUIMessageStream,
|
||||||
generateText,
|
generateText,
|
||||||
smoothStream,
|
smoothStream,
|
||||||
stepCountIs,
|
stepCountIs,
|
||||||
@ -197,7 +194,6 @@ import {
|
|||||||
} from "@/lib/ai/runtime/agent-media-markdown";
|
} from "@/lib/ai/runtime/agent-media-markdown";
|
||||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||||
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
|
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
|
||||||
import { withAuthenticationFailover } from "@/lib/ai/runtime/authentication-failover";
|
|
||||||
import {
|
import {
|
||||||
recordAiModelHealthFailure,
|
recordAiModelHealthFailure,
|
||||||
recordAiModelHealthSuccess,
|
recordAiModelHealthSuccess,
|
||||||
@ -337,7 +333,6 @@ import {
|
|||||||
} from "./_lib/image-media";
|
} from "./_lib/image-media";
|
||||||
import {
|
import {
|
||||||
buildImageFallbackCandidates,
|
buildImageFallbackCandidates,
|
||||||
countChatMessageImageInputs,
|
|
||||||
generateImageWithFailoverCandidate,
|
generateImageWithFailoverCandidate,
|
||||||
getImageCrossProviderCandidates,
|
getImageCrossProviderCandidates,
|
||||||
IMAGE_FAILOVER_PROVIDER_ALLOWLIST,
|
IMAGE_FAILOVER_PROVIDER_ALLOWLIST,
|
||||||
@ -346,8 +341,6 @@ import {
|
|||||||
setImageFallbackModelCooldown,
|
setImageFallbackModelCooldown,
|
||||||
shouldCooldownImageFallbackModel,
|
shouldCooldownImageFallbackModel,
|
||||||
} from "./_lib/image-failover";
|
} from "./_lib/image-failover";
|
||||||
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
|
||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
|
||||||
import {
|
import {
|
||||||
buildAgentSceneWorkflowPrompt,
|
buildAgentSceneWorkflowPrompt,
|
||||||
buildAgentSlotPrompt,
|
buildAgentSlotPrompt,
|
||||||
@ -426,11 +419,7 @@ import {
|
|||||||
type VideoModeType,
|
type VideoModeType,
|
||||||
} from "./_lib/video-models";
|
} from "./_lib/video-models";
|
||||||
|
|
||||||
import { reportImageGenerationFailure } from "./_lib/image-generation-failure";
|
|
||||||
|
|
||||||
const chatApiLogger = createLogger("chat.route");
|
const chatApiLogger = createLogger("chat.route");
|
||||||
const createUIMessageStream: typeof createAiUIMessageStream = (options) =>
|
|
||||||
createAiUIMessageStream({ ...options, execute: (writer) => trackRuntimeWork(() => options.execute(writer)) });
|
|
||||||
|
|
||||||
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
||||||
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
||||||
@ -448,6 +437,16 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri
|
|||||||
throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`);
|
throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPublicImageGenerationErrorText(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || "");
|
||||||
|
const publicMessage = getUserFacingImageGenerationErrorMessage(error);
|
||||||
|
if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) {
|
||||||
|
return "图片生成失败,请稍后重试";
|
||||||
|
}
|
||||||
|
return publicMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
|
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6),
|
Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6),
|
||||||
@ -905,15 +904,6 @@ export async function POST(request: Request) {
|
|||||||
const json = await request.json();
|
const json = await request.json();
|
||||||
chatApiLogger.info("[Chat API] Received request body:", JSON.stringify(json, null, 2));
|
chatApiLogger.info("[Chat API] Received request body:", JSON.stringify(json, null, 2));
|
||||||
requestBody = postRequestBodySchema.parse(json);
|
requestBody = postRequestBodySchema.parse(json);
|
||||||
// Agent/Auto Agent has one execution owner: the isolated native ZCode entry.
|
|
||||||
// This also upgrades existing browser tabs without routing Claw here.
|
|
||||||
if (requestBody.composerMode === "agent" && requestBody.contextPayload?.source === "canvas" && requestBody.contextPayload.projectId) {
|
|
||||||
const { POST: nativeAgentPost } = await import("@/app/api/canvas/agent-chat/route");
|
|
||||||
return nativeAgentPost(new Request(new URL("/api/canvas/agent-chat", request.url), {
|
|
||||||
method: "POST", headers: request.headers, body: JSON.stringify(json),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatApiLogger.error("[Chat API] Schema validation failed:", error);
|
chatApiLogger.error("[Chat API] Schema validation failed:", error);
|
||||||
return new ChatSDKError("bad_request:api").toResponse();
|
return new ChatSDKError("bad_request:api").toResponse();
|
||||||
@ -1208,14 +1198,6 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldRunAgentConversation && preferredAgentModelId && !shouldKeepSelectedModel) {
|
|
||||||
const preferred = await getAiModelById({ id: preferredAgentModelId }).catch(() => null);
|
|
||||||
if (!preferred?.isEnabled || preferred.healthStatus === "auto_disabled") {
|
|
||||||
preferredAgentModelId = undefined;
|
|
||||||
configuredAgentConversationModelId = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRunAgentConversation && !preferredAgentModelId) {
|
if (shouldRunAgentConversation && !preferredAgentModelId) {
|
||||||
const configuredAgentModel = await getAgentAiModelWithProvider().catch((agentModelError) => {
|
const configuredAgentModel = await getAgentAiModelWithProvider().catch((agentModelError) => {
|
||||||
chatApiLogger.warn("[Chat API] Failed to load configured agent model:", agentModelError);
|
chatApiLogger.warn("[Chat API] Failed to load configured agent model:", agentModelError);
|
||||||
@ -1336,7 +1318,6 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allowAgentVideoGeneration =
|
const allowAgentVideoGeneration =
|
||||||
(contextPayload?.source === "canvas" && inferVideoTaskRequirements(requestTextContent).expectsVideo) ||
|
|
||||||
Boolean(requestBody.videoParams) ||
|
Boolean(requestBody.videoParams) ||
|
||||||
isVideoProviderType(requestedModelProviderType, requestedModelConfig || undefined);
|
isVideoProviderType(requestedModelProviderType, requestedModelConfig || undefined);
|
||||||
let smartAutoWorkflowDecision:
|
let smartAutoWorkflowDecision:
|
||||||
@ -5680,7 +5661,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
modelName: resolvedModelRef,
|
modelName: resolvedModelRef,
|
||||||
imageCount: replacementReferences.length,
|
imageCount: replacementReferences.length,
|
||||||
@ -6023,7 +6003,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase:
|
phase:
|
||||||
queueStatus.status === "running"
|
queueStatus.status === "running"
|
||||||
? "processing"
|
? "processing"
|
||||||
@ -6179,7 +6158,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: successCount > 0 ? "completed" : "failed",
|
phase: successCount > 0 ? "completed" : "failed",
|
||||||
modelName: resolvedModelDisplayName,
|
modelName: resolvedModelDisplayName,
|
||||||
imageCount: successCount,
|
imageCount: successCount,
|
||||||
@ -6412,7 +6390,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
modelName:
|
modelName:
|
||||||
selectedAgentPreset.defaultModel?.displayName ||
|
selectedAgentPreset.defaultModel?.displayName ||
|
||||||
@ -6964,7 +6941,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase:
|
phase:
|
||||||
queueStatus.status === "running"
|
queueStatus.status === "running"
|
||||||
? "processing"
|
? "processing"
|
||||||
@ -7290,7 +7266,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase:
|
phase:
|
||||||
queueStatus.status === "running"
|
queueStatus.status === "running"
|
||||||
? "processing"
|
? "processing"
|
||||||
@ -7519,7 +7494,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase:
|
phase:
|
||||||
queueStatus.status === "running"
|
queueStatus.status === "running"
|
||||||
? "processing"
|
? "processing"
|
||||||
@ -7838,7 +7812,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: successCount > 0 ? "completed" : "failed",
|
phase: successCount > 0 ? "completed" : "failed",
|
||||||
modelName:
|
modelName:
|
||||||
selectedAgentPreset.defaultModel?.displayName ||
|
selectedAgentPreset.defaultModel?.displayName ||
|
||||||
@ -8048,7 +8021,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
providerType: requestedModelProviderType,
|
providerType: requestedModelProviderType,
|
||||||
modelName: finalModelId,
|
modelName: finalModelId,
|
||||||
@ -8087,7 +8059,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
...directImageStatusPayload,
|
...directImageStatusPayload,
|
||||||
},
|
},
|
||||||
@ -8180,7 +8151,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "completed",
|
phase: "completed",
|
||||||
providerType: requestedModelProviderType,
|
providerType: requestedModelProviderType,
|
||||||
modelName: directImageResult.model,
|
modelName: directImageResult.model,
|
||||||
@ -8201,16 +8171,42 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
} catch (directToolError) {
|
} catch (directToolError) {
|
||||||
chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError);
|
chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError);
|
||||||
const errorMessage = await reportImageGenerationFailure({
|
const errorMessage = getDirectAgentToolErrorMessage(
|
||||||
writer, chatId: id, userMessageId: message.id,
|
"generate_image",
|
||||||
messageId: assistantMessageId, error: directToolError,
|
directToolError,
|
||||||
label: "agent:generate_image:error",
|
);
|
||||||
|
await saveAssistantTextOnlyMessage({
|
||||||
|
chatId: id,
|
||||||
|
messageId: assistantMessageId,
|
||||||
|
text: errorMessage,
|
||||||
});
|
});
|
||||||
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
errorMessage,
|
||||||
|
"agent:generate_image:error",
|
||||||
|
assistantMessageId,
|
||||||
|
);
|
||||||
|
if (contextPayload?.source === "canvas") {
|
||||||
|
writeImageGenerationStatusChunk(
|
||||||
|
writer,
|
||||||
|
{
|
||||||
|
phase: "failed",
|
||||||
|
providerType: requestedModelProviderType,
|
||||||
|
modelName: finalModelId,
|
||||||
|
},
|
||||||
|
"agent:generate_image:status-failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
writeAgentProgressChunk(
|
writeAgentProgressChunk(
|
||||||
writer,
|
writer,
|
||||||
buildDirectAgentImageGenerateFailedProgress(errorMessage),
|
buildDirectAgentImageGenerateFailedProgress(errorMessage),
|
||||||
"agent:generate_image:progress-failed",
|
"agent:generate_image:progress-failed",
|
||||||
);
|
);
|
||||||
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
"<!-- IMAGE_GENERATION_END -->",
|
||||||
|
"agent:generate_image:end-error",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
stopDirectImageKeepalive();
|
stopDirectImageKeepalive();
|
||||||
if (stopDirectImageStatus) {
|
if (stopDirectImageStatus) {
|
||||||
@ -8752,19 +8748,6 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^[0-9a-f-]{36}$/i.test(finalModelId)) {
|
|
||||||
const requested = await getAiModelById({ id: finalModelId });
|
|
||||||
if (requested?.supportsImageGen && isImageModelBalanceBlocked(requested)) {
|
|
||||||
const replacement = resolveConfiguredImageBalanceFallback(requested, await getEnabledAiModelsWithProvider(), {
|
|
||||||
imageSize: effectiveImageGenerationParams.imageSize,
|
|
||||||
aspectRatio: effectiveImageGenerationParams.aspectRatio,
|
|
||||||
referenceImageCount: countChatMessageImageInputs(uiMessagesForModel.filter(item => item.role === "user").slice(-1)),
|
|
||||||
});
|
|
||||||
if (!replacement) return new ChatSDKError("bad_request:api", "当前生图线路余额不足,已暂停新请求;请切换可用模型,已有任务和图片会保留。").toResponse();
|
|
||||||
finalModelId = replacement.model.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否是 UUID 格式(数据库中的模型 ID)
|
// 检查是否是 UUID 格式(数据库中的模型 ID)
|
||||||
const isUUID =
|
const isUUID =
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
@ -8882,7 +8865,7 @@ export async function POST(request: Request) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
let activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
|
const activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
|
||||||
resolvedRuntimeModelLookupId,
|
resolvedRuntimeModelLookupId,
|
||||||
).catch(() => null);
|
).catch(() => null);
|
||||||
if (shouldRunAgentConversation && activeRuntimeModelDescriptor) {
|
if (shouldRunAgentConversation && activeRuntimeModelDescriptor) {
|
||||||
@ -9172,7 +9155,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
providerType: (languageModel as any).provider,
|
providerType: (languageModel as any).provider,
|
||||||
modelName: currentModelConfig?.displayName || actualModelId,
|
modelName: currentModelConfig?.displayName || actualModelId,
|
||||||
@ -9260,7 +9242,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.warn("[Chat API] Record Gemini fallback health failure failed:", healthError);
|
chatApiLogger.warn("[Chat API] Record Gemini fallback health failure failed:", healthError);
|
||||||
});
|
});
|
||||||
chatApiLogger.error(`[Chat API] ❌ Fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
chatApiLogger.error(`[Chat API] ❌ Fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -9335,7 +9316,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.warn("[Chat API] Record Gemini cross-provider health failure failed:", healthError);
|
chatApiLogger.warn("[Chat API] Record Gemini cross-provider health failure failed:", healthError);
|
||||||
});
|
});
|
||||||
chatApiLogger.error(`[Chat API] ❌ Gemini cross-provider fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
chatApiLogger.error(`[Chat API] ❌ Gemini cross-provider fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -9403,7 +9383,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "completed",
|
phase: "completed",
|
||||||
providerType: (languageModel as any).provider,
|
providerType: (languageModel as any).provider,
|
||||||
modelName: usedModelDisplayName,
|
modelName: usedModelDisplayName,
|
||||||
@ -9468,14 +9447,6 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatApiLogger.error("[Chat API] Gemini generation error:", error);
|
chatApiLogger.error("[Chat API] Gemini generation error:", error);
|
||||||
let finalError = error;
|
|
||||||
if (!shouldFailoverAiModelError(error)) {
|
|
||||||
await reportImageGenerationFailure({
|
|
||||||
writer, chatId: id, userMessageId: message.id,
|
|
||||||
messageId: generateUUID(), error, label: "gemini:error",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chatApiLogger.info("[Chat API] Attempting failover to Volcano Vision...");
|
chatApiLogger.info("[Chat API] Attempting failover to Volcano Vision...");
|
||||||
|
|
||||||
// 故障转移:尝试使用火山引擎模型
|
// 故障转移:尝试使用火山引擎模型
|
||||||
@ -9553,7 +9524,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "completed",
|
phase: "completed",
|
||||||
providerType: "volcano-vision",
|
providerType: "volcano-vision",
|
||||||
modelName: volcanoModel.model.displayName || volcanoModel.model.modelId,
|
modelName: volcanoModel.model.displayName || volcanoModel.model.modelId,
|
||||||
@ -9619,14 +9589,46 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.info("[Chat API] No Volcano Vision model available for failover");
|
chatApiLogger.info("[Chat API] No Volcano Vision model available for failover");
|
||||||
} catch (failoverError) {
|
} catch (failoverError) {
|
||||||
chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError);
|
chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError);
|
||||||
finalError = failoverError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果故障转移也失败,返回错误消息
|
// 如果故障转移也失败,返回错误消息
|
||||||
await reportImageGenerationFailure({
|
const errorMessageId = generateUUID();
|
||||||
writer, chatId: id, userMessageId: message.id,
|
writeAssistantTextChunk(
|
||||||
messageId: generateUUID(), error: finalError, label: "gemini:error",
|
writer,
|
||||||
|
"图片生成失败,请稍后重试",
|
||||||
|
"gemini:error",
|
||||||
|
errorMessageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🆕 图片生成结束标记(失败)
|
||||||
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
"<!-- IMAGE_GENERATION_END -->",
|
||||||
|
"gemini:image-end-failed",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 尝试保存错误消息
|
||||||
|
try {
|
||||||
|
await saveMessages({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: errorMessageId,
|
||||||
|
chatId: id,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text: "图片生成失败,请稍后重试" }],
|
||||||
|
attachments: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
videoStorageStatus: null,
|
||||||
|
temporaryVideoUrl: null,
|
||||||
|
permanentVideoUrl: null,
|
||||||
|
archivedAt: null,
|
||||||
|
videoMetadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
} catch (saveError) {
|
||||||
|
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
stopKeepalive?.();
|
stopKeepalive?.();
|
||||||
}
|
}
|
||||||
@ -9824,7 +9826,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "queued",
|
phase: "queued",
|
||||||
providerType: "fal-image",
|
providerType: "fal-image",
|
||||||
modelName: modelDisplayName,
|
modelName: modelDisplayName,
|
||||||
@ -9931,7 +9932,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
providerType: (languageModel as any).provider,
|
providerType: (languageModel as any).provider,
|
||||||
modelName: alibabaModelConfig?.displayName || actualModelId,
|
modelName: alibabaModelConfig?.displayName || actualModelId,
|
||||||
@ -9981,10 +9981,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
let fallbackSuccess = false;
|
let fallbackSuccess = false;
|
||||||
const triedFallbackModelIds = new Set<string>();
|
const triedFallbackModelIds = new Set<string>();
|
||||||
const balanceBlockedProviders = new Set<string>();
|
|
||||||
if (isProviderBalanceError(primaryError) && currentModelEntry?.provider.id) balanceBlockedProviders.add(currentModelEntry.provider.id);
|
|
||||||
for (const candidate of fallbackCandidates) {
|
for (const candidate of fallbackCandidates) {
|
||||||
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
|
|
||||||
try {
|
try {
|
||||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||||
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
@ -10007,7 +10004,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
|
chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
|
||||||
break;
|
break;
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
if (isProviderBalanceError(fallbackError)) balanceBlockedProviders.add(candidate.provider.id);
|
|
||||||
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
|
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
|
||||||
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
||||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||||
@ -10029,7 +10025,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError);
|
chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError);
|
||||||
});
|
});
|
||||||
chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -10057,10 +10052,9 @@ export async function POST(request: Request) {
|
|||||||
currentModelKey: actualModelId,
|
currentModelKey: actualModelId,
|
||||||
currentProviderId: alibabaModelConfig?.providerId,
|
currentProviderId: alibabaModelConfig?.providerId,
|
||||||
providerPriority,
|
providerPriority,
|
||||||
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")) && !balanceBlockedProviders.has(candidate.provider.id));
|
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")));
|
||||||
|
|
||||||
for (const candidate of crossProviderCandidates) {
|
for (const candidate of crossProviderCandidates) {
|
||||||
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
|
|
||||||
try {
|
try {
|
||||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||||
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
@ -10142,7 +10136,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
break;
|
break;
|
||||||
} catch (crossError) {
|
} catch (crossError) {
|
||||||
if (isProviderBalanceError(crossError)) balanceBlockedProviders.add(candidate.provider.id);
|
|
||||||
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
|
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
|
||||||
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
||||||
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
|
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
|
||||||
@ -10164,7 +10157,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError);
|
chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError);
|
||||||
});
|
});
|
||||||
chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError);
|
chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError);
|
||||||
if (!shouldFailoverAiModelError(crossError)) throw crossError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -10232,7 +10224,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "completed",
|
phase: "completed",
|
||||||
providerType: (languageModel as any).provider,
|
providerType: (languageModel as any).provider,
|
||||||
modelName: usedModelDisplayName,
|
modelName: usedModelDisplayName,
|
||||||
@ -10297,10 +10288,43 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.error("[Chat API] Alibaba generation error:", error);
|
chatApiLogger.error("[Chat API] Alibaba generation error:", error);
|
||||||
|
|
||||||
// 返回错误消息
|
// 返回错误消息
|
||||||
await reportImageGenerationFailure({
|
const errorMessageId = generateUUID();
|
||||||
writer, chatId: id, userMessageId: message.id,
|
const errorText = getPublicImageGenerationErrorText(error);
|
||||||
messageId: generateUUID(), error, label: "alibaba:error",
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
errorText,
|
||||||
|
"alibaba:error",
|
||||||
|
errorMessageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🆕 图片生成结束标记(失败)
|
||||||
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
"<!-- IMAGE_GENERATION_END -->",
|
||||||
|
"alibaba:image-end-failed",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveMessages({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: errorMessageId,
|
||||||
|
chatId: id,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text: errorText }],
|
||||||
|
attachments: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
videoStorageStatus: null,
|
||||||
|
temporaryVideoUrl: null,
|
||||||
|
permanentVideoUrl: null,
|
||||||
|
archivedAt: null,
|
||||||
|
videoMetadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
} catch (saveError) {
|
||||||
|
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
stopKeepalive?.();
|
stopKeepalive?.();
|
||||||
}
|
}
|
||||||
@ -10429,7 +10453,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
providerType: "volcano-vision",
|
providerType: "volcano-vision",
|
||||||
modelName: volcanoModelConfig?.displayName || actualModelId,
|
modelName: volcanoModelConfig?.displayName || actualModelId,
|
||||||
@ -10475,7 +10498,6 @@ export async function POST(request: Request) {
|
|||||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||||
}
|
}
|
||||||
chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!fallbackSuccess) {
|
if (!fallbackSuccess) {
|
||||||
@ -10642,7 +10664,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "completed",
|
phase: "completed",
|
||||||
providerType: "volcano-vision",
|
providerType: "volcano-vision",
|
||||||
modelName: usedModelDisplayName,
|
modelName: usedModelDisplayName,
|
||||||
@ -10707,10 +10728,43 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
|
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
|
||||||
await reportImageGenerationFailure({
|
const errorMessageId = generateUUID();
|
||||||
writer, chatId: id, userMessageId: message.id,
|
writeAssistantTextChunk(
|
||||||
messageId: generateUUID(), error, label: "volcano:error",
|
writer,
|
||||||
|
"图片生成失败,请稍后重试",
|
||||||
|
"volcano:error",
|
||||||
|
errorMessageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🆕 图片生成结束标记(失败)
|
||||||
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
"<!-- IMAGE_GENERATION_END -->",
|
||||||
|
"volcano:image-end-failed",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 尝试保存错误消息
|
||||||
|
try {
|
||||||
|
await saveMessages({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: errorMessageId,
|
||||||
|
chatId: id,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text: "图片生成失败,请稍后重试" }],
|
||||||
|
attachments: [], // 必须提供 attachments 字段
|
||||||
|
createdAt: new Date(),
|
||||||
|
videoStorageStatus: null,
|
||||||
|
temporaryVideoUrl: null,
|
||||||
|
permanentVideoUrl: null,
|
||||||
|
archivedAt: null,
|
||||||
|
videoMetadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
} catch (saveError) {
|
||||||
|
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
stopKeepalive?.();
|
stopKeepalive?.();
|
||||||
}
|
}
|
||||||
@ -11168,7 +11222,7 @@ export async function POST(request: Request) {
|
|||||||
requestHeaders: request.headers,
|
requestHeaders: request.headers,
|
||||||
requestUrl: request.url,
|
requestUrl: request.url,
|
||||||
defaultModelId: finalModelId,
|
defaultModelId: finalModelId,
|
||||||
authorizeDestructive: (nodeIds) => isExplicitDestructiveIntent(requestTextContent) && nodeIds.length > 0 && nodeIds.every((nodeId) => selectedCanvasImageNodeIds.includes(nodeId)),
|
destructiveConfirmed: isCanvasOperationConfirmText(requestTextContent),
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onClientCommand: (command, capability) => {
|
onClientCommand: (command, capability) => {
|
||||||
writeCanvasCapabilityCommandChunk(
|
writeCanvasCapabilityCommandChunk(
|
||||||
@ -11283,7 +11337,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
dataStream as unknown as StreamWriter,
|
dataStream as unknown as StreamWriter,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType,
|
providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType,
|
||||||
modelName: currentModelConfig?.displayName || actualModelId,
|
modelName: currentModelConfig?.displayName || actualModelId,
|
||||||
@ -11375,7 +11428,6 @@ export async function POST(request: Request) {
|
|||||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||||
}
|
}
|
||||||
chatApiLogger.error(`[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
chatApiLogger.error(`[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -11547,7 +11599,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
dataStream as unknown as StreamWriter,
|
dataStream as unknown as StreamWriter,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "completed",
|
phase: "completed",
|
||||||
providerType: resolvedProviderType || providerType,
|
providerType: resolvedProviderType || providerType,
|
||||||
modelName: usedModelDisplayName,
|
modelName: usedModelDisplayName,
|
||||||
@ -11607,10 +11658,54 @@ export async function POST(request: Request) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
|
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
|
||||||
|
|
||||||
await reportImageGenerationFailure({
|
const errorMessageId = generateUUID();
|
||||||
writer: dataStream as unknown as StreamWriter,
|
const errorText = getPublicImageGenerationErrorText(error);
|
||||||
chatId: id, userMessageId: message.id,
|
|
||||||
messageId: generateUUID(), error, label: "openai-compat:error",
|
dataStream.write({
|
||||||
|
type: "data-appendMessage",
|
||||||
|
data: JSON.stringify({
|
||||||
|
id: errorMessageId,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text: errorText }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
dataStream.write({
|
||||||
|
type: "data-appendMessage",
|
||||||
|
data: JSON.stringify({
|
||||||
|
id: generateUUID(),
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text: "<!-- IMAGE_GENERATION_END -->" }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (contextPayload?.source === "canvas") {
|
||||||
|
writeImageGenerationStatusChunk(
|
||||||
|
dataStream as unknown as StreamWriter,
|
||||||
|
{
|
||||||
|
phase: "failed",
|
||||||
|
providerType: resolvedProviderType || providerType,
|
||||||
|
modelName: currentModelConfig?.displayName || actualModelId,
|
||||||
|
imageCount: effectiveImageParams?.imageCount || 1,
|
||||||
|
},
|
||||||
|
"openai-compat:image-status-failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveMessages({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: errorMessageId,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text: errorText }],
|
||||||
|
createdAt: new Date(),
|
||||||
|
attachments: [],
|
||||||
|
chatId: id,
|
||||||
|
videoStorageStatus: null,
|
||||||
|
temporaryVideoUrl: null,
|
||||||
|
permanentVideoUrl: null,
|
||||||
|
archivedAt: null,
|
||||||
|
videoMetadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -12156,70 +12251,8 @@ export async function POST(request: Request) {
|
|||||||
const shouldDisableSmoothStream =
|
const shouldDisableSmoothStream =
|
||||||
resolvedModelSupportsImageGen || isVolcanoVideoModel;
|
resolvedModelSupportsImageGen || isVolcanoVideoModel;
|
||||||
|
|
||||||
let activeConversationModel = currentModelConfig;
|
|
||||||
let activeConversationProvider = currentProviderConfig;
|
|
||||||
const recordedAuthFailures = new Set<string>();
|
|
||||||
const recordConversationAuthFailure = async (error: unknown) => {
|
|
||||||
const key = activeConversationModel?.id;
|
|
||||||
if (!key || recordedAuthFailures.has(key)) return;
|
|
||||||
recordedAuthFailures.add(key);
|
|
||||||
recordProviderHealthFailure({
|
|
||||||
providerKey: activeConversationProvider?.id,
|
|
||||||
providerType: activeConversationProvider?.type,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
await recordAiModelHealthFailure({
|
|
||||||
model: activeConversationModel,
|
|
||||||
provider: activeConversationProvider,
|
|
||||||
error,
|
|
||||||
context: "canvas_agent_conversation_auth",
|
|
||||||
}).catch((healthError) => chatApiLogger.warn("[Chat API] Could not persist model authentication failure:", healthError));
|
|
||||||
};
|
|
||||||
const needsVisionFallback = convertedMessages.some((item: any) =>
|
|
||||||
Array.isArray(item.content) && item.content.some((part: any) =>
|
|
||||||
part.type === "image" || (part.type === "file" && String(part.mediaType || "").startsWith("image/"))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const streamModel = shouldRunAgentConversation && !shouldKeepSelectedModel &&
|
|
||||||
currentModelConfig?.excludeFromFailover !== true &&
|
|
||||||
!resolvedModelSupportsImageGen && !isVolcanoVideoModel
|
|
||||||
? withAuthenticationFailover({
|
|
||||||
primary: languageModel as any,
|
|
||||||
onAuthenticationFailure: recordConversationAuthFailure,
|
|
||||||
resolveFallback: async () => {
|
|
||||||
const enabled = await getEnabledAiModelsWithProvider(false);
|
|
||||||
const preferredIds = [currentModelConfig?.fallbackModelId1, currentModelConfig?.fallbackModelId2];
|
|
||||||
const candidates = enabled.filter(({ model, provider }) =>
|
|
||||||
model.id !== activeConversationModel?.id && model.isEnabled && provider.isEnabled &&
|
|
||||||
!model.excludeFromFailover && !model.isSubModel && model.healthStatus !== "auto_disabled" &&
|
|
||||||
!isImageGenerationModelType(provider.type, model) && !isVideoProviderType(provider.type, model) &&
|
|
||||||
(!needsVisionFallback || model.supportsVision) &&
|
|
||||||
(isAiModelAgentAuto(model) || preferredIds.includes(model.id)) &&
|
|
||||||
!shouldAvoidProvider(getProviderHealthSnapshot(provider.id))
|
|
||||||
).sort((a, b) => {
|
|
||||||
const rank = (id: string, isDefault: boolean) => {
|
|
||||||
const index = preferredIds.indexOf(id);
|
|
||||||
return index >= 0 ? index : isDefault ? 2 : 3;
|
|
||||||
};
|
|
||||||
return rank(a.model.id, a.model.isDefault) - rank(b.model.id, b.model.isDefault);
|
|
||||||
});
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const fallback = await getLanguageModelById(candidate.model.id);
|
|
||||||
if (!fallback || typeof fallback === "string") continue;
|
|
||||||
activeConversationModel = candidate.model;
|
|
||||||
activeConversationProvider = candidate.provider;
|
|
||||||
finalModelId = candidate.model.id;
|
|
||||||
actualModelId = candidate.model.modelId;
|
|
||||||
activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(candidate.model.id).catch(() => null);
|
|
||||||
return fallback as any;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: languageModel;
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: streamModel,
|
model: languageModel,
|
||||||
maxRetries: shouldRunAgentConversation ? 0 : undefined,
|
|
||||||
system: effectiveConversationSystemPrompt,
|
system: effectiveConversationSystemPrompt,
|
||||||
messages: convertedMessages,
|
messages: convertedMessages,
|
||||||
stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5),
|
stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5),
|
||||||
|
|||||||
@ -8,8 +8,6 @@ import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsR
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { formatBeijingTime } from "@/lib/utils";
|
import { formatBeijingTime } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
type FeaturedImage = {
|
type FeaturedImage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -76,11 +74,18 @@ export default function FeaturedPage() {
|
|||||||
|
|
||||||
const handleDownload = async (url: string, prompt: string) => {
|
const handleDownload = async (url: string, prompt: string) => {
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url, name: "featured-image" });
|
const response = await fetch(url);
|
||||||
toast.success("原图已开始下载");
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = `featured-${Date.now()}.png`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download failed:", error);
|
console.error("Download failed:", error);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,5 +3,10 @@ export default function FlowBoardLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return <>{children}</>;
|
return (
|
||||||
|
<>
|
||||||
|
<link href="/vendor/drawnix.css" rel="stylesheet" />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@ -10,10 +9,9 @@ import { Input } from "@/components/ui/input";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
import { formatBeijingTime } from "@/lib/utils";
|
import { formatBeijingTime } from "@/lib/utils";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
type GalleryImage = {
|
type GalleryImage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -34,7 +32,7 @@ type Pagination = {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildGalleryDownloadFilename(url: string, alt: string, contentType: string) {
|
function buildGalleryDownloadFilename(url: string, alt: string) {
|
||||||
const rawUrl = String(url || "").trim();
|
const rawUrl = String(url || "").trim();
|
||||||
const rawAlt = String(alt || "").trim();
|
const rawAlt = String(alt || "").trim();
|
||||||
const safeBase =
|
const safeBase =
|
||||||
@ -60,7 +58,7 @@ function buildGalleryDownloadFilename(url: string, alt: string, contentType: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${safeBase}.${getImageDownloadExtension(contentType) || extension}`;
|
return `${safeBase}-${Date.now()}.${extension}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GalleryPage() {
|
export default function GalleryPage() {
|
||||||
@ -105,7 +103,7 @@ export default function GalleryPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (url: string, alt: string) => {
|
const handleDownload = async (url: string, alt: string) => {
|
||||||
const originalUrl = String(url || "").trim();
|
const originalUrl = inferOriginalImageUrl(String(url || "").trim());
|
||||||
if (!originalUrl) {
|
if (!originalUrl) {
|
||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
@ -114,17 +112,32 @@ export default function GalleryPage() {
|
|||||||
try {
|
try {
|
||||||
toast.info("正在准备下载...");
|
toast.info("正在准备下载...");
|
||||||
|
|
||||||
// Validate the supplied URL before any legacy thumbnail inference can
|
const response = await fetch(
|
||||||
// remove signed storage transformation parameters.
|
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
|
||||||
buildImageProxyUrl(originalUrl, { delivery: "original" });
|
);
|
||||||
const parsed = new URL(originalUrl, window.location.origin);
|
if (!response.ok) {
|
||||||
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
|
throw new Error(`download failed: ${response.status}`);
|
||||||
|
}
|
||||||
await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") });
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = buildGalleryDownloadFilename(originalUrl, alt);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
|
||||||
toast.success("图片已开始下载");
|
toast.success("图片已开始下载");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download failed:", error);
|
console.error("Download failed:", error);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
const fallbackLink = document.createElement("a");
|
||||||
|
fallbackLink.href = originalUrl;
|
||||||
|
fallbackLink.target = "_blank";
|
||||||
|
fallbackLink.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(fallbackLink);
|
||||||
|
fallbackLink.click();
|
||||||
|
document.body.removeChild(fallbackLink);
|
||||||
|
toast.error("下载失败,已为你打开原图,可直接另存为");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -304,12 +317,7 @@ export default function GalleryPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex flex-wrap justify-center gap-6 px-4">
|
<div className="flex flex-wrap justify-center gap-6 px-4">
|
||||||
{images.map((img) => isRetiredStorageUrl(img.url) ? (
|
{images.map((img) => (
|
||||||
<div key={img.id} className="flex h-[270px] w-[200px] shrink-0 flex-col justify-center rounded-lg border bg-muted p-4 text-center text-sm text-muted-foreground">
|
|
||||||
<p>{RETIRED_STORAGE_MESSAGE}</p>
|
|
||||||
<p className="mt-3 line-clamp-3 text-xs">{img.alt}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
<div
|
||||||
key={img.id}
|
key={img.id}
|
||||||
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
|
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
|
||||||
@ -318,7 +326,7 @@ export default function GalleryPage() {
|
|||||||
>
|
>
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<Image
|
<Image
|
||||||
src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url}
|
src={img.thumbnailUrl || img.url}
|
||||||
alt={img.alt}
|
alt={img.alt}
|
||||||
width={200}
|
width={200}
|
||||||
height={270}
|
height={270}
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { aiModelHealthEvent } from "@/lib/db/schema";
|
|
||||||
import { and, desc, eq, sql } from "drizzle-orm";
|
|
||||||
import {
|
import {
|
||||||
|
listAiModelHealthEvents,
|
||||||
restoreAiModelHealth,
|
restoreAiModelHealth,
|
||||||
} from "@/lib/ai/runtime/model-health";
|
} from "@/lib/ai/runtime/model-health";
|
||||||
|
|
||||||
@ -17,30 +15,14 @@ export async function GET(request: Request) {
|
|||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const modelId = searchParams.get("modelId");
|
const modelId = searchParams.get("modelId") || undefined;
|
||||||
const providerId = searchParams.get("providerId");
|
const limit = Number(searchParams.get("limit") || 80);
|
||||||
const rawLimit = Number(searchParams.get("limit") || 20);
|
const events = await listAiModelHealthEvents({
|
||||||
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 20;
|
modelId,
|
||||||
const rawPage = Number(searchParams.get("page") || 1);
|
limit: Number.isFinite(limit) ? limit : 80,
|
||||||
const page = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1;
|
});
|
||||||
const filters = [];
|
|
||||||
if (modelId) filters.push(eq(aiModelHealthEvent.modelId, modelId));
|
return Response.json({ events });
|
||||||
if (providerId) filters.push(eq(aiModelHealthEvent.providerId, providerId));
|
|
||||||
if (searchParams.get("eventType")) filters.push(eq(aiModelHealthEvent.eventType, searchParams.get("eventType")!));
|
|
||||||
const start = searchParams.get("startDate");
|
|
||||||
const end = searchParams.get("endDate");
|
|
||||||
if (start && /^\d{4}-\d{2}-\d{2}$/.test(start)) filters.push(sql`${aiModelHealthEvent.createdAt}>=${start}::date`);
|
|
||||||
if (end && /^\d{4}-\d{2}-\d{2}$/.test(end)) filters.push(sql`${aiModelHealthEvent.createdAt}<${end}::date+interval '1 day'`);
|
|
||||||
const events = await db.select({
|
|
||||||
id: aiModelHealthEvent.id, modelId: aiModelHealthEvent.modelId, modelDisplayName: aiModelHealthEvent.modelDisplayName,
|
|
||||||
modelRuntimeId: aiModelHealthEvent.modelRuntimeId, providerName: aiModelHealthEvent.providerName,
|
|
||||||
providerId: aiModelHealthEvent.providerId, eventType: aiModelHealthEvent.eventType,
|
|
||||||
errorMessage: aiModelHealthEvent.errorMessage, elapsedMs: aiModelHealthEvent.elapsedMs,
|
|
||||||
consecutiveFailures: aiModelHealthEvent.consecutiveFailures, autoDisabled: aiModelHealthEvent.autoDisabled,
|
|
||||||
counted: aiModelHealthEvent.counted, createdAt: aiModelHealthEvent.createdAt,
|
|
||||||
}).from(aiModelHealthEvent).where(and(...filters)).orderBy(desc(aiModelHealthEvent.createdAt), desc(aiModelHealthEvent.id))
|
|
||||||
.limit(limit + 1).offset((page - 1) * limit);
|
|
||||||
return Response.json({ events: events.slice(0, limit), hasMore: events.length > limit, page });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) {
|
if (error instanceof ChatSDKError) {
|
||||||
return error.toResponse();
|
return error.toResponse();
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { aiModel, aiProvider } from "@/lib/db/schema";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
import {
|
import {
|
||||||
|
getAllAiModels,
|
||||||
getAiModelById,
|
getAiModelById,
|
||||||
|
getAiModelsByProviderId,
|
||||||
createAiModel,
|
createAiModel,
|
||||||
updateAiModel,
|
updateAiModel,
|
||||||
deleteAiModel,
|
deleteAiModel,
|
||||||
@ -155,14 +154,8 @@ export async function GET(request: Request) {
|
|||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
const providerId = searchParams.get("providerId");
|
const providerId = searchParams.get("providerId");
|
||||||
|
|
||||||
if (searchParams.get("view") === "options") {
|
|
||||||
const models = await db.select({ id: aiModel.id, modelId: aiModel.modelId, displayName: aiModel.displayName,
|
|
||||||
providerName: aiProvider.name }).from(aiModel).leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id));
|
|
||||||
return Response.json({ models: sortModelsForDisplay(models) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, id) });
|
const model = await getAiModelById({ id });
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
|
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
|
||||||
}
|
}
|
||||||
@ -170,19 +163,12 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (providerId) {
|
if (providerId) {
|
||||||
const models = await db.select().from(aiModel).where(eq(aiModel.providerId, providerId));
|
const models = await getAiModelsByProviderId({ providerId });
|
||||||
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = await db.select().from(aiModel);
|
const models = await getAllAiModels();
|
||||||
const normalized = models.map(normalizeModel);
|
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
||||||
const output = searchParams.get("view") === "summary" ? normalized.map(model => {
|
|
||||||
const { capabilities, defaultParams, imageGenConfig, description, ...summary } = model;
|
|
||||||
const fields = ["kind", "mediaType", "modelType", "outputType", "supportedModes"];
|
|
||||||
return { ...summary, healthLastError: summary.healthLastError?.slice(0, 1200),
|
|
||||||
capabilities: Object.fromEntries(fields.filter(key => capabilities?.[key] !== undefined).map(key => [key, capabilities[key]])) };
|
|
||||||
}) : normalized;
|
|
||||||
return Response.json({ models: sortModelsForDisplay(output) });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) {
|
if (error instanceof ChatSDKError) {
|
||||||
return error.toResponse();
|
return error.toResponse();
|
||||||
|
|||||||
@ -1,31 +1,237 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage, user } from "@/lib/db/schema";
|
import { chat, generatedImage, message, user } from "@/lib/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
function extractVideoUrlFromParts(parts: unknown): string | null {
|
||||||
import { mediaRecordsQuery, parseMediaQuery } from "@/lib/admin/media-records";
|
const text = Array.isArray(parts)
|
||||||
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
? parts
|
||||||
|
.map((part) => (part && typeof part === "object" && "text" in part ? String((part as any).text || "") : ""))
|
||||||
|
.join("\n")
|
||||||
|
: typeof parts === "string"
|
||||||
|
? parts
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
const htmlMatch = text.match(/<video[^>]+src="([^"]+)"/i);
|
||||||
|
if (htmlMatch?.[1]) return htmlMatch[1];
|
||||||
|
|
||||||
|
const markdownMatch = text.match(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i);
|
||||||
|
if (markdownMatch?.[1]) return markdownMatch[1];
|
||||||
|
|
||||||
|
const plainMatch = text.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
|
||||||
|
return plainMatch?.[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await requireAdmin();
|
const session = await auth();
|
||||||
const operator = await db.query.user.findFirst({ where: eq(user.id, session.user.id) });
|
|
||||||
if (operator?.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
if (!session?.user?.id) {
|
||||||
const query = parseMediaQuery(request.nextUrl.searchParams);
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (query.startDate && query.endDate && query.startDate > query.endDate) {
|
|
||||||
return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
const [result] = await db.execute(mediaRecordsQuery(query));
|
|
||||||
const images = await resolveArchivedImageUrls(result.records);
|
// 检查是否为管理员
|
||||||
const total = Number(result.total);
|
const currentUser = await db.query.user.findFirst({
|
||||||
return NextResponse.json({ images, pagination: { page: query.page, pageSize: query.pageSize, total,
|
where: eq(user.id, session.user.id),
|
||||||
totalPages: Math.ceil(total / query.pageSize), totalImages: Number(result.totalImages), totalVideos: Number(result.totalVideos) } });
|
});
|
||||||
|
|
||||||
|
if (currentUser?.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取查询参数
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const page = Number.parseInt(searchParams.get("page") || "1");
|
||||||
|
const pageSize = Number.parseInt(searchParams.get("pageSize") || "200");
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const search = searchParams.get("search") || "";
|
||||||
|
const startDate = searchParams.get("startDate");
|
||||||
|
const endDate = searchParams.get("endDate");
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
const imageConditions = [];
|
||||||
|
const videoConditions = [
|
||||||
|
eq(message.role, "assistant"),
|
||||||
|
or(
|
||||||
|
isNotNull(message.temporaryVideoUrl),
|
||||||
|
isNotNull(message.permanentVideoUrl),
|
||||||
|
sql`${message.parts}::text ILIKE '%<video%'`,
|
||||||
|
sql`${message.parts}::text ILIKE '%.mp4%'`
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (search) {
|
||||||
|
// 搜索用户名或邮箱
|
||||||
|
const matchingUsers = await db
|
||||||
|
.select({ id: user.id })
|
||||||
|
.from(user)
|
||||||
|
.where(or(
|
||||||
|
like(user.name, `%${search}%`),
|
||||||
|
like(user.email, `%${search}%`)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (matchingUsers.length > 0) {
|
||||||
|
const matchedUserIds = matchingUsers.map((u) => u.id);
|
||||||
|
imageConditions.push(
|
||||||
|
sql`${generatedImage.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
|
||||||
|
);
|
||||||
|
videoConditions.push(
|
||||||
|
sql`${chat.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 没有匹配的用户,返回空结果
|
||||||
|
return NextResponse.json({
|
||||||
|
images: [],
|
||||||
|
pagination: { page, pageSize, total: 0, totalPages: 0 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
const start = new Date(startDate);
|
||||||
|
imageConditions.push(gte(generatedImage.createdAt, start));
|
||||||
|
videoConditions.push(gte(message.createdAt, start));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
const end = new Date(endDate);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
imageConditions.push(lte(generatedImage.createdAt, end));
|
||||||
|
videoConditions.push(lte(message.createdAt, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageWhereClause = imageConditions.length > 0 ? and(...imageConditions) : undefined;
|
||||||
|
const videoWhereClause = videoConditions.length > 0 ? and(...videoConditions) : undefined;
|
||||||
|
|
||||||
|
// 查询图片总数
|
||||||
|
const imageCountResult = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(generatedImage)
|
||||||
|
.where(imageWhereClause);
|
||||||
|
|
||||||
|
// 查询视频总数
|
||||||
|
const videoCountResult = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(message)
|
||||||
|
.innerJoin(chat, eq(message.chatId, chat.id))
|
||||||
|
.where(videoWhereClause);
|
||||||
|
|
||||||
|
const totalImages = imageCountResult[0]?.count || 0;
|
||||||
|
const totalVideos = videoCountResult[0]?.count || 0;
|
||||||
|
const total = totalImages + totalVideos;
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
const fetchLimit = Math.max(page * pageSize, pageSize);
|
||||||
|
|
||||||
|
// 查询图片列表(包含用户信息)
|
||||||
|
const imageRows = await db
|
||||||
|
.select({
|
||||||
|
id: generatedImage.id,
|
||||||
|
userId: generatedImage.userId,
|
||||||
|
userName: user.name,
|
||||||
|
userEmail: user.email,
|
||||||
|
userDepartment: user.department,
|
||||||
|
thumbnailUrl: generatedImage.thumbnailUrl,
|
||||||
|
originalUrl: generatedImage.originalUrl,
|
||||||
|
imageSize: generatedImage.imageSize,
|
||||||
|
prompt: generatedImage.prompt,
|
||||||
|
modelId: generatedImage.modelId,
|
||||||
|
isFeatured: generatedImage.isFeatured,
|
||||||
|
featuredCategory: generatedImage.featuredCategory,
|
||||||
|
featuredAt: generatedImage.featuredAt,
|
||||||
|
createdAt: generatedImage.createdAt,
|
||||||
|
chatId: generatedImage.chatId,
|
||||||
|
recordType: sql<"image">`'image'`,
|
||||||
|
videoUrl: sql<string | null>`null`,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||||
|
.where(imageWhereClause)
|
||||||
|
.orderBy(desc(generatedImage.createdAt))
|
||||||
|
.limit(fetchLimit);
|
||||||
|
|
||||||
|
// 查询视频列表(来自消息表)
|
||||||
|
const videoRows = await db
|
||||||
|
.select({
|
||||||
|
id: message.id,
|
||||||
|
userId: chat.userId,
|
||||||
|
userName: user.name,
|
||||||
|
userEmail: user.email,
|
||||||
|
userDepartment: user.department,
|
||||||
|
temporaryVideoUrl: message.temporaryVideoUrl,
|
||||||
|
permanentVideoUrl: message.permanentVideoUrl,
|
||||||
|
parts: message.parts,
|
||||||
|
videoMetadata: message.videoMetadata,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
chatId: message.chatId,
|
||||||
|
})
|
||||||
|
.from(message)
|
||||||
|
.innerJoin(chat, eq(message.chatId, chat.id))
|
||||||
|
.leftJoin(user, eq(chat.userId, user.id))
|
||||||
|
.where(videoWhereClause)
|
||||||
|
.orderBy(desc(message.createdAt))
|
||||||
|
.limit(fetchLimit);
|
||||||
|
|
||||||
|
const normalizedVideos = videoRows
|
||||||
|
.map((item) => {
|
||||||
|
const metadata = (item.videoMetadata as Record<string, any> | null) || {};
|
||||||
|
const videoUrl =
|
||||||
|
item.permanentVideoUrl ||
|
||||||
|
item.temporaryVideoUrl ||
|
||||||
|
extractVideoUrlFromParts(item.parts);
|
||||||
|
|
||||||
|
if (!videoUrl) return null;
|
||||||
|
|
||||||
|
const resolution =
|
||||||
|
typeof metadata.resolution === "string" && metadata.resolution.trim()
|
||||||
|
? metadata.resolution.trim()
|
||||||
|
: null;
|
||||||
|
const prompt =
|
||||||
|
typeof metadata.prompt === "string" && metadata.prompt.trim()
|
||||||
|
? metadata.prompt.trim()
|
||||||
|
: null;
|
||||||
|
const modelId =
|
||||||
|
typeof metadata.model === "string" && metadata.model.trim()
|
||||||
|
? metadata.model.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `video-${item.id}`,
|
||||||
|
sourceMessageId: item.id,
|
||||||
|
userId: item.userId,
|
||||||
|
userName: item.userName,
|
||||||
|
userEmail: item.userEmail,
|
||||||
|
userDepartment: item.userDepartment,
|
||||||
|
thumbnailUrl: videoUrl,
|
||||||
|
originalUrl: videoUrl,
|
||||||
|
videoUrl,
|
||||||
|
imageSize: resolution,
|
||||||
|
prompt,
|
||||||
|
modelId,
|
||||||
|
isFeatured: false,
|
||||||
|
featuredCategory: null,
|
||||||
|
featuredAt: null,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
chatId: item.chatId,
|
||||||
|
recordType: "video" as const,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
||||||
|
|
||||||
|
const merged = [...imageRows, ...normalizedVideos].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
);
|
||||||
|
const images = merged.slice(offset, offset + pageSize);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
images,
|
||||||
|
pagination: { page, pageSize, total, totalPages, totalImages, totalVideos }
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) return error.toResponse();
|
console.error("[Admin Images API] Error:", error);
|
||||||
console.error("[Admin Images API] GET failed", error);
|
return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 });
|
||||||
return NextResponse.json({ error: "记录加载失败,请重试" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
|
||||||
import { parseMediaQuery } from "@/lib/admin/media-records";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
await requireAdmin();
|
|
||||||
const q = parseMediaQuery(request.nextUrl.searchParams);
|
|
||||||
const status = request.nextUrl.searchParams.get("status") || "all";
|
|
||||||
const filters = [sql`true`];
|
|
||||||
if (q.search)
|
|
||||||
filters.push(sql`(u.name ilike ${`%${q.search}%`} or u.email ilike ${`%${q.search}%`})`);
|
|
||||||
if (["pending", "running", "failed", "succeeded"].includes(status))
|
|
||||||
filters.push(sql`t.status=${status}`);
|
|
||||||
if (q.startDate) filters.push(sql`t."createdAt">=${q.startDate}::date - interval '8 hours'`);
|
|
||||||
if (q.endDate) filters.push(sql`t."createdAt"<${q.endDate}::date+interval '16 hours'`);
|
|
||||||
const [result] = await db.execute(sql`with filtered as not materialized (
|
|
||||||
select t.id,u.name as "userName",t.status,t."createdAt"+interval '8 hours' as "createdAt",t.error,coalesce(t.request->>'modelId',t.request->>'model') as model,
|
|
||||||
extract(epoch from (t."completedAt"-t."startedAt"))*1000 as "elapsedMs"
|
|
||||||
from "CanvasImageTask" t left join "User" u on u.id=t."userId" where ${sql.join(filters, sql` and `)}
|
|
||||||
) select (select count(*)::int from filtered) as total,
|
|
||||||
coalesce((select jsonb_agg(p) from (select * from filtered order by "createdAt" desc,id desc limit ${q.pageSize} offset ${(q.page - 1) * q.pageSize}) p),'[]'::jsonb) as tasks`);
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ChatSDKError) return error.toResponse();
|
|
||||||
return NextResponse.json({ error: "任务记录加载失败,请重试" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -106,22 +106,6 @@ export async function POST(request: Request) {
|
|||||||
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
||||||
: catalogPresets;
|
: catalogPresets;
|
||||||
|
|
||||||
if (body?.dryRun === true) {
|
|
||||||
const catalogIds = new Set(catalogPresets.map(preset => preset.modelId));
|
|
||||||
const fields = ["displayName", "description", "supportsVision", "supportsImageGen", "isHidden", "sortOrder", "capabilities", "defaultParams", "imageGenConfig"] as const;
|
|
||||||
const added = presets.filter(preset => !existingModelIds.has(preset.modelId)).map(preset => ({ modelId: preset.modelId, displayName: preset.displayName }));
|
|
||||||
const updated = presets.flatMap(preset => {
|
|
||||||
const existing = existingModels.find(model => model.modelId === preset.modelId);
|
|
||||||
if (!existing) return [];
|
|
||||||
const changedFields: string[] = fields.filter(field => preset[field] !== undefined && JSON.stringify(existing[field]) !== JSON.stringify(preset[field]));
|
|
||||||
if (existing.avatarUrl) changedFields.push("avatarUrl");
|
|
||||||
if (requestedModelIds !== null && !existing.isEnabled) changedFields.push("isEnabled");
|
|
||||||
return changedFields.length ? [{ modelId: preset.modelId, displayName: existing.displayName, changedFields }] : [];
|
|
||||||
});
|
|
||||||
const disabled = requestedModelIds === null ? existingModels.filter(model => !catalogIds.has(model.modelId) && (model.isEnabled || !model.isHidden)).map(model => ({ modelId: model.modelId, displayName: model.displayName })) : [];
|
|
||||||
return NextResponse.json({ preview: { added, updated, disabled }, total: catalogPresets.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
const addedModels: AiModel[] = [];
|
const addedModels: AiModel[] = [];
|
||||||
const updatedModels: AiModel[] = [];
|
const updatedModels: AiModel[] = [];
|
||||||
const disabledModels: AiModel[] = [];
|
const disabledModels: AiModel[] = [];
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { canvasProject } from "@/lib/db/schema";
|
import { canvasProject } from "@/lib/db/schema";
|
||||||
@ -57,20 +56,14 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const requestedId = body.id === undefined ? undefined : z.string().uuid().safeParse(body.id);
|
|
||||||
if (requestedId && !requestedId.success) {
|
|
||||||
return NextResponse.json({ success: false, error: "项目编号无效" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const id = requestedId?.data;
|
|
||||||
const title = String(body.title || "").trim() || "未命名生图项目";
|
const title = String(body.title || "").trim() || "未命名生图项目";
|
||||||
const description = String(body.description || "").trim() || null;
|
const description = String(body.description || "").trim() || null;
|
||||||
const thumbnail = String(body.thumbnail || "").trim() || null;
|
const thumbnail = String(body.thumbnail || "").trim() || null;
|
||||||
const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState);
|
const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState);
|
||||||
|
|
||||||
const [insertedProject] = await db
|
const [project] = await db
|
||||||
.insert(canvasProject)
|
.insert(canvasProject)
|
||||||
.values({
|
.values({
|
||||||
id,
|
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@ -85,22 +78,8 @@ export async function POST(request: NextRequest) {
|
|||||||
agentProjectState: projectState,
|
agentProjectState: projectState,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
})
|
||||||
.onConflictDoNothing({ target: canvasProject.id })
|
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const project = insertedProject || (id ? (await db
|
|
||||||
.select()
|
|
||||||
.from(canvasProject)
|
|
||||||
.where(and(
|
|
||||||
eq(canvasProject.id, id),
|
|
||||||
eq(canvasProject.userId, session.user.id),
|
|
||||||
eq(canvasProject.workspaceType, "agent")
|
|
||||||
))
|
|
||||||
.limit(1))[0] : undefined);
|
|
||||||
if (!project) {
|
|
||||||
return NextResponse.json({ success: false, error: "项目编号不可用,请重试" }, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
|
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AgentProjects API] 创建项目失败:", error);
|
console.error("[AgentProjects API] 创建项目失败:", error);
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export function GET() {
|
|
||||||
return NextResponse.json({ version: process.env.NEXT_PUBLIC_APP_RELEASE || "development" }, {
|
|
||||||
headers: { "Cache-Control": "no-store, max-age=0" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
||||||
|
|
||||||
@ -13,6 +12,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
|||||||
"tripo3d.ai",
|
"tripo3d.ai",
|
||||||
"tripo3d.com",
|
"tripo3d.com",
|
||||||
"worldlabs.ai",
|
"worldlabs.ai",
|
||||||
|
"s3.1080p.hk",
|
||||||
"img.yydd.ai",
|
"img.yydd.ai",
|
||||||
"scqny.jiansun.vip",
|
"scqny.jiansun.vip",
|
||||||
"blob.vercel-storage.com",
|
"blob.vercel-storage.com",
|
||||||
@ -25,6 +25,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
|||||||
|
|
||||||
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
|
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
|
||||||
"bbyy.ai",
|
"bbyy.ai",
|
||||||
|
"s3.1080p.hk",
|
||||||
"img.yydd.ai",
|
"img.yydd.ai",
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -164,9 +165,6 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
|
|||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const rawUrl = String(searchParams.get("url") || "").trim();
|
const rawUrl = String(searchParams.get("url") || "").trim();
|
||||||
if (isRetiredStorageUrl(rawUrl)) {
|
|
||||||
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
|
||||||
}
|
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@ -182,18 +180,6 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
|
|||||||
return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizeLegacyAliyunStorageUrl(targetUrl.toString())) {
|
|
||||||
try {
|
|
||||||
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
|
|
||||||
targetUrl = new URL(await resolveLegacyAliyunStorageUrl(targetUrl.toString()));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message.includes("HTTP 410")) {
|
|
||||||
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAllowedModelHost(targetUrl.hostname)) {
|
if (!isAllowedModelHost(targetUrl.hostname)) {
|
||||||
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
|
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,12 @@ import { NextResponse } from "next/server";
|
|||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
|
|
||||||
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
|
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
|
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
|
||||||
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
|
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
|
||||||
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
|
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
|
||||||
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
|
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
|
||||||
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
|
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
|
||||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const maxDuration = 600;
|
export const maxDuration = 600;
|
||||||
@ -41,8 +39,7 @@ export async function GET(
|
|||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
return NextResponse.json({ success: true, task });
|
||||||
return NextResponse.json({ success: true, task: { ...task, failure, error: failure ? formatRequestFailure(failure, task.id) : task.error } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
@ -54,11 +51,9 @@ export async function POST(
|
|||||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let requestId: string | undefined;
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
|
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
|
||||||
requestId = command.requestId;
|
|
||||||
const result = await executeCanvasCapabilityRuntime({
|
const result = await executeCanvasCapabilityRuntime({
|
||||||
command,
|
command,
|
||||||
projectId: id,
|
projectId: id,
|
||||||
@ -72,9 +67,8 @@ export async function POST(
|
|||||||
error instanceof CanvasCapabilityRuntimeError ||
|
error instanceof CanvasCapabilityRuntimeError ||
|
||||||
error instanceof CanvasAgentMutationError
|
error instanceof CanvasAgentMutationError
|
||||||
) {
|
) {
|
||||||
const failure = getRequestFailureDetails(error);
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: formatRequestFailure(failure, requestId), code: error.code, failure, requestId },
|
{ success: false, error: error.message, code: error.code },
|
||||||
{ status: error.status }
|
{ status: error.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -88,10 +82,10 @@ export async function POST(
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const failure = getRequestFailureDetails(error, "执行画布能力失败");
|
const message = error instanceof Error ? error.message : "执行画布能力失败";
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: formatRequestFailure(failure, requestId), failure, requestId },
|
{ success: false, error: message },
|
||||||
{ status: error instanceof RmbgServiceError ? error.status : 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,65 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { canvasProject, imageArchiveTask, generatedImage, canvasImageTask } from "@/lib/db/schema";
|
|
||||||
import { imageArchiveSourceKey } from "@/lib/storage/image-archive-db";
|
|
||||||
import { isGeneratedImageSource } from "@/lib/storage/image-archive-urls";
|
|
||||||
import { scheduleImageArchiveWorker } from "@/lib/storage/image-archive-worker";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
const { id } = await params;
|
|
||||||
const [project] = await db
|
|
||||||
.select({ id: canvasProject.id })
|
|
||||||
.from(canvasProject)
|
|
||||||
.where(and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id)))
|
|
||||||
.limit(1);
|
|
||||||
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
|
|
||||||
const body = await request.json();
|
|
||||||
if (
|
|
||||||
!Array.isArray(body.urls) ||
|
|
||||||
body.urls.length > 100 ||
|
|
||||||
body.urls.some((url: unknown) => typeof url !== "string" || !isGeneratedImageSource(url))
|
|
||||||
) {
|
|
||||||
return NextResponse.json({ error: "图片地址格式错误" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const urls: string[] = [...new Set<string>(body.urls)];
|
|
||||||
if (!urls.length) return NextResponse.json({ replacements: {}, pending: [] });
|
|
||||||
const source = imageArchiveTask.sourceUrl;
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
sourceUrl: imageArchiveTask.sourceUrl,
|
|
||||||
targetUrl: imageArchiveTask.targetUrl,
|
|
||||||
status: imageArchiveTask.status,
|
|
||||||
})
|
|
||||||
.from(imageArchiveTask)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(imageArchiveTask.sourceUrl, urls.map(imageArchiveSourceKey)),
|
|
||||||
sql`(
|
|
||||||
exists (select 1 from ${generatedImage} where ${generatedImage.userId} = ${session.user.id}
|
|
||||||
and (${generatedImage.originalUrl} = ${source} or ${generatedImage.thumbnailUrl} = ${source}
|
|
||||||
or ${generatedImage.originalUrl} = ${imageArchiveTask.targetUrl} or ${generatedImage.thumbnailUrl} = ${imageArchiveTask.targetUrl}))
|
|
||||||
or exists (select 1 from ${canvasImageTask} where ${canvasImageTask.userId} = ${session.user.id}
|
|
||||||
and (position(${source} in ${canvasImageTask.result}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasImageTask.result}::text) > 0))
|
|
||||||
or exists (select 1 from ${canvasProject} where ${canvasProject.id} = ${id} and ${canvasProject.userId} = ${session.user.id}
|
|
||||||
and (position(${source} in ${canvasProject.canvasData}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasProject.canvasData}::text) > 0))
|
|
||||||
)`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const byKey = new Map(rows.map((row) => [row.sourceUrl, row]));
|
|
||||||
const replacements: Record<string, string> = {};
|
|
||||||
const pending: string[] = [];
|
|
||||||
for (const url of urls) {
|
|
||||||
const row = byKey.get(imageArchiveSourceKey(url));
|
|
||||||
if (row?.targetUrl) replacements[url] = row.targetUrl;
|
|
||||||
else if (row && row.status !== "abandoned") pending.push(url);
|
|
||||||
}
|
|
||||||
if (rows.some((row) => row.status === "pending")) scheduleImageArchiveWorker(true);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ replacements, pending },
|
|
||||||
{ headers: { "Cache-Control": "private, no-store" } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -3,7 +3,6 @@ import { auth } from '@/app/(auth)/auth';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { canvasProject } from '@/lib/db/schema';
|
import { canvasProject } from '@/lib/db/schema';
|
||||||
import { syncCanvasNodesForProject } from '@/lib/db/queries';
|
import { syncCanvasNodesForProject } from '@/lib/db/queries';
|
||||||
import { resolveArchivedImageUrls } from '@/lib/storage/image-archive-db';
|
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
buildStaleCanvasSaveConflictPayload,
|
buildStaleCanvasSaveConflictPayload,
|
||||||
@ -123,8 +122,7 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description, canvasData } = body;
|
const { title, description, thumbnail, canvasData } = body;
|
||||||
const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
|
|
||||||
const baseCanvasRevision =
|
const baseCanvasRevision =
|
||||||
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
|
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
|
||||||
const workspaceType =
|
const workspaceType =
|
||||||
@ -207,11 +205,7 @@ export async function PUT(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
return NextResponse.json({
|
return NextResponse.json({ success: true, data: saved });
|
||||||
success: true,
|
|
||||||
data: saved,
|
|
||||||
previousCanvasRevision: existingProject?.updatedAt.toISOString() ?? null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("更新/创建画布失败:", error);
|
console.error("更新/创建画布失败:", error);
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
import { PROJECT_AGENT_OPERATIONS, projectOperationAuthorized } from "@/lib/ai/canvas-agent-runtime/project-operation-catalog";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { canvasProject } from "@/lib/db/schema";
|
|
||||||
import { getSystemSetting } from "@/lib/db/queries";
|
|
||||||
import {
|
|
||||||
normalizeVideoCanvasFunctionConfig,
|
|
||||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
|
||||||
} from "@/lib/video-canvas/config";
|
|
||||||
import { getCanvasAgentAccessManifest } from "@/lib/ai/canvas-agent-runtime/capability-access";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
const parsed = z.string().uuid().safeParse(new URL(request.url).searchParams.get("projectId"));
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: "画布编号无效" }, { status: 400 });
|
|
||||||
const [project] = await db
|
|
||||||
.select({ id: canvasProject.id, workspace: canvasProject.workspaceType })
|
|
||||||
.from(canvasProject)
|
|
||||||
.where(and(eq(canvasProject.id, parsed.data), eq(canvasProject.userId, session.user.id)))
|
|
||||||
.limit(1);
|
|
||||||
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
|
|
||||||
const config = normalizeVideoCanvasFunctionConfig(
|
|
||||||
(await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }))?.value,
|
|
||||||
);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
workspace: project.workspace,
|
|
||||||
projectServices: PROJECT_AGENT_OPERATIONS.map(item => ({ ...item, authorized: projectOperationAuthorized(item) })),
|
|
||||||
utilityTools: ["getWeather", "createDocument", "updateDocument", "requestSuggestions", "loadProjectSkill"],
|
|
||||||
capabilities: getCanvasAgentAccessManifest(config).filter(
|
|
||||||
(item) => item.workspace === (project.workspace === "video" ? "video" : "canvas"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ headers: { "Cache-Control": "private, no-store" } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,491 +0,0 @@
|
|||||||
import { agentModelCandidates } from "@/lib/ai/runtime/agent-model-failover";
|
|
||||||
import { agentModelCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
|
|
||||||
import { createAgentVisionExecutor } from "@/lib/ai/runtime/agent-vision-failover";
|
|
||||||
import { canvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
|
|
||||||
import { getStyleProImageModelEntries } from "@/lib/style-pro/model-options";
|
|
||||||
import { registerProjectAgentTools } from "@/lib/ai/canvas-agent-runtime/project-tools";
|
|
||||||
import { isZcodeWorkspaceAuthorized } from "@/lib/ai/canvas-agent-runtime/authorization";
|
|
||||||
import {
|
|
||||||
createUIMessageStream,
|
|
||||||
createUIMessageStreamResponse,
|
|
||||||
generateText,
|
|
||||||
} from "ai";
|
|
||||||
import { after, NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { siteSettings, canvasProject } from "@/lib/db/schema";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { postRequestBodySchema } from "@/app/(chat)/api/chat/schema";
|
|
||||||
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
|
||||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
|
||||||
import {
|
|
||||||
getAgentAiModelWithProvider,
|
|
||||||
getAccessibleAgentPresetById,
|
|
||||||
getEnabledAiModelsWithProvider,
|
|
||||||
getChatById,
|
|
||||||
getMessagesByChatId,
|
|
||||||
getMessageCountByUserId,
|
|
||||||
getSiteSettings,
|
|
||||||
saveChat,
|
|
||||||
saveMessages,
|
|
||||||
} from "@/lib/db/queries";
|
|
||||||
import { runZcodeCanvasTask } from "@/lib/ai/zcode-agent/task";
|
|
||||||
import { canvasAgentReplyText } from "@/lib/ai/zcode-agent/assistant-output";
|
|
||||||
import { createZcodeModelGateway } from "@/lib/ai/zcode-agent/model-gateway";
|
|
||||||
import { newAgentRunSnapshot, publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
|
|
||||||
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
|
|
||||||
import {
|
|
||||||
acquireAgentRun,
|
|
||||||
agentRunView,
|
|
||||||
AgentRunError,
|
|
||||||
checkpointAgentRun,
|
|
||||||
consumeAgentRunInputs,
|
|
||||||
heartbeatAgentRun,
|
|
||||||
} from "@/lib/ai/canvas-agent-runtime/store";
|
|
||||||
import { readOwnedCanvas } from "@/lib/ai/canvas-agent-runtime/canvas";
|
|
||||||
import { buildCanvasRuntimeTools } from "@/lib/ai/canvas-agent-runtime/tools";
|
|
||||||
import { canvasRuntimeSystemPrompt } from "@/lib/ai/canvas-agent-runtime/prompt";
|
|
||||||
import { verifyCanvasAgentResults } from "@/lib/ai/canvas-agent-runtime/review";
|
|
||||||
import { inferCanvasTaskRequirements } from "@/lib/ai/canvas-agent-runtime/requirements";
|
|
||||||
import {
|
|
||||||
resolveRuntimeConversationSources,
|
|
||||||
persistRuntimeConversationResults,
|
|
||||||
} from "@/lib/ai/canvas-agent-runtime/conversation";
|
|
||||||
import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
|
|
||||||
import { canApplyCanvasAgentMutations } from "@/lib/canvas/agent-feature-flags";
|
|
||||||
|
|
||||||
import {
|
|
||||||
videoAgentSettingsSchema,
|
|
||||||
inferVideoTaskRequirements,
|
|
||||||
videoNodeUrl,
|
|
||||||
videoRuntimeSystemPrompt,
|
|
||||||
} from "@/lib/ai/canvas-agent-runtime/video-protocol";
|
|
||||||
import { readOwnedVideoCanvas } from "@/lib/ai/canvas-agent-runtime/video-store";
|
|
||||||
import {
|
|
||||||
buildVideoRuntimeTools,
|
|
||||||
createVideoRuntimePorts,
|
|
||||||
} from "@/lib/ai/canvas-agent-runtime/video-tools";
|
|
||||||
import { probeVideoMetadata } from "@/lib/ai/canvas-agent-runtime/video-probe";
|
|
||||||
import { verifyVideoAgentResults } from "@/lib/ai/canvas-agent-runtime/video-review";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const maxDuration = 1200;
|
|
||||||
const requestSchema = postRequestBodySchema.extend({
|
|
||||||
resumeRunId: z.string().uuid().optional(),
|
|
||||||
selectedImageModel: z.string().min(1).max(180).optional(),
|
|
||||||
workspaceVariant: z.enum(["canvas", "video"]).default("canvas"),
|
|
||||||
videoAgentSettings: videoAgentSettingsSchema.optional(),
|
|
||||||
});
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
try {
|
|
||||||
const body = requestSchema.parse(await request.json());
|
|
||||||
if (!shouldUseCanvasAgentRuntime(body))
|
|
||||||
throw new AgentRunError("此请求不属于画布 Agent 任务", 400);
|
|
||||||
const projectId = body.contextPayload!.projectId!;
|
|
||||||
const [ownedProject] = await db.select({ userId: canvasProject.userId, workspaceType: canvasProject.workspaceType })
|
|
||||||
.from(canvasProject).where(eq(canvasProject.id, projectId)).limit(1);
|
|
||||||
if (!ownedProject || ownedProject.userId !== session.user.id) throw new AgentRunError("画布不存在", 404);
|
|
||||||
if (!isZcodeWorkspaceAuthorized(ownedProject.workspaceType)) throw new AgentRunError("视频画布暂未授权给 ZCode Agent,请使用手工功能。", 403);
|
|
||||||
const isVideo = ownedProject.workspaceType === "video";
|
|
||||||
const preset = body.agentPresetId ? await getAccessibleAgentPresetById({ id: body.agentPresetId, userId: session.user.id }) : null;
|
|
||||||
if (preset && String(preset.type) === "video") throw new AgentRunError("视频技能暂未授权给 ZCode Agent", 403);
|
|
||||||
if (body.agentPresetId && (!preset || preset.status !== "active")) throw new AgentRunError("此 Agent 预设不可用", 403);
|
|
||||||
const objective = body.message.parts
|
|
||||||
.filter((part) => part.type === "text")
|
|
||||||
.map((part) => part.text)
|
|
||||||
.join("\n")
|
|
||||||
.trim();
|
|
||||||
if (!objective) throw new AgentRunError("请描述需要完成的创作任务", 400);
|
|
||||||
const settings = await getSiteSettings();
|
|
||||||
if (
|
|
||||||
!settings.agentEnabled ||
|
|
||||||
!canApplyCanvasAgentMutations(await getCanvasAgentFeatureFlagsFromSystemSettings())
|
|
||||||
) {
|
|
||||||
throw new AgentRunError("管理员尚未开启画布 Agent 操作", 403);
|
|
||||||
}
|
|
||||||
const [usageSettings] = await db.select().from(siteSettings).limit(1);
|
|
||||||
if (usageSettings?.usageTimeEnabled) {
|
|
||||||
const time = new Intl.DateTimeFormat("en-GB", {
|
|
||||||
timeZone: "Asia/Shanghai",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hourCycle: "h23",
|
|
||||||
}).format(new Date());
|
|
||||||
if (
|
|
||||||
time < (usageSettings.usageTimeStart || "08:00") ||
|
|
||||||
time > (usageSettings.usageTimeEnd || "20:00")
|
|
||||||
)
|
|
||||||
throw new AgentRunError(usageSettings.usageTimeMessage || "当前不在系统使用时间段内", 403);
|
|
||||||
}
|
|
||||||
const count = await getMessageCountByUserId({ id: session.user.id, differenceInHours: 24 });
|
|
||||||
if (count > entitlementsByUserType[session.user.type].maxMessagesPerDay)
|
|
||||||
throw new AgentRunError("今日对话次数已达到限额", 429);
|
|
||||||
const imageCanvas = isVideo ? null : await readOwnedCanvas(session.user.id, projectId);
|
|
||||||
if (imageCanvas?.project.workspaceType === "video")
|
|
||||||
throw new AgentRunError("请使用视频画布 Agent 入口", 400);
|
|
||||||
const videoCanvas = isVideo ? await readOwnedVideoCanvas(session.user.id, projectId) : null;
|
|
||||||
const nodes = imageCanvas?.nodes || [];
|
|
||||||
const availableIds = isVideo
|
|
||||||
? videoCanvas!.nodes.map((node) => node.id)
|
|
||||||
: nodes.map((node) => node.id);
|
|
||||||
const selectedNodeIds = body.contextPayload?.selectedNodeIds || [];
|
|
||||||
if (selectedNodeIds.some((id) => !availableIds.includes(id))) {
|
|
||||||
throw new AgentRunError("选中图片尚未保存到画布,请等待保存完成后重试。");
|
|
||||||
}
|
|
||||||
const existingChat = await getChatById({ id: body.id });
|
|
||||||
if (existingChat && existingChat.userId !== session.user.id)
|
|
||||||
throw new AgentRunError("对话不存在", 404);
|
|
||||||
if (!existingChat)
|
|
||||||
await saveChat({
|
|
||||||
id: body.id,
|
|
||||||
userId: session.user.id,
|
|
||||||
title: objective.slice(0, 80),
|
|
||||||
visibility: "private",
|
|
||||||
});
|
|
||||||
const conversation = isVideo
|
|
||||||
? {
|
|
||||||
sourceNodeIds: selectedNodeIds,
|
|
||||||
targetReason: "video-selected-nodes",
|
|
||||||
previousContext: null,
|
|
||||||
}
|
|
||||||
: await resolveRuntimeConversationSources({
|
|
||||||
userId: session.user.id,
|
|
||||||
chatId: body.id,
|
|
||||||
projectId,
|
|
||||||
text: objective,
|
|
||||||
parts: body.message.parts,
|
|
||||||
nodes,
|
|
||||||
selectedNodeIds,
|
|
||||||
});
|
|
||||||
const candidates = agentModelCandidates(await getAgentAiModelWithProvider(), await getEnabledAiModelsWithProvider());
|
|
||||||
const configured = candidates[0];
|
|
||||||
if (!configured) throw new AgentRunError("请先在后台配置可用的多模态 Agent 对话模型", 503);
|
|
||||||
let model = await getLanguageModelById(configured.model.id);
|
|
||||||
for (const candidate of candidates.slice(1)) {
|
|
||||||
if (model) break;
|
|
||||||
model = await getLanguageModelById(candidate.model.id);
|
|
||||||
}
|
|
||||||
if (!model) throw new AgentRunError("Agent 模型暂不可用", 503);
|
|
||||||
const visionModel = model;
|
|
||||||
const executeVisionModel = createAgentVisionExecutor(candidates, agentModelCircuitStore, getLanguageModelById);
|
|
||||||
// Validate the user's image choice independently of the Agent planning model.
|
|
||||||
if (body.selectedImageModel) {
|
|
||||||
const images = await getStyleProImageModelEntries();
|
|
||||||
if (!images.some(({ model }) => model.id === body.selectedImageModel && model.healthStatus !== "auto_disabled"))
|
|
||||||
throw new AgentRunError("你选择的生图模型当前不可用,请重新选择;不会自动换模型生成。", 400);
|
|
||||||
}
|
|
||||||
const assistantMessageId = crypto.randomUUID();
|
|
||||||
const previousVideoMessages =
|
|
||||||
existingChat
|
|
||||||
? (await getMessagesByChatId({ id: body.id })).slice(-6).map((message) => ({
|
|
||||||
role: message.role as "user" | "assistant",
|
|
||||||
content: (message.parts as Array<{ type: string; text?: string }>)
|
|
||||||
.filter((part) => part.type === "text")
|
|
||||||
.map((part) => part.text || "")
|
|
||||||
.join("\n")
|
|
||||||
.slice(0, 4000),
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
const initialSnapshot = newAgentRunSnapshot(objective, [
|
|
||||||
...previousVideoMessages,
|
|
||||||
{ role: "user", content: objective },
|
|
||||||
]);
|
|
||||||
initialSnapshot.workspace = isVideo ? "video" : "canvas";
|
|
||||||
if (isVideo) {
|
|
||||||
initialSnapshot.videoRequirements = inferVideoTaskRequirements(objective);
|
|
||||||
initialSnapshot.videoSettings = videoAgentSettingsSchema.parse({
|
|
||||||
...body.videoAgentSettings,
|
|
||||||
...(initialSnapshot.videoRequirements.duration
|
|
||||||
? { duration: initialSnapshot.videoRequirements.duration }
|
|
||||||
: {}),
|
|
||||||
...(initialSnapshot.videoRequirements.ratio
|
|
||||||
? { ratio: initialSnapshot.videoRequirements.ratio }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
initialSnapshot.sourceNodeIds = conversation.sourceNodeIds;
|
|
||||||
initialSnapshot.requirements = {
|
|
||||||
...inferCanvasTaskRequirements(objective, initialSnapshot.sourceNodeIds.length),
|
|
||||||
sourceCount: initialSnapshot.sourceNodeIds.length,
|
|
||||||
};
|
|
||||||
const run = await acquireAgentRun({
|
|
||||||
id: body.message.id,
|
|
||||||
userId: session.user.id,
|
|
||||||
projectId,
|
|
||||||
chatId: body.id,
|
|
||||||
assistantMessageId,
|
|
||||||
resumeRunId: body.resumeRunId,
|
|
||||||
snapshot: initialSnapshot,
|
|
||||||
});
|
|
||||||
const snapshot = run.snapshot;
|
|
||||||
snapshot.imagePreferences = {
|
|
||||||
...snapshot.imagePreferences,
|
|
||||||
// Each submitted turn captures the current controls, including switching back to Auto.
|
|
||||||
modelId: body.selectedImageModel,
|
|
||||||
parameters: body.imageParams || snapshot.imagePreferences?.parameters,
|
|
||||||
};
|
|
||||||
const taskSourceNodeIds = snapshot.sourceNodeIds ?? selectedNodeIds;
|
|
||||||
const context = {
|
|
||||||
recentConversation: previousVideoMessages,
|
|
||||||
preset: preset ? { name: preset.name, type: preset.type, promptTemplate: preset.promptTemplate, defaultModelId: preset.defaultModelId, defaultParams: preset.defaultParams, slots: preset.slots.map(slot => ({ title: slot.title, promptTemplate: slot.promptTemplate, defaultParams: slot.defaultParams })) } : null,
|
|
||||||
videoSettings: snapshot.videoSettings,
|
|
||||||
selectedNodeIds: taskSourceNodeIds,
|
|
||||||
nodes: nodes
|
|
||||||
.filter((node) => taskSourceNodeIds.includes(node.id))
|
|
||||||
.map((node) => ({
|
|
||||||
id: node.id,
|
|
||||||
type: node.type,
|
|
||||||
position: node.position,
|
|
||||||
size: node.size,
|
|
||||||
title: node.data.title,
|
|
||||||
})),
|
|
||||||
skillInstructions: body.hiddenPrompt || undefined,
|
|
||||||
imagePreferences: snapshot.imagePreferences,
|
|
||||||
targetReason: conversation.targetReason,
|
|
||||||
previousConversationContext: conversation.previousContext,
|
|
||||||
};
|
|
||||||
await saveMessages({
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: body.message.id,
|
|
||||||
chatId: body.id,
|
|
||||||
role: "user",
|
|
||||||
parts: body.message.parts,
|
|
||||||
attachments: [],
|
|
||||||
createdAt: new Date(),
|
|
||||||
videoStorageStatus: null,
|
|
||||||
temporaryVideoUrl: null,
|
|
||||||
permanentVideoUrl: null,
|
|
||||||
archivedAt: null,
|
|
||||||
videoMetadata: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
let archiveVideoResults: (() => Promise<void>) | undefined;
|
|
||||||
const stream = createUIMessageStream({
|
|
||||||
execute: async ({ writer }) => {
|
|
||||||
const emit = (type: string, data: unknown) => writer.write({ type, data, ...(type === "data-canvasAgentRun" ? { transient: true } : {}) } as never);
|
|
||||||
let cancelled = false;
|
|
||||||
let leaseLost = false;
|
|
||||||
const activeModelController = new AbortController();
|
|
||||||
const withModel: typeof executeVisionModel = (execute, signal) => executeVisionModel(execute,
|
|
||||||
signal ? AbortSignal.any([signal, activeModelController.signal]) : activeModelController.signal);
|
|
||||||
const heartbeat = setInterval(() => {
|
|
||||||
void heartbeatAgentRun(run)
|
|
||||||
.then((current) => {
|
|
||||||
if (!current) leaseLost = true;
|
|
||||||
if (current?.cancelRequested) cancelled = true;
|
|
||||||
if (cancelled || leaseLost) activeModelController?.abort();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
leaseLost = true;
|
|
||||||
activeModelController?.abort();
|
|
||||||
});
|
|
||||||
emit("data-keepalive", { label: "canvas-agent-runtime", ts: Date.now() });
|
|
||||||
}, 10_000);
|
|
||||||
const textId = crypto.randomUUID();
|
|
||||||
writer.write({ type: "start", messageId: assistantMessageId });
|
|
||||||
writer.write({ type: "text-start", id: textId });
|
|
||||||
let streamedText = "";
|
|
||||||
const emitAssistantText = (delta: string) => {
|
|
||||||
streamedText += delta;
|
|
||||||
writer.write({ type: "text-delta", id: textId, delta });
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const videoPorts = isVideo
|
|
||||||
? await createVideoRuntimePorts({
|
|
||||||
userId: session.user.id,
|
|
||||||
chatId: body.id,
|
|
||||||
projectId,
|
|
||||||
runId: run.id,
|
|
||||||
assistantMessageId,
|
|
||||||
request,
|
|
||||||
snapshot,
|
|
||||||
emit,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
archiveVideoResults = videoPorts?.archiveResults;
|
|
||||||
const tools = isVideo
|
|
||||||
? buildVideoRuntimeTools({
|
|
||||||
runId: run.id,
|
|
||||||
projectId,
|
|
||||||
selectedNodeIds,
|
|
||||||
snapshot,
|
|
||||||
ports: videoPorts!,
|
|
||||||
})
|
|
||||||
: await buildCanvasRuntimeTools({
|
|
||||||
userId: session.user.id,
|
|
||||||
chatId: body.id,
|
|
||||||
projectId,
|
|
||||||
runId: run.id,
|
|
||||||
assistantMessageId,
|
|
||||||
selectedNodeIds: taskSourceNodeIds,
|
|
||||||
request,
|
|
||||||
model: visionModel || model,
|
|
||||||
supportsVision: Boolean(visionModel),
|
|
||||||
withModel,
|
|
||||||
imageSize: snapshot.imagePreferences?.parameters?.imageSize,
|
|
||||||
imagePreferences: snapshot.imagePreferences,
|
|
||||||
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
|
|
||||||
operationSelectedNodeIds: selectedNodeIds,
|
|
||||||
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(
|
|
||||||
"\n",
|
|
||||||
),
|
|
||||||
emit,
|
|
||||||
});
|
|
||||||
await registerProjectAgentTools({ session, projectId, request, objective: snapshot.objective,
|
|
||||||
definitions: tools.definitions, executable: tools.executable, emit,
|
|
||||||
imagePreferences: snapshot.imagePreferences });
|
|
||||||
const gateway = await createZcodeModelGateway(configured, undefined, candidates);
|
|
||||||
const status = await runZcodeCanvasTask({
|
|
||||||
runId: run.id, snapshot, definitions: tools.definitions,
|
|
||||||
instructions: (isVideo ? videoRuntimeSystemPrompt : canvasRuntimeSystemPrompt)(snapshot.objective, snapshot.summary, snapshot.inputs.map(input => input.text), context),
|
|
||||||
context, gateway, signal: activeModelController.signal,
|
|
||||||
onAssistantText: emitAssistantText,
|
|
||||||
tools: tools.executable,
|
|
||||||
checkpoint: async (state, stateStatus) => {
|
|
||||||
if (leaseLost) throw new AgentRunError("任务执行已中断");
|
|
||||||
const saved = await checkpointAgentRun(run, state, stateStatus);
|
|
||||||
cancelled ||= saved.cancelRequested;
|
|
||||||
emit("data-canvasAgentRun", agentRunView(saved));
|
|
||||||
},
|
|
||||||
consumeInputs: async (state) => {
|
|
||||||
if (leaseLost) throw new AgentRunError("任务执行已中断");
|
|
||||||
const control = await consumeAgentRunInputs(run, state);
|
|
||||||
cancelled ||= control.cancelled;
|
|
||||||
return { cancelled };
|
|
||||||
},
|
|
||||||
isCancelled: async () => {
|
|
||||||
const current = await heartbeatAgentRun(run);
|
|
||||||
if (!current) throw new AgentRunError("任务执行已中断");
|
|
||||||
cancelled ||= current.cancelRequested;
|
|
||||||
return cancelled;
|
|
||||||
},
|
|
||||||
verify: async (state) =>
|
|
||||||
isVideo
|
|
||||||
? verifyVideoAgentResults({
|
|
||||||
snapshot: state,
|
|
||||||
nodes: (await readOwnedVideoCanvas(session.user.id, projectId)).nodes,
|
|
||||||
inspect: async (node, requirements) => {
|
|
||||||
const metadata = await probeVideoMetadata(videoNodeUrl(node));
|
|
||||||
const report = await videoPorts!.analyze({
|
|
||||||
mode: "video-analysis",
|
|
||||||
prompt: `请基于真实抽帧画面检查主体、场景、镜头连贯性,说明不确定内容。用户要求:${requirements}`,
|
|
||||||
imageUrls: [],
|
|
||||||
videoUrls: [videoNodeUrl(node)],
|
|
||||||
});
|
|
||||||
const verdict = await generateText({
|
|
||||||
model,
|
|
||||||
system:
|
|
||||||
'你是视频验收员。依据实际抽帧分析判断是否满足用户要求;不能从文件名或提示词判断。若报告无法读取真实帧,必须不通过。抽帧只能检查画面,不能证明音频、精确时长或完整运动,涉及这些要求无法确认时说明需要人工检查。仅返回 JSON {"passed":boolean,"issues":string[]}。',
|
|
||||||
prompt: JSON.stringify({ requirements, report }),
|
|
||||||
maxOutputTokens: 1200,
|
|
||||||
maxRetries: 0,
|
|
||||||
abortSignal: AbortSignal.timeout(60_000),
|
|
||||||
});
|
|
||||||
state.inputTokens += verdict.usage.inputTokens || 0;
|
|
||||||
state.outputTokens += verdict.usage.outputTokens || 0;
|
|
||||||
const match = verdict.text.match(/\{[\s\S]*\}/);
|
|
||||||
if (!match) throw new Error("视频检查未返回有效结果");
|
|
||||||
return {
|
|
||||||
...z
|
|
||||||
.object({ passed: z.boolean(), issues: z.array(z.string()).max(12) })
|
|
||||||
.parse(JSON.parse(match[0])),
|
|
||||||
...metadata,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: verifyCanvasAgentResults({
|
|
||||||
snapshot: state,
|
|
||||||
userId: session.user.id,
|
|
||||||
projectId,
|
|
||||||
model: visionModel || model,
|
|
||||||
supportsVision: Boolean(visionModel),
|
|
||||||
withModel,
|
|
||||||
signal: activeModelController.signal,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const finalText = canvasAgentReplyText(snapshot);
|
|
||||||
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot, status }));
|
|
||||||
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
|
|
||||||
if (!isVideo)
|
|
||||||
await persistRuntimeConversationResults({
|
|
||||||
userId: session.user.id,
|
|
||||||
chatId: body.id,
|
|
||||||
projectId,
|
|
||||||
assistantMessageId,
|
|
||||||
snapshot,
|
|
||||||
}).catch((error) => {
|
|
||||||
console.warn("[Canvas Agent] Conversation memory update failed", {
|
|
||||||
runId: run.id,
|
|
||||||
error: publicAgentError(error),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await saveMessages({
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: assistantMessageId,
|
|
||||||
chatId: body.id,
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "text", text: finalText }, { type: "data-canvasAgentProgress", data: progress }],
|
|
||||||
attachments: [],
|
|
||||||
createdAt: new Date(),
|
|
||||||
videoStorageStatus: null,
|
|
||||||
temporaryVideoUrl: null,
|
|
||||||
permanentVideoUrl: null,
|
|
||||||
archivedAt: null,
|
|
||||||
videoMetadata: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
emitAssistantText(finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : `\n\n${snapshot.message}`);
|
|
||||||
} catch (error) {
|
|
||||||
const statusText = publicAgentError(error);
|
|
||||||
const text = canvasAgentReplyText({ ...snapshot, message: statusText });
|
|
||||||
await checkpointAgentRun(run, { ...snapshot, message: statusText }, "needs_review").catch(
|
|
||||||
() => undefined,
|
|
||||||
);
|
|
||||||
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot: { ...snapshot, message: statusText }, status: "needs_review" }));
|
|
||||||
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
|
|
||||||
await saveMessages({ messages: [{ id: assistantMessageId, chatId: body.id, role: "assistant",
|
|
||||||
parts: [{ type: "text", text }, { type: "data-canvasAgentProgress", data: progress }],
|
|
||||||
attachments: [], createdAt: new Date(), videoStorageStatus: null, temporaryVideoUrl: null,
|
|
||||||
permanentVideoUrl: null, archivedAt: null, videoMetadata: null }] }).catch(() => undefined);
|
|
||||||
emitAssistantText(text.startsWith(streamedText) ? text.slice(streamedText.length) : `\n\n${statusText}`);
|
|
||||||
} finally {
|
|
||||||
clearInterval(heartbeat);
|
|
||||||
writer.write({ type: "text-end", id: textId });
|
|
||||||
writer.write({ type: "finish" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Browser disconnection does not cancel paid image work. Keep consuming on the server.
|
|
||||||
const [client, background] = stream.tee();
|
|
||||||
after(async () => {
|
|
||||||
const reader = background.getReader();
|
|
||||||
try {
|
|
||||||
while (!(await reader.read()).done) {
|
|
||||||
/* Drain the server branch. */
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
await archiveVideoResults?.();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return createUIMessageStreamResponse({
|
|
||||||
stream: client,
|
|
||||||
headers: { "Cache-Control": "no-store", "X-Accel-Buffering": "no" },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: publicAgentError(error), message: publicAgentError(error) },
|
|
||||||
{
|
|
||||||
status:
|
|
||||||
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import {
|
|
||||||
AgentRunError,
|
|
||||||
agentRunView,
|
|
||||||
assertAgentRunOwnership,
|
|
||||||
controlAgentRun,
|
|
||||||
getAgentRun,
|
|
||||||
} from "@/lib/ai/canvas-agent-runtime/store";
|
|
||||||
import { publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
const scope = z.object({
|
|
||||||
chatId: z.string().uuid(),
|
|
||||||
projectId: z.string().uuid(),
|
|
||||||
runId: z.string().uuid().optional(),
|
|
||||||
});
|
|
||||||
const control = scope
|
|
||||||
.extend({
|
|
||||||
runId: z.string().uuid(),
|
|
||||||
action: z.enum(["steer", "cancel"]),
|
|
||||||
inputId: z.string().uuid().optional(),
|
|
||||||
text: z.string().trim().min(1).max(2000).optional(),
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(input) => input.action !== "steer" || Boolean(input.inputId && input.text),
|
|
||||||
"补充要求缺少内容或请求号",
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
try {
|
|
||||||
const input = scope.parse(Object.fromEntries(new URL(request.url).searchParams));
|
|
||||||
await assertAgentRunOwnership(session.user.id, input.projectId, input.chatId);
|
|
||||||
const run = await getAgentRun(session.user.id, input.chatId, input.projectId, input.runId);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ run: run ? agentRunView(run) : null },
|
|
||||||
{ headers: { "Cache-Control": "private, no-store" } },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return failure(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
try {
|
|
||||||
const input = control.parse(await request.json());
|
|
||||||
await assertAgentRunOwnership(session.user.id, input.projectId, input.chatId);
|
|
||||||
return NextResponse.json({ run: await controlAgentRun({ ...input, userId: session.user.id }) });
|
|
||||||
} catch (error) {
|
|
||||||
return failure(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function failure(error: unknown) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: publicAgentError(error) },
|
|
||||||
{
|
|
||||||
status:
|
|
||||||
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,15 +1,100 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout";
|
import {
|
||||||
|
BoxCutoutServiceError,
|
||||||
|
executeBoxCutoutJob,
|
||||||
|
} from "@/lib/ai/box-cutout/service";
|
||||||
|
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
|
||||||
|
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||||
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
|
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
type BoxCutoutRequest = {
|
||||||
|
imageUrl?: string;
|
||||||
|
cropRect?: {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
paddingRatio?: number;
|
||||||
|
prompt?: string;
|
||||||
|
chatId?: string | null;
|
||||||
|
messageId?: string | null;
|
||||||
|
skipGallerySave?: boolean;
|
||||||
|
sourceNodeId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
|
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
|
||||||
}
|
}
|
||||||
return executeBoxCutoutPOST(request, session.user.id);
|
|
||||||
|
const body = (await request.json()) as BoxCutoutRequest;
|
||||||
|
const rawImageUrl = String(body.imageUrl || "").trim();
|
||||||
|
if (!rawImageUrl) {
|
||||||
|
return NextResponse.json({ success: false, error: "缺少 imageUrl 参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
|
||||||
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
|
userId: session.user.id,
|
||||||
|
unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"),
|
||||||
|
reason: "image_box_cutout",
|
||||||
|
modelId: "box-cutout",
|
||||||
|
note: "框选抠图消耗积分",
|
||||||
|
pointsConfig,
|
||||||
|
});
|
||||||
|
if (!pointsCharge.ok) {
|
||||||
|
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeBoxCutoutJob({
|
||||||
|
userId: session.user.id,
|
||||||
|
imageUrl: resolvedImageUrl,
|
||||||
|
cropRect: body.cropRect || {},
|
||||||
|
paddingRatio: body.paddingRatio,
|
||||||
|
prompt: body.prompt || "框选抠图",
|
||||||
|
chatId: body.chatId || null,
|
||||||
|
messageId: body.messageId || null,
|
||||||
|
skipGallerySave: body.skipGallerySave === true,
|
||||||
|
sourceNodeId: body.sourceNodeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pointsCharge.chargeable) {
|
||||||
|
try {
|
||||||
|
await pointsCharge.consume();
|
||||||
|
} catch (pointsError) {
|
||||||
|
console.error("[Points] Failed to consume box cutout points:", pointsError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.detail || error.message,
|
||||||
|
},
|
||||||
|
{ status: error.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[BoxCutout] 处理失败:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "框选抠图失败",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,5 @@
|
|||||||
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
|
||||||
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
|
||||||
import { after, NextRequest, NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
|
|
||||||
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
|
||||||
import {
|
import {
|
||||||
buildAttachmentContentDisposition,
|
buildAttachmentContentDisposition,
|
||||||
fetchCanvasDownloadAsset,
|
fetchCanvasDownloadAsset,
|
||||||
@ -16,50 +8,6 @@ import {
|
|||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 120;
|
||||||
const logger = createLogger("canvas.download");
|
|
||||||
|
|
||||||
function failed(failure: DownloadFailure, requestId: string) {
|
|
||||||
logger.warn("request_failed", { requestId, code: failure.code, status: failure.status });
|
|
||||||
return NextResponse.json({ success: false, error: formatDownloadFailure(failure, requestId), failure, requestId }, {
|
|
||||||
status: failure.status,
|
|
||||||
headers: { "Cache-Control": "no-store", ...(failure.status === 429 ? { "Retry-After": "3" } : {}) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function preparing(requestId: string) {
|
|
||||||
return NextResponse.json({ delivery: "preparing", retryAfter: 2, requestId,
|
|
||||||
message: "正在准备原图,完成后将自动开始下载" }, {
|
|
||||||
status: 202, headers: { "Retry-After": "2", "Cache-Control": "no-store" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
|
|
||||||
// A proxy candidate for the same original must not repeat the same slow
|
|
||||||
// upstream request through our own server (and create another HTTP timeout).
|
|
||||||
const sources = [...new Set(input.urls.map(url => {
|
|
||||||
const source = extractImageProxySourceUrl(url) || url;
|
|
||||||
return inferOriginalImageUrl(source) || source;
|
|
||||||
}))];
|
|
||||||
let lastError: unknown;
|
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
|
||||||
signal.throwIfAborted();
|
|
||||||
for (const url of sources) {
|
|
||||||
signal.throwIfAborted();
|
|
||||||
const result = await fetchCanvasDownloadAsset({
|
|
||||||
file: { url, name: "original" }, index: 0,
|
|
||||||
requestOrigin: input.requestOrigin, cookie: input.cookie,
|
|
||||||
includeSequencePrefix: false, timeoutMs: 60_000, retryCount: 1, signal,
|
|
||||||
});
|
|
||||||
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
|
|
||||||
lastError = result.error;
|
|
||||||
}
|
|
||||||
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
|
|
||||||
// No AI/model request is involved and a completed source fetch is shared.
|
|
||||||
if (attempt < 1 && getDownloadFailure(lastError).retryable) await new Promise(resolve => setTimeout(resolve, 1_000));
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
throw new Error(String(lastError || "Original image sources unavailable"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRequestOrigin(request: NextRequest): string {
|
function getRequestOrigin(request: NextRequest): string {
|
||||||
const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim();
|
const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim();
|
||||||
@ -98,63 +46,21 @@ function resolveDownloadUrls(body: Record<string, unknown>, origin: string): str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const requestId = randomUUID();
|
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return failed(getDownloadFailure("未登录"), requestId);
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
const requestOrigin = getRequestOrigin(request);
|
const requestOrigin = getRequestOrigin(request);
|
||||||
const candidates = resolveDownloadUrls(body, requestOrigin);
|
const urls = resolveDownloadUrls(body, requestOrigin);
|
||||||
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
|
|
||||||
return failed(getDownloadFailure(RETIRED_STORAGE_MESSAGE), requestId);
|
|
||||||
}
|
|
||||||
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
|
|
||||||
const requestedUrls = [...urls];
|
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
return failed(getDownloadFailure("图片地址无效"), requestId);
|
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
||||||
if (body.preferDirect === true) {
|
|
||||||
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch((error) => {
|
|
||||||
logger.warn("cdn_lookup_failed", error, { requestId });
|
|
||||||
return { task: null, direct: null, canPrepareDirect: false };
|
|
||||||
});
|
|
||||||
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
|
|
||||||
if (task && canPrepareDirect && body.preparation === "async") {
|
|
||||||
const asset = (task.metadata as { downloadAsset?: { status?: string; failure?: DownloadFailure; retryAt?: string } } | null)?.downloadAsset;
|
|
||||||
if (asset?.status === "abandoned" || (asset?.status === "failed" && Date.parse(asset.retryAt || "") > Date.now())) {
|
|
||||||
return failed(asset.failure || getDownloadFailure(null), task.id);
|
|
||||||
}
|
|
||||||
scheduleImageDownloadPreparation(task.id);
|
|
||||||
return preparing(task.id);
|
|
||||||
}
|
|
||||||
if (task?.targetUrl) {
|
|
||||||
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
|
|
||||||
scheduleImageDownloadPreparation(task.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const cookie = request.headers.get("cookie") || "";
|
const cookie = request.headers.get("cookie") || "";
|
||||||
if (body.preparation === "async") {
|
|
||||||
const result = await getDownloadPreparationQueue(prepareDownload, after).request({
|
|
||||||
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
|
|
||||||
});
|
|
||||||
if (result.status === "preparing") {
|
|
||||||
return preparing(result.requestId);
|
|
||||||
}
|
|
||||||
if (result.status === "failed") {
|
|
||||||
return failed(result.failure, result.requestId);
|
|
||||||
}
|
|
||||||
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
|
|
||||||
"Content-Type": result.asset.contentType,
|
|
||||||
"Content-Length": String(result.asset.buffer.length),
|
|
||||||
"Content-Disposition": buildAttachmentContentDisposition(`${name}.png`),
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
} });
|
|
||||||
}
|
|
||||||
let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null;
|
let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null;
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
|
|
||||||
@ -167,14 +73,17 @@ export async function POST(request: NextRequest) {
|
|||||||
includeSequencePrefix: false,
|
includeSequencePrefix: false,
|
||||||
});
|
});
|
||||||
if (result.ok) break;
|
if (result.ok) break;
|
||||||
failures.push(result.error);
|
failures.push(`${url}: ${result.error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result?.ok) {
|
if (!result?.ok) {
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
logger.warn("all_sources_failed", { requestId, failures });
|
console.warn("[CanvasDownloadImage] All candidates failed:", failures);
|
||||||
}
|
}
|
||||||
return failed(getDownloadFailure(result?.error), requestId);
|
return NextResponse.json(
|
||||||
|
{ success: false, error: result?.error || "图片源文件暂时不可访问" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(result.bytes, {
|
return new NextResponse(result.bytes, {
|
||||||
@ -187,7 +96,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("download_failed", error, { requestId });
|
console.error("[CanvasDownloadImage] Failed:", error);
|
||||||
return failed(getDownloadFailure(error), requestId);
|
return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,54 @@
|
|||||||
import { writeDownloadZip, readDownloadZip } from "@/lib/canvas/download-zip-stream";
|
|
||||||
import { claimZipTask, loadZipTask, saveZipTask, zipTaskPath, assertZipDiskSpace, cleanupZipTasks, assertZipTaskCapacity, type ZipTask } from "@/lib/canvas/download-zip-task-store";
|
|
||||||
import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range";
|
|
||||||
import { createReadStream } from "node:fs";
|
|
||||||
import { mkdir, stat, writeFile, rm } from "node:fs/promises";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { after, NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
||||||
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
|
|
||||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 300;
|
export const maxDuration = 300;
|
||||||
type DownloadFileItem = { url: string; name?: string };
|
|
||||||
|
type DownloadFileItem = {
|
||||||
|
url: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ZipTaskStatus = "pending" | "running" | "completed" | "failed";
|
||||||
|
|
||||||
|
type ZipTask = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
folderName: string;
|
||||||
|
files: DownloadFileItem[];
|
||||||
|
totalCount: number;
|
||||||
|
processedCount: number;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
compressPercent: number;
|
||||||
|
status: ZipTaskStatus;
|
||||||
|
stage: string;
|
||||||
|
error?: string;
|
||||||
|
zipBuffer?: Buffer;
|
||||||
|
contentDisposition?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_CONCURRENCY = 8;
|
||||||
const MAX_DOWNLOAD_FILES = 200;
|
const MAX_DOWNLOAD_FILES = 200;
|
||||||
const TASK_TTL_MS = 24 * 60 * 60_000;
|
const TASK_TTL_MS = 30 * 60 * 1000;
|
||||||
const PREPARATION_TTL_MS = 6 * 60 * 60_000;
|
|
||||||
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
|
const globalTaskStore = globalThis as typeof globalThis & {
|
||||||
const active = running.__canvasZipRunning ??= new Set<string>();
|
__canvasDownloadZipTasks?: Map<string, ZipTask>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTaskStore() {
|
||||||
|
if (!globalTaskStore.__canvasDownloadZipTasks) {
|
||||||
|
globalTaskStore.__canvasDownloadZipTasks = new Map<string, ZipTask>();
|
||||||
|
}
|
||||||
|
return globalTaskStore.__canvasDownloadZipTasks;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeFolderName(value: string): string {
|
function sanitizeFolderName(value: string): string {
|
||||||
const cleaned = String(value || "")
|
const cleaned = String(value || "")
|
||||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||||
@ -26,7 +56,7 @@ function sanitizeFolderName(value: string): string {
|
|||||||
.replace(/-+/g, "-")
|
.replace(/-+/g, "-")
|
||||||
.replace(/^-+|-+$/g, "")
|
.replace(/^-+|-+$/g, "")
|
||||||
.trim();
|
.trim();
|
||||||
return cleaned && !/^\.+$/.test(cleaned) ? cleaned.slice(0, 100) : `canvas-images-${Date.now()}`;
|
return cleaned || `canvas-images-${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildContentDisposition(folderName: string): string {
|
function buildContentDisposition(folderName: string): string {
|
||||||
@ -80,172 +110,258 @@ function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
|
|||||||
.slice(0, MAX_DOWNLOAD_FILES);
|
.slice(0, MAX_DOWNLOAD_FILES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanupExpiredTasks() {
|
||||||
|
const now = Date.now();
|
||||||
|
const store = getTaskStore();
|
||||||
|
for (const [taskId, task] of store.entries()) {
|
||||||
|
if (task.expiresAt <= now) {
|
||||||
|
store.delete(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskProgress(task: ZipTask) {
|
||||||
|
if (task.status === "completed") {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
if (task.status === "failed") {
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (task.processedCount >= task.totalCount && task.totalCount > 0) {
|
||||||
|
return Math.min(99, 85 + Math.round((task.compressPercent / 100) * 14));
|
||||||
|
}
|
||||||
|
return Math.max(
|
||||||
|
4,
|
||||||
|
Math.min(85, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function serializeTask(task: ZipTask) {
|
function serializeTask(task: ZipTask) {
|
||||||
return { id: task.id, folderName: task.folderName, status: task.status, stage: task.stage,
|
const downloadUrl =
|
||||||
totalCount: task.totalCount, processedCount: task.processedCount, successCount: task.successCount,
|
task.status === "completed"
|
||||||
failedCount: task.failedCount, progress: task.status === "completed" ? 100 : Math.min(99,
|
? `/api/canvas/download-zip-task?taskId=${encodeURIComponent(task.id)}&download=1`
|
||||||
Math.round(task.processedCount / Math.max(1, task.totalCount) * 85 + task.compressPercent * .14)),
|
: "";
|
||||||
error: task.error || "", expiresAt: task.expiresAt,
|
return {
|
||||||
downloadUrl: task.status === "completed" ? `/api/canvas/download-zip-task?taskId=${task.id}&download=1` : "" };
|
id: task.id,
|
||||||
|
folderName: task.folderName,
|
||||||
|
status: task.status,
|
||||||
|
stage: task.stage,
|
||||||
|
totalCount: task.totalCount,
|
||||||
|
processedCount: task.processedCount,
|
||||||
|
successCount: task.successCount,
|
||||||
|
failedCount: task.failedCount,
|
||||||
|
progress: getTaskProgress(task),
|
||||||
|
error: task.error || "",
|
||||||
|
downloadUrl,
|
||||||
|
createdAt: task.createdAt,
|
||||||
|
updatedAt: task.updatedAt,
|
||||||
|
expiresAt: task.expiresAt,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
async function processTask(id: string, userId: string, requestOrigin: string, cookie: string) {
|
|
||||||
if (active.has(id) || active.size >= 2) return;
|
async function processZipTask(params: {
|
||||||
active.add(id);
|
task: ZipTask;
|
||||||
let release: (() => Promise<void>) | null = null;
|
requestOrigin: string;
|
||||||
let task: ZipTask | null = null;
|
cookie: string;
|
||||||
|
}) {
|
||||||
|
const { task, requestOrigin, cookie } = params;
|
||||||
|
const store = getTaskStore();
|
||||||
try {
|
try {
|
||||||
release = await claimZipTask(id);
|
task.status = "running";
|
||||||
if (!release) return;
|
task.stage = "正在读取源图";
|
||||||
task = await loadZipTask(id, userId);
|
task.updatedAt = Date.now();
|
||||||
if (!task || task.status === "completed" || task.status === "failed") return;
|
|
||||||
await assertZipDiskSpace();
|
const zip = new JSZip();
|
||||||
const existingZip = task.zipFile && task.contentDisposition && await stat(task.zipFile.path).catch(() => null);
|
const folder = zip.folder(task.folderName) || zip;
|
||||||
const reuseZip = existingZip && existingZip.size === task.zipFile!.bytes;
|
const results: Array<
|
||||||
task.status = "running"; task.stage = reuseZip ? "正在准备加速下载" : "正在准备原图"; task.updatedAt = Date.now();
|
| Awaited<ReturnType<typeof fetchCanvasDownloadAsset>>
|
||||||
await saveZipTask(task);
|
| null
|
||||||
if (!reuseZip) {
|
> = new Array(task.files.length).fill(null);
|
||||||
delete task.zipFile; delete task.contentDisposition; delete task.cdnUrl;
|
|
||||||
// Stage each image on disk, not all PNG buffers in the web process heap.
|
|
||||||
// A restart resumes from these files using the current owner's session.
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
let writes = Promise.resolve();
|
const workerCount = Math.min(MAX_CONCURRENCY, task.files.length);
|
||||||
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
|
|
||||||
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
|
await Promise.all(
|
||||||
while (cursor < task!.files.length) {
|
Array.from({ length: workerCount }, async () => {
|
||||||
const index = cursor++; const file = task!.files[index];
|
while (true) {
|
||||||
const path = join(zipTaskPath(id), `${index}.image`);
|
const index = cursor;
|
||||||
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
|
cursor += 1;
|
||||||
delete file.prepared; delete file.filename;
|
if (index >= task.files.length) break;
|
||||||
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
|
|
||||||
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
|
const file = task.files[index]!;
|
||||||
if (result.ok) {
|
results[index] = await fetchCanvasDownloadAsset({
|
||||||
await assertZipDiskSpace(result.bytes.length);
|
file,
|
||||||
await writeFile(path, result.bytes, { mode: 0o600 });
|
index,
|
||||||
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
|
requestOrigin,
|
||||||
} else file.error = result.error;
|
cookie,
|
||||||
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
|
|
||||||
task!.successCount = task!.files.filter(f => f.prepared).length;
|
|
||||||
task!.failedCount = task!.files.filter(f => f.error).length;
|
|
||||||
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
|
|
||||||
task!.updatedAt = Date.now(); await persist();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
const rejected = results.find(result => result.status === "rejected");
|
|
||||||
if (rejected?.status === "rejected") throw rejected.reason;
|
|
||||||
if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
|
|
||||||
const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
|
|
||||||
for (const file of task.files) if (file.prepared && file.filename)
|
|
||||||
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
|
|
||||||
.once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
|
|
||||||
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
|
|
||||||
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
|
|
||||||
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
|
|
||||||
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
|
|
||||||
task.contentDisposition = buildContentDisposition(task.folderName);
|
|
||||||
}
|
|
||||||
task.stage = "正在准备下载链接"; await saveZipTask(task);
|
|
||||||
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile!, disposition: task.contentDisposition!,
|
|
||||||
onProgress: async bytes => {
|
|
||||||
task!.stage = `正在传送至七牛 ${Math.round(bytes / task!.zipFile!.bytes * 100)}%`;
|
|
||||||
task!.updatedAt = Date.now(); await saveZipTask(task!);
|
|
||||||
} });
|
|
||||||
if (cdn) {
|
|
||||||
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
|
|
||||||
if (!cdn.url) throw new Error(cdn.error || `ZIP 已打包,但加速下载未就绪,请再次点击下载继续传送原 ZIP。下载编号:${task.id}`);
|
|
||||||
task.cdnUrl = cdn.url;
|
|
||||||
}
|
|
||||||
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
|
|
||||||
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
|
|
||||||
await saveZipTask(task);
|
|
||||||
// The complete ZIP is enough for retries; release staged duplicate bytes.
|
|
||||||
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
|
|
||||||
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
|
|
||||||
bytes: task.zipFile!.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
|
|
||||||
} catch (error) {
|
|
||||||
if (task) {
|
|
||||||
task.status = "failed"; task.stage = task.zipFile ? "ZIP 已打包,加速下载未就绪" : "打包未完成";
|
|
||||||
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
|
|
||||||
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
|
|
||||||
}
|
|
||||||
console.error("[CanvasDownloadZipTask] Preparation failed", { taskId: id, message: task?.error });
|
|
||||||
} finally { await release?.().catch(() => undefined); active.delete(id); }
|
|
||||||
}
|
|
||||||
function schedule(task: ZipTask, request: NextRequest) {
|
|
||||||
const run = () => trackRuntimeWork(() => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || ""));
|
|
||||||
after(run);
|
|
||||||
}
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
let id = typeof body.taskId === "string" ? body.taskId : randomUUID();
|
|
||||||
let task = await loadZipTask(id, session.user.id);
|
|
||||||
if (!task) {
|
|
||||||
await assertZipTaskCapacity(session.user.id);
|
|
||||||
if (await stat(zipTaskPath(id)).catch(() => null)) id = randomUUID();
|
|
||||||
const files = toDownloadItems(body.files, getRequestOrigin(request));
|
|
||||||
if (!files.length || !Array.isArray(body.files) || body.files.length > MAX_DOWNLOAD_FILES || files.length !== body.files.length)
|
|
||||||
return NextResponse.json({ error: "请选择 1 到 200 张可下载图片" }, { status: 400 });
|
|
||||||
// Exclusive creation prevents an arbitrary taskId from overwriting another owner's manifest.
|
|
||||||
await mkdir(zipTaskPath(id), { recursive: false, mode: 0o700 }).catch(async error => {
|
|
||||||
if (error.code !== "ENOENT") throw error;
|
|
||||||
await mkdir((await import("@/lib/canvas/download-zip-stream")).downloadZipDirectory, { recursive: true, mode: 0o700 });
|
|
||||||
await mkdir(zipTaskPath(id), { mode: 0o700 });
|
|
||||||
});
|
});
|
||||||
const now = Date.now();
|
|
||||||
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
|
const result = results[index];
|
||||||
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
|
task.processedCount += 1;
|
||||||
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + PREPARATION_TTL_MS };
|
if (result?.ok) {
|
||||||
await saveZipTask(task);
|
task.successCount += 1;
|
||||||
} else if ((task.status === "failed" || task.status === "completed" && task.cdnObject && !task.cdnUrl) && body.retry === true) {
|
} else {
|
||||||
task.status = "pending"; task.error = undefined;
|
task.failedCount += 1;
|
||||||
await assertZipTaskCapacity(session.user.id);
|
|
||||||
if (!task.zipFile) {
|
|
||||||
for (const file of task.files) delete file.error;
|
|
||||||
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
|
|
||||||
task.failedCount = 0;
|
|
||||||
}
|
}
|
||||||
task.updatedAt = Date.now(); task.expiresAt = Date.now() + PREPARATION_TTL_MS;
|
task.stage = `正在处理 ${task.processedCount}/${task.totalCount} 张`;
|
||||||
await saveZipTask(task);
|
task.updatedAt = Date.now();
|
||||||
}
|
}
|
||||||
if (task.status === "pending" || task.status === "running") schedule(task, request);
|
})
|
||||||
after(() => cleanupZipTasks().catch(() => { console.warn("[CanvasDownloadZipTask] Expired file cleanup deferred"); }));
|
);
|
||||||
return NextResponse.json({ success: true, task: serializeTask(task) }, { status: 202, headers: { "Cache-Control": "no-store" } });
|
|
||||||
|
const failures: Array<{ index: number; url: string; name?: string; error: string }> = [];
|
||||||
|
for (const [index, item] of results.entries()) {
|
||||||
|
if (!item || !item.ok) {
|
||||||
|
failures.push({
|
||||||
|
index: index + 1,
|
||||||
|
url: item?.ok === false ? item.url : task.files[index]?.url || "",
|
||||||
|
name: item?.ok === false ? item.name : task.files[index]?.name,
|
||||||
|
error: item?.ok === false ? item.error : "未知错误",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
folder.file(item.filename, item.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
folder.file(
|
||||||
|
"_download-failures.txt",
|
||||||
|
[
|
||||||
|
"以下图片暂时无法下载。可稍后重试,或复制链接检查源图是否仍可访问。",
|
||||||
|
"",
|
||||||
|
...failures.map((item) => {
|
||||||
|
const label = item.name ? `${item.index}. ${item.name}` : `${item.index}. image`;
|
||||||
|
return `${label}\nURL: ${item.url}\n原因: ${item.error}`;
|
||||||
|
}),
|
||||||
|
].join("\n\n")
|
||||||
|
);
|
||||||
|
console.warn("[CanvasDownloadZipTask] Partial download failures:", failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.stage = "正在生成 ZIP 文件";
|
||||||
|
task.updatedAt = Date.now();
|
||||||
|
task.zipBuffer = await zip.generateAsync(
|
||||||
|
{
|
||||||
|
type: "nodebuffer",
|
||||||
|
compression: "DEFLATE",
|
||||||
|
compressionOptions: { level: 6 },
|
||||||
|
},
|
||||||
|
(metadata) => {
|
||||||
|
task.compressPercent = Math.max(0, Math.min(100, metadata.percent || 0));
|
||||||
|
task.updatedAt = Date.now();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
task.contentDisposition = buildContentDisposition(task.folderName);
|
||||||
|
task.status = "completed";
|
||||||
|
task.stage = "打包完成";
|
||||||
|
task.compressPercent = 100;
|
||||||
|
task.updatedAt = Date.now();
|
||||||
|
task.expiresAt = Date.now() + TASK_TTL_MS;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[CanvasDownloadZipTask] Create rejected", { message: (error as Error).message });
|
console.error("[CanvasDownloadZipTask] Failed:", error);
|
||||||
return NextResponse.json({ error: "无法创建打包任务,请稍后重试" }, { status: 400 });
|
task.status = "failed";
|
||||||
|
task.stage = "打包失败";
|
||||||
|
task.error = error instanceof Error ? error.message : "打包下载失败";
|
||||||
|
task.updatedAt = Date.now();
|
||||||
|
task.expiresAt = Date.now() + TASK_TTL_MS;
|
||||||
|
} finally {
|
||||||
|
store.set(task.id, task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const session = await auth();
|
export async function POST(request: NextRequest) {
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
try {
|
try {
|
||||||
const id = request.nextUrl.searchParams.get("taskId") || "";
|
cleanupExpiredTasks();
|
||||||
const task = await loadZipTask(id, session.user.id);
|
const session = await auth();
|
||||||
if (!task) return NextResponse.json({ error: "打包任务不存在或已过期,请重新打包" }, { status: 404 });
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
const requestOrigin = getRequestOrigin(request);
|
||||||
|
const files = toDownloadItems(body.files, requestOrigin);
|
||||||
|
const folderName = sanitizeFolderName(String(body.folderName || ""));
|
||||||
|
if (files.length === 0) {
|
||||||
|
return NextResponse.json({ success: false, error: "没有可下载的图片" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const task: ZipTask = {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: session.user.id,
|
||||||
|
folderName,
|
||||||
|
files,
|
||||||
|
totalCount: files.length,
|
||||||
|
processedCount: 0,
|
||||||
|
successCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
compressPercent: 0,
|
||||||
|
status: "pending",
|
||||||
|
stage: "等待服务端开始打包",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + TASK_TTL_MS,
|
||||||
|
};
|
||||||
|
getTaskStore().set(task.id, task);
|
||||||
|
|
||||||
|
void processZipTask({
|
||||||
|
task,
|
||||||
|
requestOrigin,
|
||||||
|
cookie: request.headers.get("cookie") || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
task: serializeTask(task),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CanvasDownloadZipTask] Create failed:", error);
|
||||||
|
return NextResponse.json({ success: false, error: "创建打包任务失败" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
cleanupExpiredTasks();
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = String(request.nextUrl.searchParams.get("taskId") || "").trim();
|
||||||
|
if (!taskId) {
|
||||||
|
return NextResponse.json({ success: false, error: "缺少任务 ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = getTaskStore().get(taskId);
|
||||||
|
if (!task || task.userId !== session.user.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "打包任务不存在或已过期" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
if (request.nextUrl.searchParams.get("download") === "1") {
|
if (request.nextUrl.searchParams.get("download") === "1") {
|
||||||
if (task.status !== "completed" || !task.zipFile) return NextResponse.json({ error: "ZIP 尚未准备好" }, { status: 409 });
|
if (task.status !== "completed" || !task.zipBuffer) {
|
||||||
if (task.cdnUrl && request.nextUrl.searchParams.get("local") !== "1")
|
return NextResponse.json({ success: false, error: "ZIP 文件尚未准备好" }, { status: 409 });
|
||||||
return NextResponse.redirect(task.cdnUrl, 307);
|
|
||||||
const info = await stat(task.zipFile.path).catch(() => null);
|
|
||||||
if (!info || info.size !== task.zipFile.bytes) return NextResponse.json({ error: "下载文件已过期,请重新打包" }, { status: 410 });
|
|
||||||
const etag = `"${task.id}-${info.size}"`;
|
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/zip", "Content-Disposition": task.contentDisposition!,
|
|
||||||
"Accept-Ranges": "bytes", "ETag": etag, "Cache-Control": "private, no-store",
|
|
||||||
"X-Download-Success-Count": String(task.successCount), "X-Download-Failed-Count": String(task.failedCount) };
|
|
||||||
const ifRange = request.headers.get("if-range");
|
|
||||||
let range;
|
|
||||||
try { range = parseDownloadByteRange(!ifRange || ifRange === etag ? request.headers.get("range") : null, info.size); }
|
|
||||||
catch { return new NextResponse(null, { status: 416, headers: { ...headers, "Content-Range": `bytes */${info.size}` } }); }
|
|
||||||
headers["Content-Length"] = String(range ? range.end - range.start + 1 : info.size);
|
|
||||||
if (range) headers["Content-Range"] = `bytes ${range.start}-${range.end}/${info.size}`;
|
|
||||||
return new NextResponse(readDownloadZip(task.zipFile.path, range || undefined), { status: range ? 206 : 200, headers });
|
|
||||||
}
|
}
|
||||||
if (task.status === "pending" || task.status === "running") schedule(task, request);
|
return new NextResponse(task.zipBuffer, {
|
||||||
return NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } });
|
status: 200,
|
||||||
} catch {
|
headers: {
|
||||||
return NextResponse.json({ error: "查询打包任务失败,请稍后重试" }, { status: 500 });
|
"Content-Type": "application/zip",
|
||||||
|
"Content-Disposition": task.contentDisposition || buildContentDisposition(task.folderName),
|
||||||
|
"Content-Length": String(task.zipBuffer.length),
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"X-Download-Success-Count": String(task.successCount),
|
||||||
|
"X-Download-Failed-Count": String(task.failedCount),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
task: serializeTask(task),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CanvasDownloadZipTask] Query failed:", error);
|
||||||
|
return NextResponse.json({ success: false, error: "查询打包任务失败" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { streamDownloadZip } from "@/lib/canvas/download-zip-stream";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
@ -160,7 +159,13 @@ export async function POST(request: NextRequest) {
|
|||||||
console.warn("[CanvasDownloadZip] Partial download failures:", failures);
|
console.warn("[CanvasDownloadZip] Partial download failures:", failures);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(streamDownloadZip(zip), {
|
const zipBuffer = await zip.generateAsync({
|
||||||
|
type: "nodebuffer",
|
||||||
|
compression: "DEFLATE",
|
||||||
|
compressionOptions: { level: 6 },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(zipBuffer, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
|
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
|
||||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
|
||||||
|
|
||||||
export const maxDuration = 1200;
|
export const maxDuration = 1200;
|
||||||
|
|
||||||
@ -24,15 +23,13 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
|
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
task: {
|
task: {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
attempts: task.attempts,
|
attempts: task.attempts,
|
||||||
error: failure ? formatRequestFailure(failure, task.id) : null,
|
error: task.error || null,
|
||||||
failure,
|
|
||||||
result: task.result || null,
|
result: task.result || null,
|
||||||
createdAt: task.createdAt,
|
createdAt: task.createdAt,
|
||||||
startedAt: task.startedAt,
|
startedAt: task.startedAt,
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import {
|
|||||||
ownsTaskProject,
|
ownsTaskProject,
|
||||||
} from "@/lib/video-canvas/task-store";
|
} from "@/lib/video-canvas/task-store";
|
||||||
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
|
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
|
||||||
import { preflightCanvasProductionRequests } from "@/lib/video-canvas/production-services";
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 900;
|
export const maxDuration = 900;
|
||||||
|
|
||||||
@ -29,7 +28,7 @@ async function owner(projectId: unknown) {
|
|||||||
}
|
}
|
||||||
const fail = (error: unknown) =>
|
const fail = (error: unknown) =>
|
||||||
NextResponse.json(
|
NextResponse.json(
|
||||||
{ success: false, error: error instanceof Error ? error.message : "任务操作失败", code: (error as any)?.code },
|
{ success: false, error: error instanceof Error ? error.message : "任务操作失败" },
|
||||||
{ status: Number((error as any)?.status) || (error instanceof SyntaxError ? 400 : 500) },
|
{ status: Number((error as any)?.status) || (error instanceof SyntaxError ? 400 : 500) },
|
||||||
);
|
);
|
||||||
const kick = (userId: string, projectId: string) =>
|
const kick = (userId: string, projectId: string) =>
|
||||||
@ -66,8 +65,7 @@ export async function POST(request: Request) {
|
|||||||
throw Object.assign(Error("剪辑参数无效"), { status: 400 });
|
throw Object.assign(Error("剪辑参数无效"), { status: 400 });
|
||||||
if (parsed.data.kind === "video-upscale" && !videoUpscaleSchema.safeParse(parsed.data.params).success) throw Object.assign(Error("视频高清参数无效"), {status:400});
|
if (parsed.data.kind === "video-upscale" && !videoUpscaleSchema.safeParse(parsed.data.params).success) throw Object.assign(Error("视频高清参数无效"), {status:400});
|
||||||
const userId = await owner(parsed.data.projectId);
|
const userId = await owner(parsed.data.projectId);
|
||||||
const task = await createCanvasGenerationTask(userId, parsed.data,
|
const task = await createCanvasGenerationTask(userId, parsed.data);
|
||||||
() => preflightCanvasProductionRequests([parsed.data]));
|
|
||||||
kick(userId, parsed.data.projectId);
|
kick(userId, parsed.data.projectId);
|
||||||
return NextResponse.json({ success: true, task }, { status: 202 });
|
return NextResponse.json({ success: true, task }, { status: 202 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
ownsTaskProject,
|
ownsTaskProject,
|
||||||
} from "@/lib/video-canvas/task-store";
|
} from "@/lib/video-canvas/task-store";
|
||||||
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
|
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
|
||||||
import { preflightCanvasProductionRequests } from "@/lib/video-canvas/production-services";
|
|
||||||
export const runtime = "nodejs",
|
export const runtime = "nodejs",
|
||||||
maxDuration = 900;
|
maxDuration = 900;
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@ -28,13 +27,12 @@ export async function POST(request: Request) {
|
|||||||
userId = session.user.id;
|
userId = session.user.id;
|
||||||
if (!(await ownsTaskProject(userId, projectId)))
|
if (!(await ownsTaskProject(userId, projectId)))
|
||||||
return NextResponse.json({ error: "画布不存在" }, { status: 404 });
|
return NextResponse.json({ error: "画布不存在" }, { status: 404 });
|
||||||
const tasks = await createCanvasGroupTasks(userId, parsed.data,
|
const tasks = await createCanvasGroupTasks(userId, parsed.data);
|
||||||
() => preflightCanvasProductionRequests(parsed.data));
|
|
||||||
after(() => runCanvasGenerationQueue(userId, projectId));
|
after(() => runCanvasGenerationQueue(userId, projectId));
|
||||||
return NextResponse.json({ success: true, tasks }, { status: 202 });
|
return NextResponse.json({ success: true, tasks }, { status: 202 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: e instanceof Error ? e.message : "编组执行失败", code: (e as any)?.code },
|
{ error: e instanceof Error ? e.message : "编组执行失败" },
|
||||||
{ status: Number((e as any)?.status) || 400 }
|
{ status: Number((e as any)?.status) || 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,78 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
|
|
||||||
import {
|
|
||||||
readOwnedVideoCanvas,
|
|
||||||
saveVideoAgentCanvas,
|
|
||||||
} from "@/lib/ai/canvas-agent-runtime/video-store";
|
|
||||||
import { AgentRunError } from "@/lib/ai/canvas-agent-runtime/store";
|
|
||||||
import { publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
try {
|
|
||||||
const projectId = z.string().uuid().parse(new URL(request.url).searchParams.get("projectId"));
|
|
||||||
await readOwnedVideoCanvas(session.user.id, projectId);
|
|
||||||
// One stable conversation per video workspace survives browser reloads.
|
|
||||||
const chat = await getChatById({ id: projectId });
|
|
||||||
if (chat && chat.userId !== session.user.id) throw new AgentRunError("对话不存在", 404);
|
|
||||||
const messages = chat ? (await getMessagesByChatId({ id: projectId })).slice(-40) : [];
|
|
||||||
return NextResponse.json(
|
|
||||||
{ messages: messages.map(({ id, role, parts }) => ({ id, role, parts })) },
|
|
||||||
{ headers: { "Cache-Control": "private, no-store" } },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: publicAgentError(error) },
|
|
||||||
{
|
|
||||||
status:
|
|
||||||
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveSchema = z.object({
|
|
||||||
projectId: z.string().uuid(),
|
|
||||||
baseRevision: z.string().datetime(),
|
|
||||||
canvasData: z.object({
|
|
||||||
nodes: z.array(z.record(z.unknown())).max(2000),
|
|
||||||
edges: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
id: z.string(),
|
|
||||||
source: z.string(),
|
|
||||||
target: z.string(),
|
|
||||||
type: z.string().optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.max(4000),
|
|
||||||
selectedNodeIds: z.array(z.string()).max(20),
|
|
||||||
viewport: z.object({
|
|
||||||
x: z.number().finite(),
|
|
||||||
y: z.number().finite(),
|
|
||||||
zoom: z.number().positive(),
|
|
||||||
}),
|
|
||||||
tool: z.literal("select"),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
try {
|
|
||||||
const body = saveSchema.parse(await request.json());
|
|
||||||
await saveVideoAgentCanvas(session.user.id, body.projectId, body.baseRevision, body.canvasData);
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: publicAgentError(error) },
|
|
||||||
{
|
|
||||||
status:
|
|
||||||
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
||||||
import { and, eq, desc, sql } from "drizzle-orm";
|
import { eq, desc, sql } from "drizzle-orm";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_URL_PATTERN } from "@/lib/storage/retired-storage";
|
|
||||||
import { cachedPublicJson } from "@/lib/public-api/performance";
|
import { cachedPublicJson } from "@/lib/public-api/performance";
|
||||||
|
|
||||||
const FEATURED_TIMEOUT_MS = 1800;
|
const FEATURED_TIMEOUT_MS = 1800;
|
||||||
@ -58,17 +57,13 @@ export async function GET(request: NextRequest) {
|
|||||||
userName: user.name,
|
userName: user.name,
|
||||||
userEmail: user.email,
|
userEmail: user.email,
|
||||||
userDepartment: user.department,
|
userDepartment: user.department,
|
||||||
modelName: sql<string | null>`(select ${aiModel.displayName} from ${aiModel}
|
modelName: aiModel.displayName,
|
||||||
where ${aiModel.modelId} = ${generatedImage.modelId}
|
|
||||||
order by ${aiModel.isEnabled} desc, ${aiModel.createdAt} desc, ${aiModel.id} asc limit 1)`,
|
|
||||||
totalCount: sql<number>`count(*) over()::int`,
|
totalCount: sql<number>`count(*) over()::int`,
|
||||||
})
|
})
|
||||||
.from(generatedImage)
|
.from(generatedImage)
|
||||||
.leftJoin(user, eq(generatedImage.userId, user.id))
|
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||||
.where(and(
|
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
|
||||||
eq(generatedImage.isFeatured, true),
|
.where(eq(generatedImage.isFeatured, true))
|
||||||
sql`${generatedImage.originalUrl} !~* ${RETIRED_STORAGE_URL_PATTERN}`,
|
|
||||||
))
|
|
||||||
.orderBy(desc(generatedImage.featuredAt))
|
.orderBy(desc(generatedImage.featuredAt))
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
@ -79,7 +74,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return {
|
return {
|
||||||
images: featuredImages.map((img) => ({
|
images: featuredImages.map((img) => ({
|
||||||
id: img.id,
|
id: img.id,
|
||||||
thumbnailUrl: isRetiredStorageUrl(img.thumbnailUrl) ? img.originalUrl : img.thumbnailUrl,
|
thumbnailUrl: img.thumbnailUrl,
|
||||||
originalUrl: img.originalUrl,
|
originalUrl: img.originalUrl,
|
||||||
imageSize: img.imageSize,
|
imageSize: img.imageSize,
|
||||||
prompt: img.prompt,
|
prompt: img.prompt,
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
import type {
|
import type { FlowBoardDraft, FlowBoardSnapshotData } from "@/lib/db/schema";
|
||||||
FlowBoardDraft,
|
|
||||||
FlowBoardSheetSnapshotData,
|
|
||||||
FlowBoardSnapshotData,
|
|
||||||
} from "@/lib/db/schema";
|
|
||||||
|
|
||||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
@ -12,73 +8,6 @@ function normalizeRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
return isRecord(value) ? value : null;
|
return isRecord(value) ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_FLOW_BOARD_CANVAS = {
|
|
||||||
backgroundColor: "#f7f8fb",
|
|
||||||
gridColor: "#d8dde8",
|
|
||||||
gridEnabled: false,
|
|
||||||
gridSize: 24,
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeCanvasColor(value: unknown, fallback: string) {
|
|
||||||
return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value)
|
|
||||||
? value.toLowerCase()
|
|
||||||
: fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFlowBoardCanvas(value: unknown): Record<string, unknown> {
|
|
||||||
const canvas = isRecord(value) ? value : {};
|
|
||||||
const requestedGridSize =
|
|
||||||
typeof canvas.gridSize === "number" && Number.isFinite(canvas.gridSize)
|
|
||||||
? Math.round(canvas.gridSize)
|
|
||||||
: DEFAULT_FLOW_BOARD_CANVAS.gridSize;
|
|
||||||
|
|
||||||
return {
|
|
||||||
backgroundColor: normalizeCanvasColor(
|
|
||||||
canvas.backgroundColor,
|
|
||||||
DEFAULT_FLOW_BOARD_CANVAS.backgroundColor
|
|
||||||
),
|
|
||||||
gridColor: normalizeCanvasColor(
|
|
||||||
canvas.gridColor,
|
|
||||||
DEFAULT_FLOW_BOARD_CANVAS.gridColor
|
|
||||||
),
|
|
||||||
gridEnabled:
|
|
||||||
typeof canvas.gridEnabled === "boolean"
|
|
||||||
? canvas.gridEnabled
|
|
||||||
: DEFAULT_FLOW_BOARD_CANVAS.gridEnabled,
|
|
||||||
gridSize: Math.min(64, Math.max(8, requestedGridSize)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSheetId(value: unknown, index: number) {
|
|
||||||
const id = typeof value === "string" ? value.trim().slice(0, 128) : "";
|
|
||||||
return id || `sheet-${index + 1}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSheetTitle(value: unknown, index: number) {
|
|
||||||
const title = typeof value === "string" ? value.trim().slice(0, 128) : "";
|
|
||||||
return title || `画布 ${index + 1}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFlowBoardSheet(
|
|
||||||
value: unknown,
|
|
||||||
index: number
|
|
||||||
): FlowBoardSheetSnapshotData | null {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: normalizeSheetId(value.id, index),
|
|
||||||
title: normalizeSheetTitle(value.title, index),
|
|
||||||
children: Array.isArray(value.children)
|
|
||||||
? value.children.filter(isRecord)
|
|
||||||
: [],
|
|
||||||
viewport: normalizeRecord(value.viewport),
|
|
||||||
theme: normalizeRecord(value.theme),
|
|
||||||
canvas: normalizeFlowBoardCanvas(value.canvas),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeFlowBoardTitle(value: unknown) {
|
export function normalizeFlowBoardTitle(value: unknown) {
|
||||||
const title = String(value || "").trim();
|
const title = String(value || "").trim();
|
||||||
return title.slice(0, 256) || "未命名流程白板";
|
return title.slice(0, 256) || "未命名流程白板";
|
||||||
@ -88,67 +17,15 @@ export function normalizeFlowBoardSnapshot(
|
|||||||
value: unknown
|
value: unknown
|
||||||
): FlowBoardSnapshotData {
|
): FlowBoardSnapshotData {
|
||||||
const raw = isRecord(value) ? value : {};
|
const raw = isRecord(value) ? value : {};
|
||||||
const legacyChildren = Array.isArray(raw.children)
|
|
||||||
? raw.children.filter(isRecord)
|
|
||||||
: [];
|
|
||||||
const normalizedSheets = Array.isArray(raw.sheets)
|
|
||||||
? raw.sheets
|
|
||||||
.map(normalizeFlowBoardSheet)
|
|
||||||
.filter(
|
|
||||||
(sheet): sheet is FlowBoardSheetSnapshotData => Boolean(sheet)
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const sheets =
|
|
||||||
normalizedSheets.length > 0
|
|
||||||
? normalizedSheets
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
id: "sheet-1",
|
|
||||||
title: "画布 1",
|
|
||||||
children: legacyChildren,
|
|
||||||
viewport: normalizeRecord(raw.viewport),
|
|
||||||
theme: normalizeRecord(raw.theme),
|
|
||||||
canvas: normalizeFlowBoardCanvas(raw.canvas),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const requestedActiveSheetId =
|
|
||||||
typeof raw.activeSheetId === "string" ? raw.activeSheetId.trim() : "";
|
|
||||||
const activeSheet =
|
|
||||||
sheets.find((sheet) => sheet.id === requestedActiveSheetId) || sheets[0];
|
|
||||||
const activeChildren = Array.isArray(raw.children)
|
|
||||||
? legacyChildren
|
|
||||||
: activeSheet.children;
|
|
||||||
const activeViewport = isRecord(raw.viewport)
|
|
||||||
? raw.viewport
|
|
||||||
: activeSheet.viewport;
|
|
||||||
const activeTheme = isRecord(raw.theme) ? raw.theme : activeSheet.theme;
|
|
||||||
const activeCanvas = isRecord(raw.canvas)
|
|
||||||
? normalizeFlowBoardCanvas(raw.canvas)
|
|
||||||
: normalizeFlowBoardCanvas(activeSheet.canvas);
|
|
||||||
const nextSheets = sheets.map((sheet) =>
|
|
||||||
sheet.id === activeSheet.id
|
|
||||||
? {
|
|
||||||
...sheet,
|
|
||||||
children: activeChildren,
|
|
||||||
viewport: activeViewport,
|
|
||||||
theme: activeTheme,
|
|
||||||
canvas: activeCanvas,
|
|
||||||
}
|
|
||||||
: sheet
|
|
||||||
);
|
|
||||||
const updatedAt =
|
const updatedAt =
|
||||||
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
|
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
|
||||||
? raw.updatedAt
|
? raw.updatedAt
|
||||||
: new Date().toISOString();
|
: new Date().toISOString();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 2,
|
children: Array.isArray(raw.children) ? raw.children.filter(isRecord) : [],
|
||||||
activeSheetId: activeSheet.id,
|
viewport: normalizeRecord(raw.viewport),
|
||||||
sheets: nextSheets,
|
theme: normalizeRecord(raw.theme),
|
||||||
children: activeChildren,
|
|
||||||
viewport: activeViewport,
|
|
||||||
theme: activeTheme,
|
|
||||||
canvas: activeCanvas,
|
|
||||||
updatedAt,
|
updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { imageArchiveTask } from "@/lib/db/schema";
|
|
||||||
|
|
||||||
// The SHA-256 URL is a capability, like the provider's signed image URL. It must be
|
|
||||||
// readable by image nodes and reference-image providers without a session cookie.
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ key: string }> }) {
|
|
||||||
const { key } = await params;
|
|
||||||
if (!/^[a-f0-9]{64}$/.test(key)) return new NextResponse(null, { status: 404 });
|
|
||||||
const configured =
|
|
||||||
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
|
|
||||||
const origin = configured
|
|
||||||
? new URL(configured).origin
|
|
||||||
: `http://localhost:${process.env.PORT || "3000"}`;
|
|
||||||
const sourceUrl = `${origin}/api/generated-image-preview/${key}`;
|
|
||||||
const [task] = await db
|
|
||||||
.select({ targetUrl: imageArchiveTask.targetUrl, metadata: imageArchiveTask.metadata })
|
|
||||||
.from(imageArchiveTask)
|
|
||||||
.where(eq(imageArchiveTask.sourceUrl, sourceUrl))
|
|
||||||
.limit(1);
|
|
||||||
if (task?.targetUrl) return NextResponse.redirect(task.targetUrl, 307);
|
|
||||||
const inline = (task?.metadata as { inlineSource?: string } | null)?.inlineSource;
|
|
||||||
const match = inline?.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
|
|
||||||
if (!match) return new NextResponse(null, { status: 404 });
|
|
||||||
return new NextResponse(Buffer.from(match[2], "base64"), {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": match[1],
|
|
||||||
"Cache-Control": "private, no-store",
|
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,12 +1,7 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error";
|
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
|
||||||
import { buildImageProxyUrl, isAlibabaModelImageUrl } from "@/lib/image-delivery";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
const logger = createLogger("image.download");
|
|
||||||
|
|
||||||
const DEFAULT_QUALITY = 82;
|
const DEFAULT_QUALITY = 82;
|
||||||
const MAX_WIDTH = 4096;
|
const MAX_WIDTH = 4096;
|
||||||
@ -14,7 +9,6 @@ const MIN_WIDTH = 64;
|
|||||||
const MIN_QUALITY = 40;
|
const MIN_QUALITY = 40;
|
||||||
const MAX_QUALITY = 100;
|
const MAX_QUALITY = 100;
|
||||||
const FETCH_TIMEOUT_MS = 15000;
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
const SMALL_PREVIEW_FETCH_TIMEOUT_MS = 45000;
|
|
||||||
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
||||||
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
|
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
|
||||||
|
|
||||||
@ -133,15 +127,13 @@ function buildImageResponse(
|
|||||||
etag: string;
|
etag: string;
|
||||||
vary?: string;
|
vary?: string;
|
||||||
cacheStatus?: "HIT" | "MISS";
|
cacheStatus?: "HIT" | "MISS";
|
||||||
cacheControl?: string;
|
|
||||||
attachment?: boolean;
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": params.contentType,
|
"Content-Type": params.contentType,
|
||||||
"Content-Length": String(body.length),
|
"Content-Length": String(body.length),
|
||||||
ETag: params.etag,
|
ETag: params.etag,
|
||||||
"Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
||||||
};
|
};
|
||||||
if (params.vary) {
|
if (params.vary) {
|
||||||
headers.Vary = params.vary;
|
headers.Vary = params.vary;
|
||||||
@ -149,11 +141,6 @@ function buildImageResponse(
|
|||||||
if (params.cacheStatus) {
|
if (params.cacheStatus) {
|
||||||
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
|
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
|
||||||
}
|
}
|
||||||
if (params.attachment) {
|
|
||||||
headers["Content-Disposition"] = "attachment";
|
|
||||||
headers["X-Content-Type-Options"] = "nosniff";
|
|
||||||
headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
|
|
||||||
}
|
|
||||||
return new NextResponse(body, {
|
return new NextResponse(body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers,
|
headers,
|
||||||
@ -243,7 +230,6 @@ function resolveDirectImageTarget(
|
|||||||
requestedQuality: number | null,
|
requestedQuality: number | null,
|
||||||
requestedFormat: string | null
|
requestedFormat: string | null
|
||||||
): URL | null {
|
): URL | null {
|
||||||
if (isAlibabaModelImageUrl(target.toString())) return null;
|
|
||||||
if (!isBrowserImageRequest(request)) {
|
if (!isBrowserImageRequest(request)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -272,19 +258,7 @@ function redirectToDirectImage(target: URL): NextResponse {
|
|||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
const requestId = crypto.randomUUID();
|
|
||||||
const downloadFailure = (error: unknown) => {
|
|
||||||
const failure = getDownloadFailure(error);
|
|
||||||
logger.warn("original_download_failed", error, { requestId, code: failure.code, status: failure.status });
|
|
||||||
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId }, {
|
|
||||||
status: failure.status, headers: { "Cache-Control": "no-store" },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const rawUrl = (searchParams.get("url") || "").trim();
|
const rawUrl = (searchParams.get("url") || "").trim();
|
||||||
if (isRetiredStorageUrl(rawUrl)) {
|
|
||||||
if (searchParams.get("original") === "1") return downloadFailure(RETIRED_STORAGE_MESSAGE);
|
|
||||||
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
|
||||||
}
|
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
|
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@ -300,24 +274,6 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 });
|
return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizeLegacyAliyunStorageUrl(target.toString())) {
|
|
||||||
try {
|
|
||||||
if (searchParams.get("original") === "1") {
|
|
||||||
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
|
|
||||||
buildImageProxyUrl(target.toString(), { delivery: "original" });
|
|
||||||
}
|
|
||||||
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
|
|
||||||
target = new URL(await resolveLegacyAliyunStorageUrl(target.toString()));
|
|
||||||
} catch (error) {
|
|
||||||
if (searchParams.get("original") === "1") return downloadFailure(error);
|
|
||||||
const failure = getDownloadFailure(error);
|
|
||||||
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId,
|
|
||||||
code: failure.code === "STORAGE_RETIRED" ? "storage_retired" : failure.code }, {
|
|
||||||
status: failure.status, headers: { "Cache-Control": "no-store" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isBlockedHost(target.hostname)) {
|
if (isBlockedHost(target.hostname)) {
|
||||||
return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
|
return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
|
||||||
}
|
}
|
||||||
@ -329,47 +285,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
|
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
|
||||||
const requestedFormat = searchParams.get("f");
|
const requestedFormat = searchParams.get("f");
|
||||||
const forceProxy = searchParams.get("proxy") === "1";
|
const forceProxy = searchParams.get("proxy") === "1";
|
||||||
const original = searchParams.get("original") === "1";
|
|
||||||
|
|
||||||
if (original && (requestedWidth || requestedQuality || requestedFormat)) {
|
if (!forceProxy) {
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Original images cannot be transformed" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!original && isAlibabaModelImageUrl(target.toString())) {
|
|
||||||
try {
|
|
||||||
const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db");
|
|
||||||
const mappings = await getArchivedImageUrlMappings([target.toString()]);
|
|
||||||
const archived = mappings[target.toString()];
|
|
||||||
if (archived) {
|
|
||||||
const preview = buildImageProxyUrl(archived, {
|
|
||||||
width: width || undefined,
|
|
||||||
quality,
|
|
||||||
format: ["webp", "avif", "jpeg", "png"].includes(requestedFormat || "") ? requestedFormat as OutputFormat : undefined,
|
|
||||||
});
|
|
||||||
if (/^https?:\/\//i.test(preview)) return redirectToDirectImage(new URL(preview));
|
|
||||||
target = new URL(archived);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn("preview_archive_lookup_failed", error, { requestId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (original && searchParams.get("download") === "resolve") {
|
|
||||||
try {
|
|
||||||
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
|
|
||||||
buildImageProxyUrl(target.toString(), { delivery: "original" });
|
|
||||||
const { resolveOriginalCdnDownload } = await import("@/lib/storage/original-image-download");
|
|
||||||
const direct = await resolveOriginalCdnDownload(target.toString(), searchParams.get("name") || "", request.signal);
|
|
||||||
return NextResponse.json(direct || { delivery: "proxy" }, { headers: { "Cache-Control": "no-store" } });
|
|
||||||
} catch (error) {
|
|
||||||
return downloadFailure(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!forceProxy && !original) {
|
|
||||||
const directImageTarget = resolveDirectImageTarget(
|
const directImageTarget = resolveDirectImageTarget(
|
||||||
request,
|
request,
|
||||||
target,
|
target,
|
||||||
@ -382,12 +299,10 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = original
|
const cacheKey = `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
|
||||||
? `${target.toString()}|original-v1`
|
|
||||||
: `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
|
|
||||||
const etag = buildEtag(cacheKey);
|
const etag = buildEtag(cacheKey);
|
||||||
const ifNoneMatch = request.headers.get("if-none-match");
|
const ifNoneMatch = request.headers.get("if-none-match");
|
||||||
if (!original && ifNoneMatch && ifNoneMatch === etag) {
|
if (ifNoneMatch && ifNoneMatch === etag) {
|
||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
status: 304,
|
status: 304,
|
||||||
headers: {
|
headers: {
|
||||||
@ -397,7 +312,7 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = original ? null : getCachedImageResponse(cacheKey);
|
const cached = getCachedImageResponse(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return buildImageResponse(cached.body, {
|
return buildImageResponse(cached.body, {
|
||||||
contentType: cached.contentType,
|
contentType: cached.contentType,
|
||||||
@ -407,73 +322,32 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchSignal = AbortSignal.any([
|
|
||||||
request.signal,
|
|
||||||
AbortSignal.timeout(original ? 60_000 : isAlibabaModelImageUrl(target.toString()) || width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
|
|
||||||
]);
|
|
||||||
const upstreamFailure = (error: unknown) => {
|
|
||||||
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
|
||||||
if (original) return downloadFailure(error);
|
|
||||||
const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError";
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" },
|
|
||||||
{ status: timedOut ? 504 : 502 },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
let contentType: string;
|
|
||||||
let originalBuffer: Buffer;
|
|
||||||
if (!original && isAlibabaModelImageUrl(target.toString())) {
|
|
||||||
try {
|
|
||||||
const { fetchImageBytes } = await import("@/lib/storage/image-byte-cache");
|
|
||||||
const data = await fetchImageBytes(target.toString(), { timeoutMs: SMALL_PREVIEW_FETCH_TIMEOUT_MS });
|
|
||||||
originalBuffer = data.buffer;
|
|
||||||
contentType = data.contentType;
|
|
||||||
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
|
||||||
if (!contentType.toLowerCase().startsWith("image/")) {
|
|
||||||
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return upstreamFailure(error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let upstreamResponse: Response;
|
let upstreamResponse: Response;
|
||||||
try {
|
try {
|
||||||
upstreamResponse = await fetch(target.toString(), {
|
upstreamResponse = await fetch(target.toString(), {
|
||||||
signal: fetchSignal,
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
headers: {
|
headers: {
|
||||||
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return upstreamFailure(error);
|
console.error("[ImageProxy] Upstream fetch failed:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch upstream image" }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!upstreamResponse.ok) {
|
if (!upstreamResponse.ok) {
|
||||||
if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
||||||
{ status: 502 }
|
{ status: 502 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
|
|
||||||
|
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
|
||||||
if (!contentType.toLowerCase().startsWith("image/")) {
|
if (!contentType.toLowerCase().startsWith("image/")) {
|
||||||
if (original) return downloadFailure("Source is not an image");
|
|
||||||
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||||
} catch (error) {
|
|
||||||
return upstreamFailure(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (original) {
|
|
||||||
return buildImageResponse(originalBuffer, {
|
|
||||||
contentType,
|
|
||||||
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
|
|
||||||
cacheControl: "private, no-store",
|
|
||||||
attachment: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const isSvg = contentType.toLowerCase().includes("image/svg");
|
const isSvg = contentType.toLowerCase().includes("image/svg");
|
||||||
const isGif = contentType.toLowerCase().includes("image/gif");
|
const isGif = contentType.toLowerCase().includes("image/gif");
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { preprocessProductImageWithRmbg } from "@/lib/scene-fusion/preprocess-product";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@ -70,6 +69,7 @@ const SCENE_FUSION_REQUEST_STAGGER_MS = 350;
|
|||||||
const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000;
|
const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000;
|
||||||
const SCENE_FUSION_AGENT_MAX_WORKERS = 3;
|
const SCENE_FUSION_AGENT_MAX_WORKERS = 3;
|
||||||
const SCENE_FUSION_PROCESS_MAX_WORKERS = 4;
|
const SCENE_FUSION_PROCESS_MAX_WORKERS = 4;
|
||||||
|
const SCENE_FUSION_PRE_RMBG_TIMEOUT_MS = 60_000;
|
||||||
const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000;
|
const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000;
|
||||||
const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8;
|
const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8;
|
||||||
const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000;
|
const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000;
|
||||||
@ -236,6 +236,41 @@ function isSceneFusionReviewRejectError(message: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPreRmbgCandidateUrls(request: Request): string[] {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
|
const requestHost = requestUrl.hostname.toLowerCase();
|
||||||
|
const requestProtocol = requestUrl.protocol.replace(":", "");
|
||||||
|
if (!(requestProtocol === "https" && (requestHost === "localhost" || requestHost === "127.0.0.1"))) {
|
||||||
|
urls.push(new URL("/api/rmbg", request.url).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = request.headers.get("host")?.trim();
|
||||||
|
const protoHint = request.headers
|
||||||
|
.get("x-forwarded-proto")
|
||||||
|
?.split(",")[0]
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const protocol = protoHint === "https" || protoHint === "http" ? protoHint : requestUrl.protocol.replace(":", "");
|
||||||
|
|
||||||
|
if (host) {
|
||||||
|
const normalizedHost = host.split(":")[0]?.toLowerCase() || "";
|
||||||
|
const isLocalHost = normalizedHost === "localhost" || normalizedHost === "127.0.0.1";
|
||||||
|
if (!(protocol === "https" && isLocalHost)) {
|
||||||
|
urls.push(`${protocol}://${host}/api/rmbg`);
|
||||||
|
}
|
||||||
|
if (isLocalHost) {
|
||||||
|
urls.push(`http://${host}/api/rmbg`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPort = requestUrl.port || process.env.PORT || "3125";
|
||||||
|
urls.push(`http://127.0.0.1:${localPort}/api/rmbg`);
|
||||||
|
urls.push(`http://localhost:${localPort}/api/rmbg`);
|
||||||
|
|
||||||
|
return Array.from(new Set(urls));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUrlForCompare(input: string): string {
|
function normalizeUrlForCompare(input: string): string {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(input);
|
const parsed = new URL(input);
|
||||||
@ -953,6 +988,102 @@ async function getSceneFusionConfig() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function preprocessProductImageWithRmbg(
|
||||||
|
request: Request,
|
||||||
|
requestId: string,
|
||||||
|
productImageUrl: string,
|
||||||
|
preRmbgEnabled: boolean
|
||||||
|
): Promise<{
|
||||||
|
productImageUrl: string;
|
||||||
|
usedPreRmbg: boolean;
|
||||||
|
provider?: string;
|
||||||
|
reason?: string;
|
||||||
|
}> {
|
||||||
|
if (!preRmbgEnabled) {
|
||||||
|
return {
|
||||||
|
productImageUrl,
|
||||||
|
usedPreRmbg: false,
|
||||||
|
reason: "disabled_by_config",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = request.headers.get("cookie");
|
||||||
|
if (!cookie) {
|
||||||
|
return {
|
||||||
|
productImageUrl,
|
||||||
|
usedPreRmbg: false,
|
||||||
|
reason: "missing_cookie",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateUrls = buildPreRmbgCandidateUrls(request);
|
||||||
|
let lastReason = "no_candidate_url";
|
||||||
|
|
||||||
|
logger.info("pre_rmbg_started", {
|
||||||
|
requestId,
|
||||||
|
candidateCount: candidateUrls.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const rmbgUrl of candidateUrls) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), SCENE_FUSION_PRE_RMBG_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(rmbgUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
cookie,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
imageUrl: productImageUrl,
|
||||||
|
prompt: "场景裂变预处理-抠图",
|
||||||
|
skipGallerySave: true,
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
lastReason = `request_failed_${response.status}`;
|
||||||
|
logger.warn("pre_rmbg_candidate_failed", {
|
||||||
|
requestId,
|
||||||
|
status: response.status,
|
||||||
|
detail: payload?.detail || payload?.error || "unknown",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedUrl = String(payload?.original_url || payload?.thumbnail_url || "").trim();
|
||||||
|
if (!cleanedUrl || cleanedUrl === productImageUrl) {
|
||||||
|
lastReason = cleanedUrl ? "same_as_source" : "empty_result";
|
||||||
|
logger.warn("pre_rmbg_candidate_invalid", {
|
||||||
|
requestId,
|
||||||
|
reason: lastReason,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
productImageUrl: cleanedUrl,
|
||||||
|
usedPreRmbg: true,
|
||||||
|
provider: String(payload?.provider || "unknown"),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lastReason = error instanceof Error ? error.message : "exception";
|
||||||
|
logger.warn("pre_rmbg_candidate_exception", error, { requestId });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
productImageUrl,
|
||||||
|
usedPreRmbg: false,
|
||||||
|
reason: lastReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建缩略图
|
* 创建缩略图
|
||||||
*/
|
*/
|
||||||
@ -2305,13 +2436,12 @@ export async function POST(request: Request) {
|
|||||||
productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product");
|
productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product");
|
||||||
}
|
}
|
||||||
const shouldForcePreRmbg = resolvedSceneMode === "random-bg";
|
const shouldForcePreRmbg = resolvedSceneMode === "random-bg";
|
||||||
const preprocessedProduct = await preprocessProductImageWithRmbg({
|
const preprocessedProduct = await preprocessProductImageWithRmbg(
|
||||||
request,
|
request,
|
||||||
requestId,
|
requestId,
|
||||||
userId: session.user.id,
|
|
||||||
productImageUrl,
|
productImageUrl,
|
||||||
preRmbgEnabled: shouldForcePreRmbg ? true : config.preRmbgEnabled,
|
shouldForcePreRmbg ? true : config.preRmbgEnabled
|
||||||
});
|
);
|
||||||
const fusionProductImageUrl = preprocessedProduct.productImageUrl;
|
const fusionProductImageUrl = preprocessedProduct.productImageUrl;
|
||||||
logger.info("pre_rmbg_resolved", {
|
logger.info("pre_rmbg_resolved", {
|
||||||
requestId,
|
requestId,
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { resolveSiteBranding } from "@/lib/site-branding";
|
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@ -153,7 +152,7 @@ import {
|
|||||||
} from "@/lib/ai-wash/config";
|
} from "@/lib/ai-wash/config";
|
||||||
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
|
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
|
||||||
import { withTimeout } from "@/lib/public-api/performance";
|
import { withTimeout } from "@/lib/public-api/performance";
|
||||||
import type { AgentCategoryTreeConfig, AgentWorkflowUiConfig } from "@/types/agent";
|
import type { AgentCategoryTreeConfig } from "@/types/agent";
|
||||||
|
|
||||||
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
|
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
|
||||||
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
|
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
|
||||||
@ -553,7 +552,6 @@ function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConf
|
|||||||
type PublicSiteSettingsScope =
|
type PublicSiteSettingsScope =
|
||||||
| "agent-brand-model"
|
| "agent-brand-model"
|
||||||
| "agent-canvas"
|
| "agent-canvas"
|
||||||
| "agent-entry"
|
|
||||||
| "agent-presets"
|
| "agent-presets"
|
||||||
| "auth"
|
| "auth"
|
||||||
| "branding"
|
| "branding"
|
||||||
@ -565,7 +563,6 @@ type PublicSiteSettingsScope =
|
|||||||
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
||||||
"agent-brand-model",
|
"agent-brand-model",
|
||||||
"agent-canvas",
|
"agent-canvas",
|
||||||
"agent-entry",
|
|
||||||
"agent-presets",
|
"agent-presets",
|
||||||
"auth",
|
"auth",
|
||||||
"branding",
|
"branding",
|
||||||
@ -609,60 +606,22 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
|||||||
lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig(undefined),
|
lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
const AGENT_CANVAS_SETTING_KEYS = [
|
|
||||||
REALTIME_CANVAS_CONFIG_KEY,
|
|
||||||
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
|
|
||||||
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
|
|
||||||
LAYER_SPLIT_CONFIG_KEY,
|
|
||||||
ANGLE_EDIT_CONFIG_KEY,
|
|
||||||
CANVAS_AGENT_MODE_ENABLED_KEY,
|
|
||||||
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
|
|
||||||
VIDEO_CANVAS_ENABLED_KEY,
|
|
||||||
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
|
|
||||||
ENHANCE_PRO_KREA_SETTING_KEY,
|
|
||||||
AI_WASH_CONFIG_KEY,
|
|
||||||
STYLE_PRO_CONFIG_KEY,
|
|
||||||
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
|
||||||
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
|
|
||||||
];
|
|
||||||
|
|
||||||
async function getAgentCanvasSettingsEtag(scope: string) {
|
|
||||||
const [settings, rows, providers] = await Promise.all([
|
|
||||||
db.select({ id: siteSettings.id, updatedAt: siteSettings.updatedAt })
|
|
||||||
.from(siteSettings).limit(1),
|
|
||||||
db.select({ key: systemSettings.key, updatedAt: systemSettings.updatedAt })
|
|
||||||
.from(systemSettings)
|
|
||||||
.where(inArray(systemSettings.key, AGENT_CANVAS_SETTING_KEYS)),
|
|
||||||
db.select({ id: aiProvider.id, updatedAt: aiProvider.updatedAt, enabled: aiProvider.isEnabled })
|
|
||||||
.from(aiProvider)
|
|
||||||
.where(inArray(aiProvider.type, ["tripo-3d", "worldlabs-3d"])),
|
|
||||||
]);
|
|
||||||
const versions = [
|
|
||||||
...settings.map((row) => `${row.id}:${row.updatedAt.getTime()}`),
|
|
||||||
...rows.map((row) => `${row.key}:${row.updatedAt.getTime()}`),
|
|
||||||
...providers.map((row) => `${row.id}:${row.updatedAt.getTime()}:${row.enabled}`),
|
|
||||||
].sort();
|
|
||||||
return `W/"${scope}/${versions.join(",")}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createScopedSiteSettingsResponse(
|
function createScopedSiteSettingsResponse(
|
||||||
scope: PublicSiteSettingsScope,
|
scope: PublicSiteSettingsScope,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS",
|
cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS",
|
||||||
cacheKey: string = scope,
|
cacheKey: string = scope,
|
||||||
options: { fallback?: boolean; startedAt?: number; etag?: string } = {}
|
options: { fallback?: boolean; startedAt?: number } = {}
|
||||||
) {
|
) {
|
||||||
if (cacheStatus === "MISS") {
|
if (cacheStatus === "MISS") {
|
||||||
setScopedSiteSettingsCache(
|
setScopedSiteSettingsCache(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
data,
|
data,
|
||||||
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
|
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS
|
||||||
options.etag
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = NextResponse.json(data);
|
const response = NextResponse.json(data);
|
||||||
if (options.etag) response.headers.set("ETag", options.etag);
|
|
||||||
// Lightweight public settings change rarely; keep them browser-local briefly
|
// Lightweight public settings change rarely; keep them browser-local briefly
|
||||||
// so page opens do not repeatedly wait on the large full settings endpoint.
|
// so page opens do not repeatedly wait on the large full settings endpoint.
|
||||||
response.headers.set(
|
response.headers.set(
|
||||||
@ -704,12 +663,12 @@ async function readScopedSiteSettings(keys: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
||||||
return resolveSiteBranding({
|
return {
|
||||||
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
||||||
siteLogo: String(settings?.siteLogo || ""),
|
siteLogo: settings?.siteLogo || null,
|
||||||
siteLogoDark: String(settings?.siteLogoDark || ""),
|
siteLogoDark: settings?.siteLogoDark || null,
|
||||||
favicon: String(settings?.favicon || ""),
|
favicon: settings?.favicon || null,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getScopedPublicSiteSettings(
|
async function getScopedPublicSiteSettings(
|
||||||
@ -717,7 +676,6 @@ async function getScopedPublicSiteSettings(
|
|||||||
options: {
|
options: {
|
||||||
brandName?: string;
|
brandName?: string;
|
||||||
cacheKey?: string;
|
cacheKey?: string;
|
||||||
etag?: string;
|
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
if (scope === "auth") {
|
if (scope === "auth") {
|
||||||
@ -779,8 +737,23 @@ async function getScopedPublicSiteSettings(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === "agent-canvas" || scope === "agent-entry") {
|
if (scope === "agent-canvas") {
|
||||||
const { settings, valueByKey } = await readScopedSiteSettings(AGENT_CANVAS_SETTING_KEYS);
|
const { settings, valueByKey } = await readScopedSiteSettings([
|
||||||
|
REALTIME_CANVAS_CONFIG_KEY,
|
||||||
|
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
|
||||||
|
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
|
||||||
|
LAYER_SPLIT_CONFIG_KEY,
|
||||||
|
ANGLE_EDIT_CONFIG_KEY,
|
||||||
|
CANVAS_AGENT_MODE_ENABLED_KEY,
|
||||||
|
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
|
||||||
|
VIDEO_CANVAS_ENABLED_KEY,
|
||||||
|
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
|
||||||
|
ENHANCE_PRO_KREA_SETTING_KEY,
|
||||||
|
AI_WASH_CONFIG_KEY,
|
||||||
|
STYLE_PRO_CONFIG_KEY,
|
||||||
|
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
||||||
|
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
|
||||||
|
]);
|
||||||
const canvasFeatureAvailability = await readCanvasFeatureAvailability(
|
const canvasFeatureAvailability = await readCanvasFeatureAvailability(
|
||||||
valueByKey
|
valueByKey
|
||||||
);
|
);
|
||||||
@ -788,8 +761,6 @@ async function getScopedPublicSiteSettings(
|
|||||||
const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled(
|
const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled(
|
||||||
valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY)
|
valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY)
|
||||||
);
|
);
|
||||||
const rawUiConfig = valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY) as
|
|
||||||
Partial<AgentWorkflowUiConfig> | undefined;
|
|
||||||
return createScopedSiteSettingsResponse(scope, {
|
return createScopedSiteSettingsResponse(scope, {
|
||||||
...createBrandingSettings(typedSettings),
|
...createBrandingSettings(typedSettings),
|
||||||
agentEnabled: typedSettings?.agentEnabled ?? true,
|
agentEnabled: typedSettings?.agentEnabled ?? true,
|
||||||
@ -825,18 +796,12 @@ async function getScopedPublicSiteSettings(
|
|||||||
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
|
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
|
||||||
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
|
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
|
||||||
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
|
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
|
||||||
scope === "agent-entry"
|
valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY)
|
||||||
? {
|
|
||||||
steps: [],
|
|
||||||
pageFeatureSettings: rawUiConfig?.pageFeatureSettings,
|
|
||||||
businessModeSettings: rawUiConfig?.businessModeSettings,
|
|
||||||
}
|
|
||||||
: rawUiConfig
|
|
||||||
),
|
),
|
||||||
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
|
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
|
||||||
valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)
|
valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)
|
||||||
),
|
),
|
||||||
}, "MISS", options.cacheKey || scope, { etag: options.etag });
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === "branding") {
|
if (scope === "branding") {
|
||||||
@ -1090,23 +1055,13 @@ export async function GET(request: NextRequest) {
|
|||||||
const cacheKey = brandName.trim()
|
const cacheKey = brandName.trim()
|
||||||
? `${scopedScope}:${brandName.trim().toLowerCase()}`
|
? `${scopedScope}:${brandName.trim().toLowerCase()}`
|
||||||
: scopedScope;
|
: scopedScope;
|
||||||
const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry"
|
|
||||||
? await getAgentCanvasSettingsEtag(scopedScope)
|
|
||||||
: undefined;
|
|
||||||
if (etag && request.headers.get("If-None-Match") === etag) {
|
|
||||||
return new NextResponse(null, {
|
|
||||||
status: 304,
|
|
||||||
headers: { ETag: etag, "Cache-Control": "private, no-cache" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const cached = getScopedSiteSettingsCache(cacheKey);
|
const cached = getScopedSiteSettingsCache(cacheKey);
|
||||||
if (cached && cached.expiresAt > Date.now() && (!etag || cached.etag === etag)) {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
return createScopedSiteSettingsResponse(
|
return createScopedSiteSettingsResponse(
|
||||||
scopedScope,
|
scopedScope,
|
||||||
cached.data,
|
cached.data,
|
||||||
"HIT",
|
"HIT",
|
||||||
cacheKey,
|
cacheKey
|
||||||
{ etag }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (cached && scopedScope !== "home") {
|
if (cached && scopedScope !== "home") {
|
||||||
@ -1117,7 +1072,6 @@ export async function GET(request: NextRequest) {
|
|||||||
const responsePromise = getScopedPublicSiteSettings(scopedScope, {
|
const responsePromise = getScopedPublicSiteSettings(scopedScope, {
|
||||||
brandName,
|
brandName,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
etag,
|
|
||||||
});
|
});
|
||||||
return scopedScope === "home"
|
return scopedScope === "home"
|
||||||
? await withTimeout(responsePromise, 1600, "site-settings:home")
|
? await withTimeout(responsePromise, 1600, "site-settings:home")
|
||||||
@ -1587,7 +1541,7 @@ export async function GET(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
...resolveSiteBranding(baseSettings),
|
...baseSettings,
|
||||||
lovartPromoText:
|
lovartPromoText:
|
||||||
baseSettings.lovartPromoText ||
|
baseSettings.lovartPromoText ||
|
||||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -48,13 +48,8 @@ export default function CanvasStyleProPage() {
|
|||||||
const canvasData = result.data.canvasData;
|
const canvasData = result.data.canvasData;
|
||||||
const latestState = useCanvasStore.getState();
|
const latestState = useCanvasStore.getState();
|
||||||
setProject(result.data.id, result.data.title);
|
setProject(result.data.id, result.data.title);
|
||||||
if (
|
if (latestState.nodes.length === 0 && latestState.edges.length === 0) {
|
||||||
latestState.projectId !== id ||
|
|
||||||
(latestState.nodes.length === 0 && latestState.edges.length === 0)
|
|
||||||
) {
|
|
||||||
loadCanvas(canvasData);
|
loadCanvas(canvasData);
|
||||||
const revision = String(result.data.updatedAt || "");
|
|
||||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { FlowBoardClient } from "@/components/flow-board/flow-board-client";
|
|
||||||
|
|
||||||
export default function FlowBoardPreviewPage() {
|
|
||||||
if (process.env.NODE_ENV === "production") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlowBoardClient
|
|
||||||
previewMode
|
|
||||||
siteLogo={null}
|
|
||||||
siteLogoDark={null}
|
|
||||||
siteName="SnapGen"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
298
app/globals.css
298
app/globals.css
@ -519,301 +519,3 @@
|
|||||||
padding: 0 12px 12px;
|
padding: 0 12px 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lovart surface typography and controls, scoped to the home and image canvas. */
|
|
||||||
.lovart-home-surface,
|
|
||||||
.lovart-canvas-surface {
|
|
||||||
font-family: Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lovart-suggestion {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 8px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border: 1px solid #e5e5e5;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: white;
|
|
||||||
color: #333;
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: background-color 150ms, border-color 150ms;
|
|
||||||
}
|
|
||||||
.lovart-suggestion:hover { background: #f7f7f7; border-color: #d5d5d5; }
|
|
||||||
.lovart-suggestion:focus-visible,
|
|
||||||
.lovart-start-action:focus-visible { outline: 2px solid #8c8c8c; outline-offset: 3px; }
|
|
||||||
|
|
||||||
.lovart-start-action {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 9px;
|
|
||||||
min-width: 172px;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 16px;
|
|
||||||
border: 1px solid #e5e5e5;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: white;
|
|
||||||
color: #333;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 400;
|
|
||||||
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
|
|
||||||
transition: background-color 150ms, border-color 150ms;
|
|
||||||
}
|
|
||||||
.lovart-start-action:hover { background: #f7f7f7; border-color: #d4d4d4; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-bottom: 0; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer { padding: 0 8px 8px; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] {
|
|
||||||
min-height: 120px;
|
|
||||||
border-color: #e5e5e5;
|
|
||||||
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
|
|
||||||
}
|
|
||||||
/* The promo backplate continues underneath the composer's rounded corners. */
|
|
||||||
.lovart-chat-promo-connected { padding: 0 8px; }
|
|
||||||
.lovart-chat-promo-surface { padding-bottom: 24px; border-radius: 24px 24px 0 0; background: #ebebeb; }
|
|
||||||
.lovart-chat-promo-row { display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 10px 0 16px; }
|
|
||||||
.lovart-chat-promo-message { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; padding: 0; border: 0; background: transparent; color: #202020; font-size: 14px; font-weight: 400; line-height: 20px; text-align: left; cursor: pointer; }
|
|
||||||
.lovart-chat-promo-message:disabled { cursor: default; opacity: 1; }
|
|
||||||
.lovart-chat-promo-message > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.lovart-chat-promo-badge { flex-shrink: 0; }
|
|
||||||
.lovart-chat-promo-close { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; border: 0; border-radius: 50%; background: transparent; color: #262626; cursor: pointer; }
|
|
||||||
.lovart-chat-promo-close:hover { background: rgb(0 0 0 / 5%); }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-promo-connected + .lovart-chat-composer { position: relative; z-index: 1; margin-top: -24px; background: transparent; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor][data-canvas-composer-mode="image"] { min-height: 184px; border-radius: 24px; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-upload { margin: 8px 8px 0; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile { flex-direction: column; gap: 8px; width: 56px; height: 68px; border: 0; border-radius: 18px; background: #f5f5f5; color: #aaa; font-size: 12px; font-weight: 400; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile:hover { background: #ececec; color: #777; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile > svg { width: 14px; height: 14px; stroke-width: 1.5; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-text-region { padding: 12px 16px 0; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] [role="textbox"] { min-height: 40px; font-size: 14px; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] [role="textbox"]:empty::before { color: #b0b0b0; }
|
|
||||||
.dark .lovart-chat-promo-surface { background: #27272a; }
|
|
||||||
.dark .lovart-chat-promo-message, .dark .lovart-chat-promo-close { color: #eee; }
|
|
||||||
.dark .lovart-chat-promo-close:hover { background: rgb(255 255 255 / 10%); }
|
|
||||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile { background: #27272a; color: #a1a1aa; }
|
|
||||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile:hover { background: #3f3f46; color: #eee; }
|
|
||||||
.dark .lovart-suggestion,
|
|
||||||
.dark .lovart-start-action { border-color: rgb(255 255 255 / 12%); background: #18181b; color: #eee; }
|
|
||||||
.dark .lovart-suggestion:hover,
|
|
||||||
.dark .lovart-start-action:hover { background: #27272a; border-color: rgb(255 255 255 / 22%); }
|
|
||||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-color: rgb(255 255 255 / 10%); }
|
|
||||||
.dark [data-canvas-right-panel="true"] > .border-l { border-left-color: #27272a; }
|
|
||||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] { border-color: rgb(255 255 255 / 12%); }
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.lovart-home-surface main { padding-top: calc(72px + var(--home-banner-offset, 0px)); }
|
|
||||||
.lovart-hero { margin-top: 16px; }
|
|
||||||
.lovart-home-composer { border-radius: 20px; }
|
|
||||||
.lovart-suggestion { padding: 0 11px; font-size: 12px; }
|
|
||||||
.lovart-start-action { min-width: 160px; }
|
|
||||||
.lovart-chat-welcome h1 { font-size: 15px; }
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.lovart-home-surface *, .lovart-canvas-surface * { scroll-behavior: auto !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.lovart-home-surface :is(button, input, textarea, select),
|
|
||||||
.lovart-canvas-surface :is(button, input, textarea, select) { font-family: inherit; }
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.lovart-canvas-surface[data-canvas-panel-open="true"] :is([data-canvas-topbar], [data-canvas-floating-toolbar], [data-canvas-compact-controls]) { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reference icon sizing is independent of the application's root rem scale. */
|
|
||||||
.lovart-suggestion { gap: 8px; padding: 0 12px; }
|
|
||||||
.lovart-start-action { min-width: 172.71875px; gap: 8px; padding: 0 12px; }
|
|
||||||
.lovart-canvas-surface [data-canvas-floating-toolbar] button { min-width: 32px; height: 32px; border-radius: 8px; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector { width: 90px; height: 32px; padding: 0 8px; background: transparent; font-size: 14px; font-weight: 400; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector > span { font-size: 13px; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector:hover,
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-image-params:hover,
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector:hover { background: rgb(0 0 0 / 4%); }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-image-params { width: 105.3828125px; min-width: 105.3828125px; height: 32px; padding: 0 4px 0 12px; background: transparent; font-size: 13px; gap: 4px; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector { flex: 0 0 121px; width: 121px; height: 32px; padding: 0 4px 0 8px; gap: 0; border: 0; background: transparent; box-shadow: none; font-size: 13px; font-weight: 400; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > [data-reference-icon]:first-child { margin-right: 8px; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > span:not([data-reference-icon]) { max-width: 65px; }
|
|
||||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] :is(.lovart-model-selector, .lovart-image-params) { color: #eee; }
|
|
||||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] :is(.lovart-model-selector, .lovart-image-params, .lovart-composer-mode-selector):hover { background: rgb(255 255 255 / 8%); }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer { padding: 0 8px 8px; gap: 0; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer > div:first-child { flex: 0 0 90px; padding-right: 0; overflow: visible; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer > div:last-child { flex: 1 1 0; min-width: 0; gap: 0; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-send { min-width: 32px; width: auto; height: 32px; padding: 0 8px; gap: 2px; font-size: 13px; font-weight: 400; }
|
|
||||||
|
|
||||||
/* Adapt to the resizable chat panel, independently of the browser width. */
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] { container: canvas-composer / inline-size; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-footer { gap: 4px; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-footer > div:first-child { flex: 0 0 auto; overflow: visible; padding-right: 0; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-footer > div:last-child { flex: 0 1 auto; min-width: 0; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-mode-selector { width: auto; max-width: none; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-image-params { width: auto; min-width: 0; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-image-params-label { overflow: visible; text-overflow: clip; white-space: nowrap; }
|
|
||||||
.lovart-image-params-icon { display: none; }
|
|
||||||
@container canvas-composer (max-width: 480px) {
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-mode-selector { width: 32px; min-width: 32px; padding: 0; }
|
|
||||||
.lovart-composer-mode-selector .lovart-composer-mode-label,
|
|
||||||
.lovart-composer-mode-selector [data-reference-icon="chevron-down"] { display: none; }
|
|
||||||
.lovart-composer-mode-selector > span > :first-child { margin-right: 0; }
|
|
||||||
}
|
|
||||||
@container canvas-composer (max-width: 360px) {
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-model-selector { flex: 0 0 32px; width: 32px; padding: 0; justify-content: center; }
|
|
||||||
.lovart-model-selector > span:not([data-reference-icon]),
|
|
||||||
.lovart-model-selector > [data-reference-icon="chevron-down"] { display: none; }
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > [data-reference-icon]:first-child { margin-right: 0; }
|
|
||||||
}
|
|
||||||
@container canvas-composer (max-width: 480px) {
|
|
||||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-image-params { width: 32px; min-width: 32px; padding: 0; justify-content: center; }
|
|
||||||
.lovart-image-params .lovart-image-params-icon { display: block; }
|
|
||||||
.lovart-image-params .lovart-image-params-label,
|
|
||||||
.lovart-image-params > [data-reference-icon="chevron-down"] { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The existing image toolbar is scaled to 80%; these dimensions render at
|
|
||||||
the reference's 44px bar, 32px buttons, and 20px icons. */
|
|
||||||
.lovart-image-action-bar {
|
|
||||||
height: 55px;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 0 7.5px;
|
|
||||||
border: 1.25px solid rgba(0, 0, 0, 0.07);
|
|
||||||
border-radius: 17.5px;
|
|
||||||
background: #fff !important;
|
|
||||||
color: #000;
|
|
||||||
box-shadow: 0 1.25px 2.5px rgba(0, 0, 0, 0.04);
|
|
||||||
backdrop-filter: none;
|
|
||||||
}
|
|
||||||
.lovart-image-action-bar::before { display: none; }
|
|
||||||
.lovart-image-action-bar > button {
|
|
||||||
width: 40px;
|
|
||||||
min-width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
margin: 0;
|
|
||||||
gap: 0;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: transparent;
|
|
||||||
color: #000;
|
|
||||||
font-size: 0;
|
|
||||||
font-weight: 400;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.lovart-image-action-bar > button:hover,
|
|
||||||
.lovart-image-action-bar > button[data-state="open"],
|
|
||||||
.lovart-image-action-bar > button[data-reference-active="true"] {
|
|
||||||
background: rgba(0, 0, 0, 0.04);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
.lovart-image-action-bar > button:disabled {
|
|
||||||
background: transparent;
|
|
||||||
color: rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
.lovart-image-action-bar > button > svg,
|
|
||||||
.lovart-image-action-bar > button > [data-reference-icon],
|
|
||||||
.lovart-image-action-bar > button > span[aria-hidden="true"],
|
|
||||||
.lovart-image-action-bar > button > img {
|
|
||||||
width: 25px;
|
|
||||||
height: 25px;
|
|
||||||
margin: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.lovart-image-action-bar > button > img { filter: brightness(0); }
|
|
||||||
.lovart-image-action-bar > div.h-px,
|
|
||||||
.lovart-image-action-bar > div.w-px {
|
|
||||||
width: 1.25px;
|
|
||||||
height: 55px;
|
|
||||||
margin: 0 2.5px;
|
|
||||||
background: rgba(0, 0, 0, 0.07);
|
|
||||||
}
|
|
||||||
.lovart-image-action-menu [role="menuitem"] > svg,
|
|
||||||
.lovart-image-action-menu [role="menuitem"] > [data-reference-icon] {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
.dark .lovart-image-action-bar {
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
background: #18191c !important;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.dark .lovart-image-action-bar > button { color: #fff; }
|
|
||||||
.dark .lovart-image-action-bar > button:hover,
|
|
||||||
.dark .lovart-image-action-bar > button[data-state="open"],
|
|
||||||
.dark .lovart-image-action-bar > button[data-reference-active="true"] {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.dark .lovart-image-action-bar > button:disabled { color: rgba(255, 255, 255, 0.3); }
|
|
||||||
.dark .lovart-image-action-bar > button > img { filter: brightness(0) invert(1); }
|
|
||||||
.dark .lovart-image-action-bar > div.w-px { background: rgba(255, 255, 255, 0.1); }
|
|
||||||
|
|
||||||
.lovart-image-action-bar[data-show-tool-names="true"] > button[data-tool-key] { width: auto; gap: 7.5px; padding: 0 12.5px; font-size: 16.25px; }
|
|
||||||
.lovart-image-tool-label { white-space: nowrap; }
|
|
||||||
.lovart-image-tool-tooltip { z-index: 10030; border: 0; border-radius: 6px; background: #1b1b1b; padding: 6px 9px; color: #fff; font-size: 13px; line-height: 18px; box-shadow: 0 4px 10px #00000012; --tw-ring-shadow: 0 0 transparent; }
|
|
||||||
.lovart-image-custom-more { min-width: 172px; padding: 6px 12px; border: 1px solid #e8e8e8; border-radius: 12px; background: #fff; }
|
|
||||||
.lovart-image-custom-more [role="menuitem"] { min-height: 32px; gap: 10px; padding: 4px 0; font-size: 14px; font-weight: 400; }
|
|
||||||
.lovart-image-custom-more .lovart-customize-entry { justify-content: center; gap: 5px; }
|
|
||||||
.lovart-toolbar-customizer-overlay { z-index: 10010; background: #0006; }
|
|
||||||
.lovart-toolbar-customizer { z-index: 10020; display: flex; flex-direction: column; width: min(720px, calc(100vw - 32px)); max-width: 720px; max-height: calc(100dvh - 48px); gap: 0; padding: 20px 24px 24px; border: 1px solid #e5e5e5; border-radius: 16px; background: #fff; color: #242424; box-shadow: 0 8px 24px #00000012; }
|
|
||||||
.lovart-customizer-header { display: flex; flex-shrink: 0; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
|
||||||
.lovart-customizer-header h2 { margin: 0; font-size: 18px; line-height: 24px; font-weight: 600; }
|
|
||||||
.lovart-customizer-header p { margin-top: 2px; color: inherit; font-size: 14px; line-height: 22px; }
|
|
||||||
.lovart-customizer-header-actions { display: flex; gap: 4px; }
|
|
||||||
.lovart-customizer-header-actions button { display: flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 7px; }
|
|
||||||
.lovart-customizer-header-actions button:last-child { background: #f1f1f1; }
|
|
||||||
.lovart-customizer-header-actions button:hover { background: #e9e9e9; }
|
|
||||||
.lovart-customizer-body { min-height: 0; overflow-y: auto; margin: 24px 0 20px; }
|
|
||||||
.lovart-customizer-preview { display: flex; height: 180px; align-items: center; overflow: hidden; padding: 16px; border-radius: 10px; background: #e5edef url("/icons/lovart/toolbar-preview.png") center / cover; }
|
|
||||||
.lovart-customizer-preview-bar { display: flex; align-items: center; flex: 1; min-width: 0; gap: 4px; padding: 5px 8px; border: 1px solid #e5e5e5; border-radius: 14px; background: #fff; overflow-x: auto; scrollbar-width: thin; }
|
|
||||||
.lovart-preview-fixed,.lovart-preview-chip { display: flex; align-items: center; flex-shrink: 0; height: 32px; gap: 8px; padding: 0 10px; font-size: 13px; white-space: nowrap; }
|
|
||||||
.lovart-preview-chip { position: relative; margin-top: 5px; margin-bottom: 5px; border-radius: 8px; background: #f4f4f4; cursor: grab; }
|
|
||||||
.lovart-preview-chip:active { cursor: grabbing; }
|
|
||||||
.lovart-preview-chip > button:last-child { position: absolute; top: -5px; right: -3px; display: flex; width: 16px; height: 16px; align-items: center; justify-content: center; border: 1px solid #e6e6e6; border-radius: 50%; background: #fff; color: #888; }
|
|
||||||
.lovart-customizer-tools { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; margin-top: 24px; }
|
|
||||||
.lovart-customizer-tools > button { display: flex; align-items: center; gap: 10px; height: 36px; min-width: 0; padding: 0 12px; border: 1px solid #e2e2e2; border-radius: 8px; background: #fff; font-size: 14px; text-align: left; }
|
|
||||||
.lovart-customizer-tools > button > span:not([aria-hidden]) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.lovart-customizer-tools > button > svg:last-child { flex-shrink: 0; }
|
|
||||||
.lovart-customizer-tools > button:hover { background: #f7f7f7; }
|
|
||||||
.lovart-customizer-footer { display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; gap: 16px; }
|
|
||||||
.lovart-customizer-footer > label,.lovart-customizer-footer > div { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
|
||||||
.lovart-customizer-switch { width: 32px; height: 18px; padding: 2px; border-radius: 20px; background: #d5d5d5; }
|
|
||||||
.lovart-customizer-switch > span { display: block; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .15s; }
|
|
||||||
.lovart-customizer-switch[aria-checked="true"] { background: #2c2c2c; }
|
|
||||||
.lovart-customizer-switch[aria-checked="true"] > span { transform: translateX(14px); }
|
|
||||||
.lovart-customizer-footer > div > button { width: 80px; height: 36px; border: 1px solid #e3e3e3; border-radius: 8px; font-size: 14px; }
|
|
||||||
.lovart-customizer-footer .lovart-customizer-save { border-color: #2c2c2c; background: #2c2c2c; color: #fff; }
|
|
||||||
.lovart-customizer-footer > div > button:hover { opacity: .8; }
|
|
||||||
.lovart-toolbar-customizer button:focus-visible,.lovart-preview-chip:focus-visible { outline: 2px solid #397dff; outline-offset: 2px; }
|
|
||||||
.dark .lovart-toolbar-customizer,.dark .lovart-image-custom-more { background: #202124; border-color: #3d3e40; color: #f5f5f5; }
|
|
||||||
.dark .lovart-customizer-preview-bar,.dark .lovart-customizer-tools > button { background: #26272a; border-color: #46474a; }
|
|
||||||
.dark .lovart-preview-chip,.dark .lovart-customizer-header-actions button:last-child { background: #35363a; }
|
|
||||||
.dark .lovart-preview-chip > button:last-child { background: #26272a; border-color: #46474a; color: #ccc; }
|
|
||||||
.dark .lovart-customizer-tools > button:hover { background: #35363a; }
|
|
||||||
@media(max-width:600px) { .lovart-toolbar-customizer { padding: 18px; } .lovart-customizer-tools { grid-template-columns: repeat(2,minmax(0,1fr)); } .lovart-customizer-preview { height: 140px; } }
|
|
||||||
|
|
||||||
.lovart-preview-handle { display: flex; align-items: center; gap: 8px; white-space: nowrap; cursor: grab; }
|
|
||||||
|
|
||||||
.lovart-image-tool-tooltip { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.lovart-image-tool-cost { margin-left: auto; padding: 0; background: transparent; color: #999; font-size: 12px; font-weight: 400; box-shadow: none; }
|
|
||||||
.lovart-image-tool-cost > svg { width: 12px; height: 12px; }
|
|
||||||
|
|
||||||
/* Preserve the canvas's own skills as the primary welcome content. */
|
|
||||||
.canvas-welcome-action {
|
|
||||||
display: inline-flex;
|
|
||||||
min-height: 36px;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border: 1px solid #e5e5e5;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: white;
|
|
||||||
color: #333;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 16px;
|
|
||||||
transition: background-color 150ms, border-color 150ms;
|
|
||||||
}
|
|
||||||
.canvas-welcome-action:hover { background: #f7f7f7; border-color: #d4d4d4; }
|
|
||||||
.canvas-welcome-action:focus-visible { outline: 2px solid #8c8c8c; outline-offset: 3px; }
|
|
||||||
.dark .canvas-welcome-action { border-color: rgb(255 255 255 / 12%); background: #18181b; color: #eee; }
|
|
||||||
.dark .canvas-welcome-action:hover { background: #27272a; border-color: rgb(255 255 255 / 22%); }
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { Toaster } from "sonner";
|
|||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { FaviconUpdater } from "@/components/favicon-updater";
|
import { FaviconUpdater } from "@/components/favicon-updater";
|
||||||
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
|
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
|
||||||
import { AppUpdateNotice } from "@/components/app-update-notice";
|
|
||||||
import { getSiteSettings } from "@/lib/db/queries";
|
import { getSiteSettings } from "@/lib/db/queries";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@ -95,32 +94,17 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep one attempt per route for the browser session. A cooldown alone can
|
function safeRemoveSessionStorage(key) {
|
||||||
// turn a recurring editor exception into an endless reload cycle.
|
try {
|
||||||
var RECOVERY_KEY = 'chunk-reload-attempted-v3:' + window.location.pathname;
|
sessionStorage.removeItem(key);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var RECOVERY_KEY = 'chunk-reload-attempted-v2';
|
||||||
var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0;
|
var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0;
|
||||||
var recoveryNoticeShown = false;
|
|
||||||
|
|
||||||
function isFlowBoardEditing() {
|
|
||||||
var path = window.location.pathname;
|
|
||||||
return path === '/flow-board' || path.indexOf('/flow-board/') === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showManualRecoveryNotice() {
|
|
||||||
if (recoveryNoticeShown) return;
|
|
||||||
recoveryNoticeShown = true;
|
|
||||||
function show() {
|
|
||||||
if (!document.body || !isFlowBoardEditing()) return;
|
|
||||||
var notice = document.createElement('div');
|
|
||||||
notice.setAttribute('role', 'alert');
|
|
||||||
notice.setAttribute('data-runtime-recovery-notice', 'true');
|
|
||||||
notice.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:2147483647;max-width:calc(100vw - 32px);padding:12px 16px;border:1px solid var(--border,#e4e4e7);border-radius:12px;background:var(--background,#fff);color:var(--foreground,#27272a);box-shadow:0 4px 20px #0002;font:14px/1.5 system-ui;';
|
|
||||||
notice.textContent = '部分页面资源加载失败,请先保存白板后手动刷新页面。';
|
|
||||||
document.body.appendChild(notice);
|
|
||||||
}
|
|
||||||
if (document.body) show();
|
|
||||||
else window.addEventListener('DOMContentLoaded', show, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldAutoRecover(message) {
|
function shouldAutoRecover(message) {
|
||||||
if (!message) return false;
|
if (!message) return false;
|
||||||
@ -133,26 +117,20 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
|||||||
normalized.indexOf('error loading dynamically imported module') !== -1 ||
|
normalized.indexOf('error loading dynamically imported module') !== -1 ||
|
||||||
normalized.indexOf('importing a module script failed') !== -1 ||
|
normalized.indexOf('importing a module script failed') !== -1 ||
|
||||||
normalized.indexOf('module script load failed') !== -1 ||
|
normalized.indexOf('module script load failed') !== -1 ||
|
||||||
normalized.indexOf('failed to find server action') !== -1
|
normalized.indexOf('/_next/static/chunks/') !== -1 ||
|
||||||
|
normalized.indexOf('failed to find server action') !== -1 ||
|
||||||
|
normalized.indexOf('server components render') !== -1 ||
|
||||||
|
normalized.indexOf('application error: a client-side exception') !== -1
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerAutoRecover(reason) {
|
function triggerAutoRecover(reason) {
|
||||||
if (isFlowBoardEditing()) {
|
|
||||||
showManualRecoveryNotice();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lastReloadAt) return;
|
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
// If storage is blocked, a reload cannot remember its attempt and must not run.
|
if (lastReloadAt && now - lastReloadAt < 30000) return;
|
||||||
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
|
|
||||||
lastReloadAt = now;
|
lastReloadAt = now;
|
||||||
console.warn('[RuntimeRecover] 检测到页面资源加载失败,尝试恢复一次...', reason || '');
|
console.warn('[RuntimeRecover] 检测到前端运行时异常,自动刷新页面...', reason || '');
|
||||||
|
safeSetSessionStorage(RECOVERY_KEY, String(now));
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
if (isFlowBoardEditing()) {
|
|
||||||
showManualRecoveryNotice();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 800);
|
}, 800);
|
||||||
}
|
}
|
||||||
@ -160,6 +138,7 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
|||||||
window.addEventListener('error', function(event) {
|
window.addEventListener('error', function(event) {
|
||||||
var message = '';
|
var message = '';
|
||||||
if (event && event.message) message += event.message;
|
if (event && event.message) message += event.message;
|
||||||
|
if (event && event.filename) message += ' ' + event.filename;
|
||||||
if (event && event.error && event.error.message) {
|
if (event && event.error && event.error.message) {
|
||||||
message += ' ' + event.error.message;
|
message += ' ' + event.error.message;
|
||||||
}
|
}
|
||||||
@ -178,6 +157,12 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 页面稳定一段时间后清除标记,避免旧资源异常无限刷新。
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
safeRemoveSessionStorage(RECOVERY_KEY);
|
||||||
|
}, 120000);
|
||||||
|
});
|
||||||
})();`;
|
})();`;
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -216,7 +201,6 @@ export default function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
>
|
>
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
<AppUpdateNotice />
|
|
||||||
<SessionProvider>{children}</SessionProvider>
|
<SessionProvider>{children}</SessionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
309
auto-deploy.sh
309
auto-deploy.sh
@ -1,6 +1,303 @@
|
|||||||
#!/usr/bin/env bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
|
||||||
printf '%s\n' 'Legacy deployment entry point disabled: it targets an obsolete host/runtime.' >&2
|
# 自动部署脚本 - 包含密码
|
||||||
printf '%s\n' 'Use: python scripts/production48.py status' >&2
|
# 使用方法: ./auto-deploy.sh
|
||||||
printf '%s\n' 'Then follow docs/production-48.md for build, stage, activate and rollback.' >&2
|
|
||||||
exit 2
|
set -e
|
||||||
|
|
||||||
|
# 服务器配置
|
||||||
|
SERVER="156.225.27.52"
|
||||||
|
PORT="5204"
|
||||||
|
USER="root"
|
||||||
|
PASSWORD="${DEPLOY_SSH_PASSWORD:?set DEPLOY_SSH_PASSWORD}"
|
||||||
|
REMOTE_DIR="/var/www/ai-chatbot"
|
||||||
|
|
||||||
|
# 颜色输出
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo -e "${GREEN}开始自动部署到服务器...${NC}"
|
||||||
|
|
||||||
|
# 检查是否安装了 sshpass
|
||||||
|
if ! command -v sshpass &> /dev/null; then
|
||||||
|
echo -e "${YELLOW}安装 sshpass...${NC}"
|
||||||
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
# macOS
|
||||||
|
if ! command -v brew &> /dev/null; then
|
||||||
|
echo -e "${RED}请先安装 Homebrew: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
brew install hudochenkov/sshpass/sshpass
|
||||||
|
else
|
||||||
|
# Linux
|
||||||
|
sudo apt-get update && sudo apt-get install -y sshpass
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SSH 连接函数
|
||||||
|
ssh_cmd() {
|
||||||
|
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -p $PORT $USER@$SERVER "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
scp_cmd() {
|
||||||
|
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no -P $PORT "$1" $USER@$SERVER:"$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
scp_dir() {
|
||||||
|
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no -P $PORT -r "$1" $USER@$SERVER:"$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 1: 检查服务器连接...${NC}"
|
||||||
|
ssh_cmd "echo '连接成功' && uname -a"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 2: 检查并安装必要软件...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
# 检查 Node.js
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo '安装 Node.js...'
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||||
|
export NVM_DIR=\"\$HOME/.nvm\"
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
|
||||||
|
nvm install 20
|
||||||
|
nvm use 20
|
||||||
|
nvm alias default 20
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 pnpm
|
||||||
|
if ! command -v pnpm &> /dev/null; then
|
||||||
|
echo '安装 pnpm...'
|
||||||
|
npm install -g pnpm
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 PM2
|
||||||
|
if ! command -v pm2 &> /dev/null; then
|
||||||
|
echo '安装 PM2...'
|
||||||
|
npm install -g pm2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 PostgreSQL
|
||||||
|
if ! command -v psql &> /dev/null; then
|
||||||
|
echo '安装 PostgreSQL...'
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y postgresql postgresql-contrib
|
||||||
|
systemctl start postgresql
|
||||||
|
systemctl enable postgresql
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 Nginx
|
||||||
|
if ! command -v nginx &> /dev/null; then
|
||||||
|
echo '安装 Nginx...'
|
||||||
|
apt-get install -y nginx
|
||||||
|
systemctl start nginx
|
||||||
|
systemctl enable nginx
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建项目目录
|
||||||
|
mkdir -p $REMOTE_DIR
|
||||||
|
mkdir -p /var/log/pm2
|
||||||
|
mkdir -p /root/backups/ai-chatbot
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 3: 配置数据库...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
# 检查并创建/更新数据库用户
|
||||||
|
echo '配置数据库用户...'
|
||||||
|
sudo -u postgres psql << 'PSQL'
|
||||||
|
-- 删除已存在的用户(如果存在)
|
||||||
|
DROP USER IF EXISTS chatbot_user;
|
||||||
|
|
||||||
|
-- 创建新用户
|
||||||
|
CREATE USER chatbot_user WITH PASSWORD 'ChatBot@2024!Secure';
|
||||||
|
|
||||||
|
-- 检查数据库是否存在
|
||||||
|
SELECT 1 FROM pg_database WHERE datname='ai_chatbot'\\gexec
|
||||||
|
|
||||||
|
-- 如果不存在则创建
|
||||||
|
DO \\$\\$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'ai_chatbot') THEN
|
||||||
|
CREATE DATABASE ai_chatbot;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
\\$\\$;
|
||||||
|
|
||||||
|
-- 授予权限
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE ai_chatbot TO chatbot_user;
|
||||||
|
|
||||||
|
-- 连接到数据库并授予schema权限
|
||||||
|
\\c ai_chatbot
|
||||||
|
GRANT ALL ON SCHEMA public TO chatbot_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO chatbot_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO chatbot_user;
|
||||||
|
|
||||||
|
\\q
|
||||||
|
PSQL
|
||||||
|
echo '数据库配置完成'
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 4: 上传项目文件...${NC}"
|
||||||
|
# 创建临时部署包
|
||||||
|
echo "准备部署文件..."
|
||||||
|
tar --exclude='node_modules' \
|
||||||
|
--exclude='.next' \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.env.local' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
--exclude='deploy.tar.gz' \
|
||||||
|
-czf /tmp/deploy.tar.gz .
|
||||||
|
|
||||||
|
# 上传部署包
|
||||||
|
scp_cmd "/tmp/deploy.tar.gz" "/tmp/"
|
||||||
|
|
||||||
|
# 在服务器上解压
|
||||||
|
ssh_cmd "
|
||||||
|
cd $REMOTE_DIR
|
||||||
|
tar -xzf /tmp/deploy.tar.gz
|
||||||
|
rm /tmp/deploy.tar.gz
|
||||||
|
|
||||||
|
if [ -f \"$REMOTE_DIR/scripts/security/install-security-stack.sh\" ]; then
|
||||||
|
echo '安装 security-stack...'
|
||||||
|
bash \"$REMOTE_DIR/scripts/security/install-security-stack.sh\" || echo 'security-stack 安装失败,继续部署'
|
||||||
|
elif [ -f \"$REMOTE_DIR/scripts/security/install-perf-guard.sh\" ]; then
|
||||||
|
echo '安装 perf-guard...'
|
||||||
|
bash \"$REMOTE_DIR/scripts/security/install-perf-guard.sh\" || echo 'perf-guard 安装失败,继续部署'
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 5: 配置环境变量...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
cd $REMOTE_DIR
|
||||||
|
|
||||||
|
# 生成 AUTH_SECRET
|
||||||
|
AUTH_SECRET=\$(openssl rand -base64 32)
|
||||||
|
|
||||||
|
# 创建 .env.local 文件
|
||||||
|
cat > .env.local << EOF
|
||||||
|
POSTGRES_URL=postgresql://chatbot_user:ChatBot@2024!Secure@localhost:5432/ai_chatbot
|
||||||
|
AUTH_SECRET=\$AUTH_SECRET
|
||||||
|
AUTH_URL=http://$SERVER
|
||||||
|
NODE_ENV=production
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo '环境变量文件已创建'
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 6: 安装依赖和构建...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
cd $REMOTE_DIR
|
||||||
|
export NVM_DIR=\"\$HOME/.nvm\"
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
|
||||||
|
|
||||||
|
echo '安装依赖...'
|
||||||
|
pnpm install --production=false
|
||||||
|
|
||||||
|
echo '运行数据库迁移...'
|
||||||
|
pnpm db:migrate || echo '迁移可能已存在,继续...'
|
||||||
|
|
||||||
|
echo '构建项目...'
|
||||||
|
pnpm build
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 7: 配置 PM2...${NC}"
|
||||||
|
# 上传 PM2 配置文件
|
||||||
|
scp_cmd "ecosystem.config.js" "$REMOTE_DIR/"
|
||||||
|
|
||||||
|
ssh_cmd "
|
||||||
|
cd $REMOTE_DIR
|
||||||
|
export NVM_DIR=\"\$HOME/.nvm\"
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
|
||||||
|
|
||||||
|
# 停止旧进程(如果存在)
|
||||||
|
pm2 delete ai-chatbot 2>/dev/null || true
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
pm2 save
|
||||||
|
|
||||||
|
# 设置开机自启
|
||||||
|
pm2 startup systemd -u root --hp /root 2>/dev/null || echo 'PM2 startup 已配置'
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 8: 配置 Nginx...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
# 创建 Nginx 配置
|
||||||
|
cat > /etc/nginx/sites-available/ai-chatbot << 'NGINX'
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name $SERVER;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_cache_bypass \$http_upgrade;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
|
||||||
|
# 启用站点
|
||||||
|
ln -sf /etc/nginx/sites-available/ai-chatbot /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
# 删除默认配置(如果存在)
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
|
# 测试配置
|
||||||
|
nginx -t
|
||||||
|
|
||||||
|
# 重启 Nginx
|
||||||
|
systemctl restart nginx
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 9: 配置防火墙...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
ufw --force enable
|
||||||
|
ufw allow 80/tcp
|
||||||
|
ufw allow 443/tcp
|
||||||
|
ufw allow 5204/tcp
|
||||||
|
ufw allow 3000/tcp
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 10: 检查部署状态...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
echo '=== PM2 状态 ==='
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== 应用日志(最后20行)==='
|
||||||
|
pm2 logs ai-chatbot --lines 20 --nostream
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== Nginx 状态 ==='
|
||||||
|
systemctl status nginx --no-pager -l
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== 端口监听 ==='
|
||||||
|
netstat -tulpn | grep -E ':(80|3000|5432)' || ss -tulpn | grep -E ':(80|3000|5432)'
|
||||||
|
"
|
||||||
|
|
||||||
|
# 清理本地临时文件
|
||||||
|
rm -f /tmp/deploy.tar.gz
|
||||||
|
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${GREEN}部署完成!${NC}"
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${YELLOW}访问地址: http://$SERVER${NC}"
|
||||||
|
echo -e "${YELLOW}管理员设置: http://$SERVER/admin/settings${NC}"
|
||||||
|
echo -e "${YELLOW}查看日志: ssh -p $PORT $USER@$SERVER 'pm2 logs ai-chatbot'${NC}"
|
||||||
|
echo -e "${YELLOW}查看状态: ssh -p $PORT $USER@$SERVER 'pm2 status'${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}数据库信息:${NC}"
|
||||||
|
echo -e " 数据库名: ai_chatbot"
|
||||||
|
echo -e " 用户名: chatbot_user"
|
||||||
|
echo -e " 密码: ChatBot@2024!Secure"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}注意: 请尽快修改数据库密码和服务器密码!${NC}"
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useEffect, useRef, type ReactNode } from "react";
|
|
||||||
|
|
||||||
export function AdminSurface({ children }: { children: ReactNode }) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
||||||
let context: { revert: () => void } | undefined;
|
|
||||||
let disposed = false;
|
|
||||||
import("gsap").then(({ gsap }) => {
|
|
||||||
if (disposed) return;
|
|
||||||
context = gsap.context(() => {
|
|
||||||
gsap.fromTo(
|
|
||||||
ref.current,
|
|
||||||
{ y: 6, opacity: 0.85 },
|
|
||||||
{ y: 0, opacity: 1, duration: 0.25, ease: "power3.out", clearProps: "transform,opacity" },
|
|
||||||
);
|
|
||||||
}, ref);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
context?.revert();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="space-y-6">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,603 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import {
|
|
||||||
RefreshCw,
|
|
||||||
Search,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
ExternalLink,
|
|
||||||
Download,
|
|
||||||
Star,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { MediaThumbnail } from "./media-thumbnail";
|
|
||||||
import { AdminSurface } from "./admin-surface";
|
|
||||||
import { formatRecordTime } from "@/lib/admin/record-time";
|
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
|
||||||
|
|
||||||
export type MediaRecord = {
|
|
||||||
id: string;
|
|
||||||
recordType: "image" | "video";
|
|
||||||
userName: string | null;
|
|
||||||
userEmail: string | null;
|
|
||||||
userDepartment: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
modelId: string | null;
|
|
||||||
chatId: string | null;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
originalUrl: string;
|
|
||||||
videoUrl?: string | null;
|
|
||||||
prompt: string | null;
|
|
||||||
imageSize: string | null;
|
|
||||||
isFeatured: boolean;
|
|
||||||
featuredCategory: string | null;
|
|
||||||
featuredAt?: string | null;
|
|
||||||
};
|
|
||||||
type MediaResponse = {
|
|
||||||
images: MediaRecord[];
|
|
||||||
pagination: {
|
|
||||||
total: number;
|
|
||||||
totalPages: number;
|
|
||||||
totalImages: number;
|
|
||||||
totalVideos: number;
|
|
||||||
page: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
export async function adminFetcher(url: string) {
|
|
||||||
const response = await fetch(url);
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok) throw new Error(data.error || data.message || "加载失败,请重试");
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
const selectClass =
|
|
||||||
"h-10 rounded-md border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
|
||||||
function recentDate() {
|
|
||||||
const d = new Date();
|
|
||||||
d.setDate(d.getDate() - 6);
|
|
||||||
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MediaRecordsPanel({ categories }: { categories: string[] }) {
|
|
||||||
const [view, setView] = useState("assets");
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
search: "",
|
|
||||||
type: "all",
|
|
||||||
startDate: recentDate(),
|
|
||||||
endDate: "",
|
|
||||||
model: "",
|
|
||||||
featured: "false",
|
|
||||||
status: "all",
|
|
||||||
});
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [selected, setSelected] = useState<MediaRecord | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [originalFailed, setOriginalFailed] = useState(false);
|
|
||||||
const update = (changes: Partial<typeof filters>) => {
|
|
||||||
setFilters((f) => ({ ...f, ...changes }));
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setFilters((f) => (f.search === search ? f : { ...f, search }));
|
|
||||||
setPage(1);
|
|
||||||
}, 350);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [search]);
|
|
||||||
const { data: options } = useSWR<{
|
|
||||||
models: Array<{ id: string; modelId: string; displayName: string; providerName: string }>;
|
|
||||||
}>("/api/admin/ai-models?view=options", adminFetcher, { revalidateOnFocus: false });
|
|
||||||
const modelNames = new Map<string, string>();
|
|
||||||
for (const model of options?.models || [])
|
|
||||||
if (!modelNames.has(model.modelId)) modelNames.set(model.modelId, model.displayName);
|
|
||||||
const recordModelNames = new Map(modelNames);
|
|
||||||
for (const model of options?.models || []) recordModelNames.set(model.id, model.displayName);
|
|
||||||
const openDetails = (item: MediaRecord) => {
|
|
||||||
setSelected(item);
|
|
||||||
setOriginalFailed(false);
|
|
||||||
};
|
|
||||||
const query = new URLSearchParams({ ...filters, page: String(page), pageSize: "40" });
|
|
||||||
const { data, error, isLoading, isValidating, mutate } = useSWR<MediaResponse>(
|
|
||||||
view === "assets" ? `/api/admin/images?${query}` : null,
|
|
||||||
adminFetcher,
|
|
||||||
{ keepPreviousData: true, revalidateOnFocus: false },
|
|
||||||
);
|
|
||||||
const tasks = useSWR<{
|
|
||||||
tasks: Array<{
|
|
||||||
id: string;
|
|
||||||
userName: string;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
error: string | null;
|
|
||||||
model: string | null;
|
|
||||||
elapsedMs: number | null;
|
|
||||||
}>;
|
|
||||||
total: number;
|
|
||||||
}>(view === "tasks" ? `/api/admin/media-tasks?${query}` : null, adminFetcher, {
|
|
||||||
keepPreviousData: true,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
});
|
|
||||||
const busy = view === "assets" ? isValidating : tasks.isValidating;
|
|
||||||
const failure = view === "assets" ? error : tasks.error;
|
|
||||||
const loading = view === "assets" ? isLoading : tasks.isLoading;
|
|
||||||
const total = view === "assets" ? data?.pagination.total : tasks.data?.total;
|
|
||||||
const pages = Math.max(1, Math.ceil((total || 0) / 40));
|
|
||||||
async function save(changes: Partial<MediaRecord>) {
|
|
||||||
if (!selected || saving) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/images", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ imageId: selected.id, ...changes }),
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error("保存失败,请重试");
|
|
||||||
setSelected({ ...selected, ...changes });
|
|
||||||
toast.success("已保存");
|
|
||||||
await mutate();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<AdminSurface>
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">生成记录</h2>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
查看图片与视频产物,追踪画布任务。时间统一按北京时间展示。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`mr-2 size-4 ${busy ? "motion-safe:animate-spin" : ""}`} />
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2" role="tablist" aria-label="记录视图">
|
|
||||||
{[
|
|
||||||
["assets", "产物记录"],
|
|
||||||
["tasks", "画布任务"],
|
|
||||||
].map(([key, label]) => (
|
|
||||||
<Button
|
|
||||||
key={key}
|
|
||||||
role="tab"
|
|
||||||
aria-selected={view === key}
|
|
||||||
variant={view === key ? "default" : "ghost"}
|
|
||||||
onClick={() => {
|
|
||||||
setView(key);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-end gap-3 rounded-xl border bg-background p-4">
|
|
||||||
<label className="min-w-48 flex-1 text-xs font-medium">
|
|
||||||
用户
|
|
||||||
<div className="relative mt-2">
|
|
||||||
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
className="h-10 pl-9"
|
|
||||||
placeholder="搜索姓名或邮箱"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
{view === "assets" ? (
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
类型
|
|
||||||
<select
|
|
||||||
aria-label="产物类型"
|
|
||||||
className={`${selectClass} mt-2 block`}
|
|
||||||
value={filters.type}
|
|
||||||
onChange={(e) => update({ type: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="all">图片与视频</option>
|
|
||||||
<option value="image">只看图片</option>
|
|
||||||
<option value="video">只看视频</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
) : (
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
任务状态
|
|
||||||
<select
|
|
||||||
aria-label="任务状态"
|
|
||||||
className={`${selectClass} mt-2 block`}
|
|
||||||
value={filters.status}
|
|
||||||
onChange={(e) => update({ status: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="all">全部状态</option>
|
|
||||||
<option value="failed">失败</option>
|
|
||||||
<option value="running">运行中</option>
|
|
||||||
<option value="pending">排队中</option>
|
|
||||||
<option value="succeeded">成功</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
开始日期
|
|
||||||
<Input
|
|
||||||
aria-label="开始日期"
|
|
||||||
className="mt-2 h-10 w-40"
|
|
||||||
type="date"
|
|
||||||
value={filters.startDate}
|
|
||||||
onChange={(e) => update({ startDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
结束日期
|
|
||||||
<Input
|
|
||||||
aria-label="结束日期"
|
|
||||||
className="mt-2 h-10 w-40"
|
|
||||||
type="date"
|
|
||||||
value={filters.endDate}
|
|
||||||
onChange={(e) => update({ endDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setSearch("");
|
|
||||||
update({
|
|
||||||
search: "",
|
|
||||||
startDate: "",
|
|
||||||
endDate: "",
|
|
||||||
model: "",
|
|
||||||
featured: "false",
|
|
||||||
type: "all",
|
|
||||||
status: "all",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
全部时间 / 重置
|
|
||||||
</Button>
|
|
||||||
{view === "assets" && (
|
|
||||||
<div className="flex w-full flex-wrap items-center gap-3">
|
|
||||||
<select
|
|
||||||
aria-label="记录模型"
|
|
||||||
className={`${selectClass} max-w-full sm:max-w-96`}
|
|
||||||
value={filters.model}
|
|
||||||
onChange={(e) => update({ model: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">全部模型</option>
|
|
||||||
{Array.from(modelNames, ([id, name]) => (
|
|
||||||
<option key={id} value={id}>
|
|
||||||
{name} · {id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={filters.featured === "true"}
|
|
||||||
onChange={(e) => update({ featured: String(e.target.checked) })}
|
|
||||||
/>
|
|
||||||
只看精选
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-live="polite"
|
|
||||||
className="flex items-center justify-between text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{total === undefined ? "正在读取记录…" : `共 ${total.toLocaleString()} 条记录`}
|
|
||||||
{view === "assets" && data
|
|
||||||
? ` · 图片 ${data.pagination.totalImages.toLocaleString()} · 视频 ${data.pagination.totalVideos.toLocaleString()}`
|
|
||||||
: ""}
|
|
||||||
</span>
|
|
||||||
{busy && !loading && <span>正在更新…</span>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
第 {page} / {pages} 页 · 每页 40 条
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={busy || page === 1}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={busy || page >= pages}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{failure ? (
|
|
||||||
<div role="alert" className="rounded-xl border border-destructive/30 p-6 text-sm">
|
|
||||||
<p>{failure.message}</p>
|
|
||||||
<Button
|
|
||||||
className="mt-3"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
|
|
||||||
>
|
|
||||||
重试加载
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
|
||||||
<div role="status" className="space-y-3 rounded-xl border p-6">
|
|
||||||
{[0, 1, 2, 3].map((x) => (
|
|
||||||
<div key={x} className="h-16 rounded-lg bg-muted motion-safe:animate-pulse" />
|
|
||||||
))}
|
|
||||||
<span className="sr-only">正在加载记录</span>
|
|
||||||
</div>
|
|
||||||
) : total === 0 ? (
|
|
||||||
<div className="rounded-xl border border-dashed p-12 text-center text-sm text-muted-foreground">
|
|
||||||
没有符合条件的记录,请调整筛选条件。
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto rounded-xl border bg-background" aria-busy={busy}>
|
|
||||||
{view === "assets" ? (
|
|
||||||
<table className="w-full min-w-[720px] text-left text-sm">
|
|
||||||
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
{["产物", "用户", "生成时间", "模型 / 尺寸", "精选", ""].map((v, i) => (
|
|
||||||
<th key={i} className="px-4 py-3 font-medium">
|
|
||||||
{v}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{data?.images.map((item) => (
|
|
||||||
<tr key={item.id} className="hover:bg-muted/30">
|
|
||||||
<td className="p-4">
|
|
||||||
<div>
|
|
||||||
<MediaThumbnail
|
|
||||||
url={item.thumbnailUrl}
|
|
||||||
video={item.recordType === "video"}
|
|
||||||
onOpen={() => openDetails(item)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="font-medium">{item.userName || item.userEmail}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{item.userDepartment || "未设置部门"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap px-4 py-3 tabular-nums">
|
|
||||||
{formatRecordTime(item.createdAt)}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-64 px-4 py-3">
|
|
||||||
<div className="truncate" title={item.modelId || ""}>
|
|
||||||
{recordModelNames.get(item.modelId || "") || item.modelId || "未记录模型"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{item.recordType === "video" ? "视频" : "图片"} ·{" "}
|
|
||||||
{item.imageSize || "未知尺寸"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{item.isFeatured ? (
|
|
||||||
<span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
|
||||||
<Star className="size-3" />
|
|
||||||
{item.featuredCategory || "精选"}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => openDetails(item)}>
|
|
||||||
详情
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
) : (
|
|
||||||
<table className="w-full min-w-[720px] text-left text-sm">
|
|
||||||
<thead className="bg-muted/50">
|
|
||||||
<tr>
|
|
||||||
{["用户 / 时间", "状态", "模型", "耗时", "失败原因 / 任务编号"].map((x) => (
|
|
||||||
<th key={x} className="px-4 py-3 font-medium">
|
|
||||||
{x}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{tasks.data?.tasks.map((task) => (
|
|
||||||
<tr key={task.id}>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
{task.userName}
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{formatRecordTime(task.createdAt)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
{{ failed: "失败", running: "运行中", succeeded: "成功", pending: "排队中" }[
|
|
||||||
task.status
|
|
||||||
] || task.status}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-48 break-words px-4 py-4">{task.model || "自动选择"}</td>
|
|
||||||
<td className="px-4 py-4 tabular-nums">
|
|
||||||
{task.elapsedMs === null ? "—" : `${Math.round(task.elapsedMs / 1000)} 秒`}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-xl break-words px-4 py-4">
|
|
||||||
<p className={task.error ? "text-destructive" : "text-muted-foreground"}>
|
|
||||||
{task.error || "—"}
|
|
||||||
</p>
|
|
||||||
<code className="mt-2 block text-xs text-muted-foreground">{task.id}</code>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
每页 40 条 · 第 {page} / {pages} 页
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" disabled={busy || page === 1} onClick={() => setPage(1)}>
|
|
||||||
首页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
aria-label="上一页"
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy || page === 1}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
aria-label="下一页"
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy || page >= pages}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" disabled={busy || page >= pages} onClick={() => setPage(pages)}>
|
|
||||||
末页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Dialog
|
|
||||||
open={!!selected}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setSelected(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto bg-background">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{selected?.recordType === "video" ? "视频详情" : "图片详情"}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{selected && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex min-h-40 justify-center rounded-xl bg-muted">
|
|
||||||
{selected.recordType === "video" ? (
|
|
||||||
<video
|
|
||||||
src={selected.videoUrl || selected.originalUrl}
|
|
||||||
controls
|
|
||||||
preload="none"
|
|
||||||
className="max-h-[45vh] w-full"
|
|
||||||
/>
|
|
||||||
) : originalFailed ? (
|
|
||||||
<div role="alert" className="p-8 text-sm">
|
|
||||||
原图无法加载,源文件可能已过期或存储不可达。
|
|
||||||
<Button
|
|
||||||
className="ml-3"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOriginalFailed(false)}
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={selected.originalUrl}
|
|
||||||
alt="生成原图"
|
|
||||||
className="max-h-[45vh] object-contain"
|
|
||||||
onError={() => setOriginalFailed(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<dl className="grid gap-4 text-sm sm:grid-cols-2">
|
|
||||||
{[
|
|
||||||
["用户", selected.userName || selected.userEmail],
|
|
||||||
["部门", selected.userDepartment],
|
|
||||||
["生成时间", formatRecordTime(selected.createdAt)],
|
|
||||||
["尺寸", selected.imageSize],
|
|
||||||
["模型", recordModelNames.get(selected.modelId || "") || selected.modelId],
|
|
||||||
["精选时间", selected.featuredAt ? formatRecordTime(selected.featuredAt) : "—"],
|
|
||||||
].map(([k, v]) => (
|
|
||||||
<div key={k}>
|
|
||||||
<dt className="text-xs text-muted-foreground">{k}</dt>
|
|
||||||
<dd className="mt-1 break-words">{v || "未记录"}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium">提示词</h3>
|
|
||||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm text-muted-foreground">
|
|
||||||
{selected.prompt || "未记录提示词"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<a href={selected.originalUrl} target="_blank" rel="noreferrer">
|
|
||||||
<ExternalLink className="mr-2 size-4" />
|
|
||||||
打开原文件
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
{selected.recordType === "image" && (
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<a
|
|
||||||
href={buildImageProxyUrl(selected.originalUrl, { delivery: "original" })}
|
|
||||||
download
|
|
||||||
>
|
|
||||||
<Download className="mr-2 size-4" />
|
|
||||||
下载原图
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{selected.recordType === "image" && (
|
|
||||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border p-4">
|
|
||||||
<select
|
|
||||||
aria-label="精选分类"
|
|
||||||
className={selectClass}
|
|
||||||
value={selected.featuredCategory || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelected({ ...selected, featuredCategory: e.target.value || null })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">未分类</option>
|
|
||||||
{[...new Set([...categories, selected.featuredCategory].filter(Boolean))].map(
|
|
||||||
(c) => (
|
|
||||||
<option key={c} value={c!}>
|
|
||||||
{c}
|
|
||||||
</option>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => save({ featuredCategory: selected.featuredCategory })}
|
|
||||||
>
|
|
||||||
保存分类
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => save({ isFeatured: !selected.isFeatured })}
|
|
||||||
>
|
|
||||||
<Star className="mr-2 size-4" />
|
|
||||||
{selected.isFeatured ? "取消精选" : "设为精选"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</AdminSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { ImageOff, Video, RotateCw } from "lucide-react";
|
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
|
||||||
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
|
|
||||||
|
|
||||||
import { queuePreview } from "@/lib/admin/preview-queue";
|
|
||||||
|
|
||||||
export function MediaThumbnail({
|
|
||||||
url,
|
|
||||||
video = false,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
|
||||||
url: string;
|
|
||||||
video?: boolean;
|
|
||||||
onOpen?: () => void;
|
|
||||||
}) {
|
|
||||||
const container = useRef<HTMLDivElement>(null);
|
|
||||||
const [state, setState] = useState<"waiting" | "loading" | "ready" | "error">("waiting");
|
|
||||||
const [attempt, setAttempt] = useState(0);
|
|
||||||
const [source, setSource] = useState("");
|
|
||||||
const [failure, setFailure] = useState("");
|
|
||||||
const retired = isRetiredStorageUrl(url);
|
|
||||||
useEffect(() => {
|
|
||||||
setState("waiting");
|
|
||||||
setSource("");
|
|
||||||
setFailure("");
|
|
||||||
if (video || retired || !url || !container.current) return;
|
|
||||||
let dispose: (() => void) | undefined;
|
|
||||||
let abort = () => {};
|
|
||||||
let objectUrl = "";
|
|
||||||
let alive = true;
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (!entry.isIntersecting || dispose) return;
|
|
||||||
observer.disconnect();
|
|
||||||
dispose = queuePreview((release) => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 48000);
|
|
||||||
setState("loading");
|
|
||||||
// Fetch is abortable, so leaving a page also stops in-flight previews.
|
|
||||||
const parsed = new URL(url, window.location.origin);
|
|
||||||
const target =
|
|
||||||
parsed.hostname === "scqnycdn.jiansun.vip" && !parsed.search
|
|
||||||
? `${url}?imageView2/2/w/192/q/72/format/webp`
|
|
||||||
: buildImageProxyUrl(url, {
|
|
||||||
width: 192,
|
|
||||||
quality: 72,
|
|
||||||
format: "webp",
|
|
||||||
delivery: "proxy",
|
|
||||||
});
|
|
||||||
fetch(target, { signal: controller.signal })
|
|
||||||
.then(async (response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
const detail = await response.json().catch(() => null);
|
|
||||||
throw new Error(`${detail?.error || "预览请求失败"}(HTTP ${response.status})`);
|
|
||||||
}
|
|
||||||
const blob = await response.blob();
|
|
||||||
if (!blob.type.startsWith("image/")) throw new Error("Not an image");
|
|
||||||
if (alive) {
|
|
||||||
objectUrl = URL.createObjectURL(blob);
|
|
||||||
setSource(objectUrl);
|
|
||||||
setState("ready");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (alive) {
|
|
||||||
setFailure(
|
|
||||||
error.name === "AbortError" ? "预览加载超时" : error.message || "预览加载失败",
|
|
||||||
);
|
|
||||||
setState("error");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
release();
|
|
||||||
});
|
|
||||||
abort = () => {
|
|
||||||
controller.abort();
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ rootMargin: "80px" },
|
|
||||||
);
|
|
||||||
observer.observe(container.current);
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
observer.disconnect();
|
|
||||||
abort();
|
|
||||||
dispose?.();
|
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
||||||
};
|
|
||||||
}, [url, video, retired, attempt]);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={container}
|
|
||||||
className="relative flex h-16 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted"
|
|
||||||
data-preview-state={state}
|
|
||||||
>
|
|
||||||
{video ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="查看视频详情"
|
|
||||||
className="flex h-full w-full items-center justify-center"
|
|
||||||
onClick={onOpen}
|
|
||||||
>
|
|
||||||
<Video className="size-5 text-muted-foreground" aria-label="视频,点击查看后播放" />
|
|
||||||
</button>
|
|
||||||
) : retired ? (
|
|
||||||
<span className="text-center text-xs text-muted-foreground">
|
|
||||||
旧存储
|
|
||||||
<br />
|
|
||||||
已停用
|
|
||||||
</span>
|
|
||||||
) : source && state === "ready" ? (
|
|
||||||
<button type="button" aria-label="查看图片详情" className="h-full w-full" onClick={onOpen}>
|
|
||||||
<img
|
|
||||||
src={source}
|
|
||||||
alt="生成结果预览"
|
|
||||||
className="h-full w-full object-contain"
|
|
||||||
onError={() => {
|
|
||||||
setFailure("图片内容无法解码");
|
|
||||||
setState("error");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
) : state === "error" ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-full w-full flex-col items-center justify-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
title={`${failure},点击重试`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setAttempt((x) => x + 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ImageOff className="size-4" />
|
|
||||||
加载失败
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<RotateCw
|
|
||||||
className={`size-4 text-muted-foreground ${state === "loading" ? "motion-safe:animate-spin" : ""}`}
|
|
||||||
aria-label={state === "loading" ? "正在加载预览" : "等待进入可见区域"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useState } from "react";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { adminFetcher } from "./media-records-panel";
|
|
||||||
import { formatRecordTime } from "@/lib/admin/record-time";
|
|
||||||
type Event = {
|
|
||||||
id: string;
|
|
||||||
modelDisplayName: string;
|
|
||||||
modelRuntimeId: string;
|
|
||||||
providerName: string;
|
|
||||||
eventType: string;
|
|
||||||
createdAt: string;
|
|
||||||
errorMessage: string;
|
|
||||||
elapsedMs: number | null;
|
|
||||||
consecutiveFailures: number;
|
|
||||||
autoDisabled: boolean;
|
|
||||||
};
|
|
||||||
export function ModelHealthPanel({
|
|
||||||
providers,
|
|
||||||
models,
|
|
||||||
initialModelId = "",
|
|
||||||
}: {
|
|
||||||
initialModelId?: string;
|
|
||||||
providers: Array<{ id: string; name: string }>;
|
|
||||||
models: Array<{ id: string; displayName: string; providerId: string }>;
|
|
||||||
}) {
|
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
providerId: "",
|
|
||||||
modelId: initialModelId,
|
|
||||||
eventType: "",
|
|
||||||
startDate: "",
|
|
||||||
endDate: "",
|
|
||||||
});
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const query = new URLSearchParams({ ...filters, page: String(page), limit: "20" });
|
|
||||||
const { data, error, isLoading, isValidating, mutate } = useSWR<{
|
|
||||||
events: Event[];
|
|
||||||
hasMore: boolean;
|
|
||||||
}>(`/api/admin/ai-model-health?${query}`, adminFetcher, {
|
|
||||||
keepPreviousData: true,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
});
|
|
||||||
const update = (change: Partial<typeof filters>) => {
|
|
||||||
setFilters((f) => ({ ...f, ...change }));
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">运行健康</h3>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
按上游和模型追踪故障,查看完整原因与发生时间。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{[
|
|
||||||
["providerId", "全部上游", providers.map((p) => [p.id, p.name])],
|
|
||||||
[
|
|
||||||
"modelId",
|
|
||||||
"全部模型",
|
|
||||||
models
|
|
||||||
.filter((m) => !filters.providerId || m.providerId === filters.providerId)
|
|
||||||
.map((m) => [m.id, m.displayName]),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"eventType",
|
|
||||||
"全部错误",
|
|
||||||
[
|
|
||||||
["quota", "额度 / 余额"],
|
|
||||||
["timeout", "超时"],
|
|
||||||
["unavailable", "不可用"],
|
|
||||||
["invalid_model", "模型不可用"],
|
|
||||||
["download_error", "结果下载失败"],
|
|
||||||
["error", "其他错误"],
|
|
||||||
["success", "成功"],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
].map(([key, label, options]) => (
|
|
||||||
<select
|
|
||||||
key={key as string}
|
|
||||||
aria-label={label as string}
|
|
||||||
className="h-10 max-w-64 rounded-md border bg-background px-3 text-sm"
|
|
||||||
value={filters[key as keyof typeof filters]}
|
|
||||||
onChange={(e) =>
|
|
||||||
update({
|
|
||||||
[key as string]: e.target.value,
|
|
||||||
...(key === "providerId" ? { modelId: "" } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">{label as string}</option>
|
|
||||||
{(options as string[][]).map(([value, text]) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{text}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
))}
|
|
||||||
<Input
|
|
||||||
aria-label="故障开始日期"
|
|
||||||
className="h-10 w-40"
|
|
||||||
type="date"
|
|
||||||
value={filters.startDate}
|
|
||||||
onChange={(e) => update({ startDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
aria-label="故障结束日期"
|
|
||||||
className="h-10 w-40"
|
|
||||||
type="date"
|
|
||||||
value={filters.endDate}
|
|
||||||
onChange={(e) => update({ endDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Button variant="outline" disabled={isValidating} onClick={() => mutate()}>
|
|
||||||
刷新日志
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{error ? (
|
|
||||||
<p role="alert" className="p-6 text-sm text-destructive">
|
|
||||||
日志加载失败,请点击刷新重试。
|
|
||||||
</p>
|
|
||||||
) : isLoading ? (
|
|
||||||
<p role="status" className="p-6 text-sm">
|
|
||||||
正在加载故障日志…
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y rounded-xl border">
|
|
||||||
{data?.events.map((event) => (
|
|
||||||
<div key={event.id} className="p-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
|
||||||
<strong className="font-semibold">
|
|
||||||
{event.modelDisplayName || event.modelRuntimeId || "已删除模型"}
|
|
||||||
</strong>
|
|
||||||
<span className="text-muted-foreground">{event.providerName || "未知上游"}</span>
|
|
||||||
<span className="rounded bg-muted px-2 py-1 text-xs">
|
|
||||||
{{
|
|
||||||
quota: "额度 / 余额",
|
|
||||||
timeout: "超时",
|
|
||||||
unavailable: "不可用",
|
|
||||||
error: "服务错误",
|
|
||||||
invalid_model: "模型不可用",
|
|
||||||
download_error: "结果下载失败",
|
|
||||||
success: "成功",
|
|
||||||
}[event.eventType] || event.eventType}
|
|
||||||
</span>
|
|
||||||
<time className="ml-auto text-xs tabular-nums text-muted-foreground">
|
|
||||||
{formatRecordTime(event.createdAt)}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 whitespace-pre-wrap break-words text-sm">
|
|
||||||
{event.errorMessage || "未记录详细原因"}
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
|
|
||||||
<span>连续失败 {event.consecutiveFailures} 次</span>
|
|
||||||
{event.elapsedMs !== null && (
|
|
||||||
<span>耗时 {Math.round(event.elapsedMs / 1000)} 秒</span>
|
|
||||||
)}
|
|
||||||
{event.autoDisabled && <span>已自动停用</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!data?.events.length && (
|
|
||||||
<p className="p-12 text-center text-sm text-muted-foreground">该范围内没有故障记录。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
第 {page} 页{isValidating && " · 正在更新…"}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={page === 1 || isValidating}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={!data?.hasMore || isValidating}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,326 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import type { AiModel } from "@/lib/db/schema";
|
|
||||||
import {
|
|
||||||
is3DModelDescriptor,
|
|
||||||
isVideoModelDescriptor,
|
|
||||||
isImageGenerationModelDescriptor,
|
|
||||||
} from "@/lib/ai/model-classification";
|
|
||||||
|
|
||||||
type Model = AiModel & { isAgentModel?: boolean; isMultimodal?: boolean };
|
|
||||||
type Provider = { id: string; name: string; type: string; isEnabled: boolean };
|
|
||||||
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm";
|
|
||||||
export function ModelManagementList({
|
|
||||||
models,
|
|
||||||
providers,
|
|
||||||
error,
|
|
||||||
loading,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onRestore,
|
|
||||||
onRefresh,
|
|
||||||
onHealth,
|
|
||||||
}: {
|
|
||||||
models: Model[];
|
|
||||||
providers: Provider[];
|
|
||||||
error?: Error;
|
|
||||||
loading: boolean;
|
|
||||||
onEdit: (model: Model) => Promise<void>;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
onRestore: (model: Model) => void;
|
|
||||||
onRefresh: () => void;
|
|
||||||
onHealth?: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [provider, setProvider] = useState("");
|
|
||||||
const [status, setStatus] = useState("enabled");
|
|
||||||
const [kind, setKind] = useState("");
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [opening, setOpening] = useState<string | null>(null);
|
|
||||||
const rows = useMemo(
|
|
||||||
() =>
|
|
||||||
models
|
|
||||||
.map((model) => {
|
|
||||||
const upstream = providers.find((p) => p.id === model.providerId);
|
|
||||||
const descriptor = { ...model, providerType: upstream?.type };
|
|
||||||
const type = is3DModelDescriptor(descriptor)
|
|
||||||
? "3d"
|
|
||||||
: isVideoModelDescriptor(descriptor)
|
|
||||||
? "video"
|
|
||||||
: isImageGenerationModelDescriptor(descriptor)
|
|
||||||
? "image"
|
|
||||||
: "chat";
|
|
||||||
return { model, upstream, type };
|
|
||||||
})
|
|
||||||
.filter(({ model, upstream, type }) => {
|
|
||||||
const text =
|
|
||||||
`${model.displayName} ${model.modelId} ${upstream?.name || ""}`.toLowerCase();
|
|
||||||
return (
|
|
||||||
text.includes(search.trim().toLowerCase()) &&
|
|
||||||
(!provider || model.providerId === provider) &&
|
|
||||||
(!kind || kind === type) &&
|
|
||||||
(status === "all" ||
|
|
||||||
(status === "enabled" && model.isEnabled) ||
|
|
||||||
(status === "disabled" && !model.isEnabled) ||
|
|
||||||
(status === "unhealthy" && model.healthStatus !== "healthy") ||
|
|
||||||
(status === "visible" && !model.isHidden))
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
[models, providers, search, provider, status, kind],
|
|
||||||
);
|
|
||||||
const pages = Math.max(1, Math.ceil(rows.length / 25));
|
|
||||||
const currentPage = Math.min(page, pages);
|
|
||||||
const change = (fn: () => void) => {
|
|
||||||
fn();
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<div className="relative min-w-56 flex-1">
|
|
||||||
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
className="h-10 pl-9"
|
|
||||||
aria-label="搜索模型"
|
|
||||||
placeholder="搜索模型名称、ID 或上游"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => change(() => setSearch(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
aria-label="模型上游"
|
|
||||||
className={selectClass}
|
|
||||||
value={provider}
|
|
||||||
onChange={(e) => change(() => setProvider(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value="">全部上游</option>
|
|
||||||
{providers.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="模型类型"
|
|
||||||
className={selectClass}
|
|
||||||
value={kind}
|
|
||||||
onChange={(e) => change(() => setKind(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value="">全部类型</option>
|
|
||||||
<option value="image">生图</option>
|
|
||||||
<option value="video">视频</option>
|
|
||||||
<option value="chat">对话</option>
|
|
||||||
<option value="3d">3D / CAD</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="模型状态"
|
|
||||||
className={selectClass}
|
|
||||||
value={status}
|
|
||||||
onChange={(e) => change(() => setStatus(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value="enabled">已启用</option>
|
|
||||||
<option value="all">全部模型</option>
|
|
||||||
<option value="disabled">已停用</option>
|
|
||||||
<option value="unhealthy">运行异常</option>
|
|
||||||
<option value="visible">前台可见</option>
|
|
||||||
</select>
|
|
||||||
<Button variant="outline" onClick={onRefresh} aria-label="刷新模型">
|
|
||||||
<RefreshCw className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{loading
|
|
||||||
? "正在加载模型…"
|
|
||||||
: `筛选结果 ${rows.length} 个 · 已启用 ${models.filter((m) => m.isEnabled).length} 个 · 全部 ${models.length} 个`}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
第 {currentPage} / {pages} 页
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
onClick={() => setPage(currentPage - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={currentPage === pages}
|
|
||||||
onClick={() => setPage(currentPage + 1)}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{error ? (
|
|
||||||
<div role="alert" className="rounded-xl border p-6 text-sm">
|
|
||||||
模型加载失败。
|
|
||||||
<Button variant="outline" onClick={onRefresh}>
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
|
||||||
<div role="status" className="h-48 rounded-xl bg-muted motion-safe:animate-pulse" />
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
|
||||||
<table className="w-full min-w-[850px] text-left text-sm">
|
|
||||||
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
{["模型", "上游 / 类型", "启用", "前台展示", "运行健康", "操作"].map((x) => (
|
|
||||||
<th key={x} className="px-4 py-3 font-medium">
|
|
||||||
{x}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{rows
|
|
||||||
.slice((currentPage - 1) * 25, currentPage * 25)
|
|
||||||
.map(({ model, upstream, type }) => (
|
|
||||||
<tr key={model.id} className="hover:bg-muted/30">
|
|
||||||
<td className="max-w-64 px-4 py-4">
|
|
||||||
<div className="font-medium">{model.displayName}</div>
|
|
||||||
<div
|
|
||||||
className="mt-1 truncate text-xs text-muted-foreground"
|
|
||||||
title={model.modelId}
|
|
||||||
>
|
|
||||||
{model.modelId}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
||||||
{model.isDefault && <span>默认模型</span>}
|
|
||||||
{model.isDefaultTextEditModel && <span>文字编辑默认</span>}
|
|
||||||
{model.isAgentModel && <span>Agent</span>}
|
|
||||||
{model.isMultimodal && <span>多模态</span>}
|
|
||||||
{model.isSubModel && <span>子模型</span>}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
{upstream?.name || "未知上游"}
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{{ image: "生图", video: "视频", chat: "对话", "3d": "3D / CAD" }[type]}
|
|
||||||
{upstream && !upstream.isEnabled ? " · 上游已停用" : ""}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap px-4 py-4">
|
|
||||||
<span
|
|
||||||
className={`rounded-md px-2 py-1 text-xs ${model.isEnabled ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" : "bg-muted text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
{model.isEnabled ? "已启用" : "已停用"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
{model.isHidden || model.isSubModel ? "隐藏" : "可见"}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-72 px-4 py-4">
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
model.healthStatus !== "healthy"
|
|
||||||
? "text-amber-700 dark:text-amber-300"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{model.healthStatus === "auto_disabled"
|
|
||||||
? "自动停用"
|
|
||||||
: model.healthStatus === "degraded"
|
|
||||||
? "观察中"
|
|
||||||
: "正常"}
|
|
||||||
</span>
|
|
||||||
{model.healthConsecutiveFailures > 0 && (
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
|
||||||
连续失败 {model.healthConsecutiveFailures} 次
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{model.healthLastError && (
|
|
||||||
<details className="mt-2 text-xs">
|
|
||||||
<summary className="cursor-pointer text-muted-foreground">
|
|
||||||
查看最近原因
|
|
||||||
</summary>
|
|
||||||
<p className="mt-2 whitespace-pre-wrap break-words">
|
|
||||||
{model.healthLastError}
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mt-2 block text-xs underline underline-offset-4"
|
|
||||||
onClick={() => onHealth?.(model.id)}
|
|
||||||
>
|
|
||||||
故障记录
|
|
||||||
</button>
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{model.excludeFromHealthBan && "不自动封禁 "}
|
|
||||||
{model.excludeFromFailover && "不参与迁移"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={opening !== null}
|
|
||||||
onClick={async () => {
|
|
||||||
setOpening(model.id);
|
|
||||||
try {
|
|
||||||
await onEdit(model);
|
|
||||||
} finally {
|
|
||||||
setOpening(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opening === model.id ? "加载中…" : "编辑"}
|
|
||||||
</Button>
|
|
||||||
{model.healthStatus === "auto_disabled" && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onRestore(model)}>
|
|
||||||
恢复
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive"
|
|
||||||
onClick={() => onDelete(model.id)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{rows.length === 0 && (
|
|
||||||
<p className="p-12 text-center text-sm text-muted-foreground">没有符合条件的模型。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
每页 25 个 · 第 {currentPage} / {pages} 页
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
aria-label="上一页模型"
|
|
||||||
variant="outline"
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
onClick={() => setPage(currentPage - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
aria-label="下一页模型"
|
|
||||||
variant="outline"
|
|
||||||
disabled={currentPage === pages}
|
|
||||||
onClick={() => setPage(currentPage + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
@ -576,11 +575,31 @@ export function AgentGenerationWorkspace({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filename = buildDownloadFilename(raw);
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
const response = await fetch(raw);
|
||||||
toast.success("原图已开始下载");
|
if (!response.ok) {
|
||||||
} catch (error) {
|
throw new Error(`download failed: ${response.status}`);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = raw;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
toast.message("已打开原图,可右键另存为");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
|
||||||
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||||
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
|
|
||||||
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
|
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import {
|
import {
|
||||||
@ -4172,7 +4169,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
? "agent-anchor-materials"
|
? "agent-anchor-materials"
|
||||||
: "agent-anchor-generate";
|
: "agent-anchor-generate";
|
||||||
|
|
||||||
const [finishedSceneRequestId, setFinishedSceneRequestId] = useState<string | null>(null);
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
setMessages,
|
setMessages,
|
||||||
@ -4193,11 +4189,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
void refreshHistory();
|
void refreshHistory();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFinish({ messages: finishedMessages }) {
|
onFinish() { void refreshHistory(); },
|
||||||
const lastUser = finishedMessages.filter((message) => message.role === "user").at(-1);
|
|
||||||
setFinishedSceneRequestId(lastUser?.id || null);
|
|
||||||
void refreshHistory();
|
|
||||||
},
|
|
||||||
experimental_throttle: 100,
|
experimental_throttle: 100,
|
||||||
generateId: generateUUID,
|
generateId: generateUUID,
|
||||||
transport: new DefaultChatTransport({
|
transport: new DefaultChatTransport({
|
||||||
@ -4880,14 +4872,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
return recoverReplacementRun({ messages, ...pendingGeneration });
|
return recoverReplacementRun({ messages, ...pendingGeneration });
|
||||||
}, [messages, pendingGeneration]);
|
}, [messages, pendingGeneration]);
|
||||||
const transportIsGenerating = status === "streaming" || status === "submitted";
|
const transportIsGenerating = status === "streaming" || status === "submitted";
|
||||||
useEffect(() => {
|
|
||||||
const beforeRefresh = (event: Event) => {
|
|
||||||
if (transportIsGenerating || pendingGeneration)
|
|
||||||
(event as CustomEvent<PageRefreshRequest>).detail.block("当前生图任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。");
|
|
||||||
};
|
|
||||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
}, [transportIsGenerating, pendingGeneration]);
|
|
||||||
const pendingGenerationProgressCount = useMemo(() => {
|
const pendingGenerationProgressCount = useMemo(() => {
|
||||||
if (!pendingGeneration) {
|
if (!pendingGeneration) {
|
||||||
return 0;
|
return 0;
|
||||||
@ -5498,17 +5482,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
setActiveResultModeType(currentResultModeType);
|
setActiveResultModeType(currentResultModeType);
|
||||||
}, [currentResultModeType]);
|
}, [currentResultModeType]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingGeneration?.requestMessageId || pendingGeneration.modeType !== "scene_generation" || transportIsGenerating) return;
|
|
||||||
const result = getSceneRunResult(messages, pendingGeneration.requestMessageId);
|
|
||||||
const persisted = getSceneRunResult(historyMessages, pendingGeneration.requestMessageId);
|
|
||||||
if (!result || (finishedSceneRequestId !== pendingGeneration.requestMessageId && !persisted?.count)) return;
|
|
||||||
setPendingGeneration(null);
|
|
||||||
if (result.count < pendingGeneration.expectedCount) {
|
|
||||||
toast.warning(`本次生成已结束,收到 ${result.count}/${pendingGeneration.expectedCount} 张图片。缺少的图片未继续生成,可调整张数后重新提交。`);
|
|
||||||
}
|
|
||||||
}, [pendingGeneration, transportIsGenerating, finishedSceneRequestId, messages, historyMessages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
|
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
|
||||||
// The backend has settled every requested card; an open SSE connection is no longer work.
|
// The backend has settled every requested card; an open SSE connection is no longer work.
|
||||||
@ -6803,11 +6776,31 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filename = buildDownloadFilename(raw);
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
const response = await fetch(raw);
|
||||||
toast.success("原图已开始下载");
|
if (!response.ok) {
|
||||||
} catch (error) {
|
throw new Error(`download failed: ${response.status}`);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = raw;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
toast.message("已打开原图,可右键另存为");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
|
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
@ -726,11 +725,30 @@ export function AgentGenerationWorkspaceNew({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const filename = buildDownloadFilename(raw);
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
const response = await fetch(raw);
|
||||||
toast.success("原图已开始下载");
|
if (!response.ok) {
|
||||||
} catch (error) {
|
throw new Error(`download failed: ${response.status}`);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = raw;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
toast.message("已打开原图,可右键另存为");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -112,8 +112,6 @@ const PREVIEW_DIMENSION_HANDLE_RADIUS = 3.2;
|
|||||||
const PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH = 1.2;
|
const PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH = 1.2;
|
||||||
const PREVIEW_DIMENSION_TEXT_HALO_WIDTH = 2.2;
|
const PREVIEW_DIMENSION_TEXT_HALO_WIDTH = 2.2;
|
||||||
const PREVIEW_DIMENSION_TEXT_OUTLINE_WIDTH = 0.85;
|
const PREVIEW_DIMENSION_TEXT_OUTLINE_WIDTH = 0.85;
|
||||||
const DIMENSION_FONT_FAMILY =
|
|
||||||
'"PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif';
|
|
||||||
const SIZE_INFO_LINE_REGEX = /尺寸信息[::]\s*([^\n]+)/i;
|
const SIZE_INFO_LINE_REGEX = /尺寸信息[::]\s*([^\n]+)/i;
|
||||||
const NUMBER_REGEX = /\d+(?:\.\d+)?/g;
|
const NUMBER_REGEX = /\d+(?:\.\d+)?/g;
|
||||||
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
|
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
|
||||||
@ -570,54 +568,87 @@ function drawAnnotationToCanvas(params: {
|
|||||||
context: CanvasRenderingContext2D;
|
context: CanvasRenderingContext2D;
|
||||||
annotation: DimensionAnnotation;
|
annotation: DimensionAnnotation;
|
||||||
imageSize: ImageSize;
|
imageSize: ImageSize;
|
||||||
previewRect: PreviewRect;
|
|
||||||
annotations: DimensionAnnotation[];
|
annotations: DimensionAnnotation[];
|
||||||
autoDimensionValues?: AutoDimensionValues | null;
|
autoDimensionValues?: AutoDimensionValues | null;
|
||||||
}) {
|
}) {
|
||||||
const { context, annotation } = params;
|
const { context, annotation, imageSize, annotations, autoDimensionValues } =
|
||||||
|
params;
|
||||||
const annotationColor = getAnnotationColor(annotation);
|
const annotationColor = getAnnotationColor(annotation);
|
||||||
const geometry = getPreviewAnnotationGeometry(params);
|
const label = getDimensionLabel({
|
||||||
|
annotation,
|
||||||
|
annotations,
|
||||||
|
autoDimensionValues,
|
||||||
|
});
|
||||||
|
const vector = getAnnotationVector(
|
||||||
|
annotation.x1,
|
||||||
|
annotation.y1,
|
||||||
|
annotation.x2,
|
||||||
|
annotation.y2
|
||||||
|
);
|
||||||
|
const rawAngleRadians = Math.atan2(vector.dy, vector.dx);
|
||||||
|
const angleRadians =
|
||||||
|
rawAngleRadians > Math.PI / 2
|
||||||
|
? rawAngleRadians - Math.PI
|
||||||
|
: rawAngleRadians < -Math.PI / 2
|
||||||
|
? rawAngleRadians + Math.PI
|
||||||
|
: rawAngleRadians;
|
||||||
|
const strokeWidth = clamp(
|
||||||
|
Math.min(imageSize.width, imageSize.height) * 0.001_35,
|
||||||
|
1,
|
||||||
|
2.4
|
||||||
|
);
|
||||||
|
const capLength = strokeWidth * 5.3;
|
||||||
|
const fontSize = clamp(
|
||||||
|
Math.round(Math.min(imageSize.width, imageSize.height) * 0.0176),
|
||||||
|
11,
|
||||||
|
27
|
||||||
|
);
|
||||||
|
const labelOffset = fontSize * 0.95;
|
||||||
|
const centerX = (annotation.x1 + annotation.x2) / 2 + vector.nx * labelOffset;
|
||||||
|
const centerY = (annotation.y1 + annotation.y2) / 2 + vector.ny * labelOffset;
|
||||||
const textOutlineColor = getAnnotationTextOutlineColor(annotation);
|
const textOutlineColor = getAnnotationTextOutlineColor(annotation);
|
||||||
|
const textOutlineWidth = annotation.glow
|
||||||
|
? Math.max(1.5, fontSize * 0.18)
|
||||||
|
: Math.max(0.85, fontSize * 0.08);
|
||||||
|
|
||||||
context.save();
|
context.save();
|
||||||
context.strokeStyle = annotationColor;
|
context.strokeStyle = annotationColor;
|
||||||
context.lineWidth = PREVIEW_DIMENSION_STROKE_WIDTH;
|
context.lineWidth = strokeWidth;
|
||||||
context.lineCap = "round";
|
context.lineCap = "round";
|
||||||
context.lineJoin = "round";
|
context.lineJoin = "round";
|
||||||
context.shadowColor = annotation.glow
|
context.shadowColor = annotation.glow
|
||||||
? colorWithAlpha(annotationColor, 0.42)
|
? colorWithAlpha(annotationColor, 0.52)
|
||||||
: "transparent";
|
: "transparent";
|
||||||
context.shadowBlur = annotation.glow ? 4 : 0;
|
context.shadowBlur = annotation.glow ? Math.max(8, fontSize * 0.7) : 0;
|
||||||
|
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.moveTo(geometry.start.x, geometry.start.y);
|
context.moveTo(annotation.x1, annotation.y1);
|
||||||
context.lineTo(geometry.end.x, geometry.end.y);
|
context.lineTo(annotation.x2, annotation.y2);
|
||||||
context.stroke();
|
context.stroke();
|
||||||
|
|
||||||
context.lineWidth = PREVIEW_DIMENSION_CAP_STROKE_WIDTH;
|
|
||||||
drawDimensionCap({
|
drawDimensionCap({
|
||||||
context,
|
context,
|
||||||
point: geometry.start,
|
point: { x: annotation.x1, y: annotation.y1 },
|
||||||
size: PREVIEW_DIMENSION_TICK_LENGTH,
|
size: capLength,
|
||||||
vector: geometry.vector,
|
vector,
|
||||||
});
|
});
|
||||||
drawDimensionCap({
|
drawDimensionCap({
|
||||||
context,
|
context,
|
||||||
point: geometry.end,
|
point: { x: annotation.x2, y: annotation.y2 },
|
||||||
size: PREVIEW_DIMENSION_TICK_LENGTH,
|
size: capLength,
|
||||||
vector: geometry.vector,
|
vector,
|
||||||
});
|
});
|
||||||
|
|
||||||
context.translate(geometry.labelCenterX, geometry.labelCenterY);
|
context.translate(centerX, centerY);
|
||||||
context.rotate((geometry.angleDegrees * Math.PI) / 180);
|
context.rotate(angleRadians);
|
||||||
context.font = `500 ${PREVIEW_DIMENSION_FONT_SIZE}px ${DIMENSION_FONT_FAMILY}`;
|
context.font = `500 ${fontSize}px "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif`;
|
||||||
context.textAlign = "center";
|
context.textAlign = "center";
|
||||||
context.textBaseline = "middle";
|
context.textBaseline = "middle";
|
||||||
context.lineWidth = getAnnotationTextOutlineWidth(annotation);
|
context.lineWidth = textOutlineWidth;
|
||||||
context.strokeStyle = textOutlineColor;
|
context.strokeStyle = textOutlineColor;
|
||||||
context.strokeText(geometry.label, 0, 0.5);
|
context.strokeText(label, 0, 0);
|
||||||
context.fillStyle = annotationColor;
|
context.fillStyle = annotationColor;
|
||||||
context.fillText(geometry.label, 0, 0.5);
|
context.fillText(label, 0, 0);
|
||||||
context.restore();
|
context.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1036,10 +1067,6 @@ export function AgentPsdTemplateExportPreview({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!imageSize || !previewRect) {
|
|
||||||
throw new Error("请等待预览图片加载完成后再下载");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { image, dispose } = await loadImageFromCandidates([
|
const { image, dispose } = await loadImageFromCandidates([
|
||||||
buildImageProxyUrl(imageUrl, { delivery: "proxy" }),
|
buildImageProxyUrl(imageUrl, { delivery: "proxy" }),
|
||||||
imageUrl,
|
imageUrl,
|
||||||
@ -1055,25 +1082,18 @@ export function AgentPsdTemplateExportPreview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
// Draw in the same coordinates as the SVG, then map its image area to
|
|
||||||
// the original bitmap. The preview can use a smaller image variant.
|
|
||||||
context.save();
|
|
||||||
context.scale(
|
|
||||||
canvas.width / previewRect.width,
|
|
||||||
canvas.height / previewRect.height
|
|
||||||
);
|
|
||||||
context.translate(-previewRect.left, -previewRect.top);
|
|
||||||
for (const annotation of annotations) {
|
for (const annotation of annotations) {
|
||||||
drawAnnotationToCanvas({
|
drawAnnotationToCanvas({
|
||||||
annotation,
|
annotation,
|
||||||
annotations,
|
annotations,
|
||||||
autoDimensionValues,
|
autoDimensionValues,
|
||||||
context,
|
context,
|
||||||
imageSize,
|
imageSize: {
|
||||||
previewRect,
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
context.restore();
|
|
||||||
|
|
||||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
@ -1105,8 +1125,6 @@ export function AgentPsdTemplateExportPreview({
|
|||||||
downloadName,
|
downloadName,
|
||||||
enableDimensionAnnotations,
|
enableDimensionAnnotations,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imageSize,
|
|
||||||
previewRect,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -1287,7 +1305,6 @@ export function AgentPsdTemplateExportPreview({
|
|||||||
<text
|
<text
|
||||||
dominantBaseline="middle"
|
dominantBaseline="middle"
|
||||||
fill={annotationColor}
|
fill={annotationColor}
|
||||||
fontFamily={DIMENSION_FONT_FAMILY}
|
|
||||||
fontSize={PREVIEW_DIMENSION_FONT_SIZE}
|
fontSize={PREVIEW_DIMENSION_FONT_SIZE}
|
||||||
fontWeight={500}
|
fontWeight={500}
|
||||||
paintOrder="stroke"
|
paintOrder="stroke"
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
|
|
||||||
import { preparePageRefresh } from "@/lib/client/page-refresh";
|
|
||||||
|
|
||||||
export function AppUpdateNotice() {
|
|
||||||
const [availableVersion, setAvailableVersion] = useState("");
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
useEffect(() => {
|
|
||||||
let disposed = false;
|
|
||||||
let controller: AbortController | undefined;
|
|
||||||
let checking = false;
|
|
||||||
const check = async () => {
|
|
||||||
if (disposed || checking || document.visibilityState !== "visible") return;
|
|
||||||
checking = true;
|
|
||||||
controller = new AbortController();
|
|
||||||
const current = controller;
|
|
||||||
const timeout = window.setTimeout(() => current.abort(), 8000);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/app-version", { cache: "no-store", signal: current.signal });
|
|
||||||
if (!response.ok) return;
|
|
||||||
const data = await response.json();
|
|
||||||
if (!disposed && !current.signal.aborted && typeof data.version === "string" && data.version)
|
|
||||||
setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : "");
|
|
||||||
} catch {
|
|
||||||
// A failed version read leaves the current workspace available.
|
|
||||||
} finally {
|
|
||||||
window.clearTimeout(timeout);
|
|
||||||
checking = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void check();
|
|
||||||
const interval = window.setInterval(check, 60_000);
|
|
||||||
window.addEventListener("focus", check);
|
|
||||||
window.addEventListener("online", check);
|
|
||||||
document.addEventListener("visibilitychange", check);
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
controller?.abort();
|
|
||||||
clearInterval(interval);
|
|
||||||
window.removeEventListener("focus", check);
|
|
||||||
window.removeEventListener("online", check);
|
|
||||||
document.removeEventListener("visibilitychange", check);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
if (refreshing) return;
|
|
||||||
setRefreshing(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
await preparePageRefresh();
|
|
||||||
window.location.reload();
|
|
||||||
setRefreshing(false);
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : "当前内容同步失败,请保留此页并重试。");
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return <AlertDialog open={Boolean(availableVersion)}>
|
|
||||||
<AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>平台已更新</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>需要刷新页面后继续使用。点击后会先同步当前画布并保留输入;正在处理的任务完成后再刷新。</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
|
||||||
<AlertDialogFooter>
|
|
||||||
{error ? <Button variant="outline" disabled={refreshing} onClick={() => { setAvailableVersion(""); setError(""); }}>返回处理</Button> : null}
|
|
||||||
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>;
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, ImageIcon, ImagePlus, Loader2, Search, Upload, X } from "lucide-react";
|
import { Check, ImageIcon, ImagePlus, Loader2, Search, X } from "lucide-react";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -21,8 +21,6 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { IMAGE_UPLOAD_MAX_BYTES, IMAGE_UPLOAD_TOO_LARGE_MESSAGE } from "@/lib/image-upload-limits";
|
|
||||||
import { imageGenerationService } from "@/services/canvas";
|
|
||||||
import type {
|
import type {
|
||||||
AgentWorkflowMaterialImage,
|
AgentWorkflowMaterialImage,
|
||||||
AgentWorkflowMaterialQueryType,
|
AgentWorkflowMaterialQueryType,
|
||||||
@ -165,8 +163,6 @@ export function CanvasAddImageDialog({
|
|||||||
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
|
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const localUploadInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [errorText, setErrorText] = useState<string | null>(null);
|
const [errorText, setErrorText] = useState<string | null>(null);
|
||||||
|
|
||||||
const images = useMemo(() => normalizeImages(result), [result]);
|
const images = useMemo(() => normalizeImages(result), [result]);
|
||||||
@ -186,13 +182,12 @@ export function CanvasAddImageDialog({
|
|||||||
|
|
||||||
const handleDialogOpenChange = useCallback(
|
const handleDialogOpenChange = useCallback(
|
||||||
(nextOpen: boolean) => {
|
(nextOpen: boolean) => {
|
||||||
if (!nextOpen && (isUploading || isAdding)) return;
|
|
||||||
if (!nextOpen) {
|
if (!nextOpen) {
|
||||||
resetDialogState();
|
resetDialogState();
|
||||||
}
|
}
|
||||||
onOpenChange(nextOpen);
|
onOpenChange(nextOpen);
|
||||||
},
|
},
|
||||||
[isAdding, isUploading, onOpenChange, resetDialogState]
|
[onOpenChange, resetDialogState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeDialog = useCallback(() => {
|
const closeDialog = useCallback(() => {
|
||||||
@ -268,51 +263,6 @@ export function CanvasAddImageDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLocalUpload = async (files: File[]) => {
|
|
||||||
if (!files.length || isUploading || isAdding) return;
|
|
||||||
if (files.some((file) => !file.type.startsWith("image/"))) {
|
|
||||||
setErrorText("请选择图片文件");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (files.some((file) => file.size > IMAGE_UPLOAD_MAX_BYTES)) {
|
|
||||||
setErrorText(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
setErrorText(null);
|
|
||||||
try {
|
|
||||||
const uploadedImages: CanvasAddImageDialogImage[] = [];
|
|
||||||
const failures: string[] = [];
|
|
||||||
for (const [index, file] of files.entries()) {
|
|
||||||
const upload = await imageGenerationService.uploadImage(file);
|
|
||||||
if (!upload.success || !upload.data?.url) {
|
|
||||||
failures.push(`${file.name}:${upload.error || "图片上传失败"}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
uploadedImages.push({
|
|
||||||
id: upload.data.assetId || crypto.randomUUID(),
|
|
||||||
key: upload.data.pathname || file.name,
|
|
||||||
label: file.name.replace(/\.[^.]+$/, "") || file.name,
|
|
||||||
fileName: file.name,
|
|
||||||
imageUrl: upload.data.url,
|
|
||||||
assetId: upload.data.assetId,
|
|
||||||
mimeType: file.type,
|
|
||||||
sourceType: "upload",
|
|
||||||
group: "other",
|
|
||||||
sortOrder: index,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (uploadedImages.length) await onAddImages(uploadedImages);
|
|
||||||
if (failures.length) setErrorText(failures.join(";"));
|
|
||||||
else closeDialog();
|
|
||||||
} catch (error) {
|
|
||||||
setErrorText(error instanceof Error ? error.message : "图片上传失败,请重试");
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
|
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
|
||||||
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
|
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
|
||||||
@ -322,38 +272,15 @@ export function CanvasAddImageDialog({
|
|||||||
添加图片
|
添加图片
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
从本地上传图片、添加空白图片节点,或检索 SPU / SKU 白底图。
|
添加空白图片节点,或从风格化素材库检索 SPU / SKU 白底图。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="max-h-[62vh] overflow-y-auto px-5 py-4">
|
<div className="max-h-[62vh] overflow-y-auto px-5 py-4">
|
||||||
<div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3">
|
<div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3">
|
||||||
<div className="flex flex-col gap-2.5 sm:flex-row">
|
<div className="flex flex-col gap-2.5 sm:flex-row">
|
||||||
<input
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
multiple
|
|
||||||
onChange={(event) => {
|
|
||||||
const files = Array.from(event.target.files || []);
|
|
||||||
event.target.value = "";
|
|
||||||
void handleLocalUpload(files);
|
|
||||||
}}
|
|
||||||
ref={localUploadInputRef}
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
className="h-9 shrink-0 justify-start gap-2"
|
className="h-9 justify-start gap-2"
|
||||||
disabled={isUploading || isAdding}
|
|
||||||
onClick={() => localUploadInputRef.current?.click()}
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
|
||||||
{isUploading ? "上传中…" : "本地上传"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="h-9 shrink-0 justify-start gap-2"
|
|
||||||
disabled={isUploading || isAdding}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onCreateBlankImage();
|
onCreateBlankImage();
|
||||||
closeDialog();
|
closeDialog();
|
||||||
@ -428,13 +355,13 @@ export function CanvasAddImageDialog({
|
|||||||
</div>
|
</div>
|
||||||
{!materialSearchEnabled ? (
|
{!materialSearchEnabled ? (
|
||||||
<div className="mt-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground">
|
<div className="mt-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground">
|
||||||
风格化功能或素材搜索步骤未开启,可从本地上传或添加空白图片。
|
风格化功能或素材搜索步骤未开启,当前只显示空白图片入口。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errorText ? (
|
{errorText ? (
|
||||||
<div role="alert" className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
|
<div className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
|
||||||
{errorText}
|
{errorText}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -513,7 +440,6 @@ export function CanvasAddImageDialog({
|
|||||||
|
|
||||||
<DialogFooter className="border-t px-5 py-3">
|
<DialogFooter className="border-t px-5 py-3">
|
||||||
<Button
|
<Button
|
||||||
disabled={isUploading || isAdding}
|
|
||||||
onClick={closeDialog}
|
onClick={closeDialog}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -522,7 +448,7 @@ export function CanvasAddImageDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
disabled={!selectedImages.length || isAdding || isUploading}
|
disabled={!selectedImages.length || isAdding}
|
||||||
onClick={() => void handleAddSelected()}
|
onClick={() => void handleAddSelected()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,115 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import type { Position } from "@/types/canvas";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
position: Position | null;
|
|
||||||
isMac: boolean;
|
|
||||||
videoUploading: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onUploadImage: () => void;
|
|
||||||
onUploadVideo: () => void;
|
|
||||||
onPaste: () => void;
|
|
||||||
onZoomIn: () => void;
|
|
||||||
onZoomOut: () => void;
|
|
||||||
onFitAll: () => void;
|
|
||||||
onResetZoom: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CanvasBlankContextMenu({
|
|
||||||
position,
|
|
||||||
isMac,
|
|
||||||
videoUploading,
|
|
||||||
onClose,
|
|
||||||
onUploadImage,
|
|
||||||
onUploadVideo,
|
|
||||||
onPaste,
|
|
||||||
onZoomIn,
|
|
||||||
onZoomOut,
|
|
||||||
onFitAll,
|
|
||||||
onResetZoom,
|
|
||||||
}: Props) {
|
|
||||||
const modifier = isMac ? "⌘" : "Ctrl+";
|
|
||||||
const itemClass =
|
|
||||||
"h-8 cursor-pointer justify-between whitespace-nowrap rounded-[5px] px-0 text-[14px] font-normal focus:bg-[#f5f5f5] dark:focus:bg-white/10";
|
|
||||||
const shortcut = (label: string) => (
|
|
||||||
<span className="ml-2 whitespace-nowrap text-[13px] text-[#aaa]">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<DropdownMenu
|
|
||||||
modal={false}
|
|
||||||
open={position !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
aria-hidden="true"
|
|
||||||
tabIndex={-1}
|
|
||||||
data-canvas-no-drag="true"
|
|
||||||
className="pointer-events-none fixed h-px w-px opacity-0"
|
|
||||||
style={{ left: position?.x ?? 0, top: position?.y ?? 0 }}
|
|
||||||
type="button"
|
|
||||||
/>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
aria-label="画布右键菜单"
|
|
||||||
align="start"
|
|
||||||
sideOffset={0}
|
|
||||||
collisionPadding={8}
|
|
||||||
data-canvas-blank-menu="true"
|
|
||||||
data-canvas-no-drag="true"
|
|
||||||
className="z-[10005] w-[200px] rounded-[12px] border-[#ededed] bg-white px-3 py-[5px] text-[#242424] shadow-[0_8px_20px_rgba(0,0,0,0.06)] ring-0 dark:border-white/10 dark:bg-zinc-900 dark:text-white"
|
|
||||||
style={{
|
|
||||||
fontFamily:
|
|
||||||
'Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
|
|
||||||
}}
|
|
||||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
onContextMenu={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem className={itemClass} onSelect={onUploadImage}>
|
|
||||||
上传图片
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={itemClass}
|
|
||||||
disabled={videoUploading}
|
|
||||||
onSelect={onUploadVideo}
|
|
||||||
>
|
|
||||||
上传视频
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
|
|
||||||
<DropdownMenuItem className={itemClass} onSelect={onPaste}>
|
|
||||||
粘贴{shortcut(`${modifier}V`)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
|
|
||||||
<DropdownMenuItem className={itemClass} onSelect={onZoomIn}>
|
|
||||||
放大{shortcut(`${modifier}+`)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className={itemClass} onSelect={onZoomOut}>
|
|
||||||
缩小{shortcut(`${modifier}-`)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className={itemClass} onSelect={onFitAll}>
|
|
||||||
显示画布所有元素{shortcut(isMac ? "⇧1" : "Shift+1")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className={itemClass} onSelect={onResetZoom}>
|
|
||||||
缩放至100%{shortcut(`${modifier}0`)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,54 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
|
||||||
|
|
||||||
type CanvasComposerPromoProps = {
|
|
||||||
text: string;
|
|
||||||
onDismiss: () => void;
|
|
||||||
onActivate?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CanvasComposerPromo({
|
|
||||||
text,
|
|
||||||
onDismiss,
|
|
||||||
onActivate,
|
|
||||||
}: CanvasComposerPromoProps) {
|
|
||||||
return (
|
|
||||||
<div className="lovart-chat-promo lovart-chat-promo-connected">
|
|
||||||
<div className="lovart-chat-promo-surface">
|
|
||||||
<div className="lovart-chat-promo-row">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="lovart-chat-promo-message"
|
|
||||||
onClick={onActivate}
|
|
||||||
disabled={!onActivate}
|
|
||||||
title={text}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
width="16"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 28"
|
|
||||||
className="lovart-chat-promo-badge"
|
|
||||||
>
|
|
||||||
<path d="M10 1.15a4 4 0 0 1 4 0l8 4.6a4 4 0 0 1 2 3.46v9.58a4 4 0 0 1-2 3.46l-8 4.6a4 4 0 0 1-4 0l-8-4.6A4 4 0 0 1 0 18.79V9.21a4 4 0 0 1 2-3.46Z" fill="#ddef65" />
|
|
||||||
<path
|
|
||||||
d="M12 5.5c1.2 5.3 2.2 6.3 7.5 7.5-5.3 1.2-6.3 2.2-7.5 7.5C10.8 15.2 9.8 14.2 4.5 13c5.3-1.2 6.3-2.2 7.5-7.5Z"
|
|
||||||
fill="#111"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>{text}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="lovart-chat-promo-close"
|
|
||||||
aria-label="关闭优惠广告"
|
|
||||||
onClick={onDismiss}
|
|
||||||
>
|
|
||||||
<ReferenceIcon name="close" size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -19,7 +19,6 @@ const PROJECT_LIST_PAGE_SIZE = 24;
|
|||||||
interface CanvasFileListPanelProps {
|
interface CanvasFileListPanelProps {
|
||||||
currentId: string;
|
currentId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onNavigate?: (url: string) => void;
|
|
||||||
workspaceType?: CanvasWorkspaceType;
|
workspaceType?: CanvasWorkspaceType;
|
||||||
workspaceLabel?: string;
|
workspaceLabel?: string;
|
||||||
createUrl?: string;
|
createUrl?: string;
|
||||||
@ -29,7 +28,6 @@ interface CanvasFileListPanelProps {
|
|||||||
export function CanvasFileListPanel({
|
export function CanvasFileListPanel({
|
||||||
currentId,
|
currentId,
|
||||||
onClose,
|
onClose,
|
||||||
onNavigate,
|
|
||||||
workspaceType = "canvas",
|
workspaceType = "canvas",
|
||||||
workspaceLabel = "画布",
|
workspaceLabel = "画布",
|
||||||
createUrl = "/canvas?new=1",
|
createUrl = "/canvas?new=1",
|
||||||
@ -90,10 +88,6 @@ export function CanvasFileListPanel({
|
|||||||
|
|
||||||
// 创建新画布
|
// 创建新画布
|
||||||
const handleCreateNew = async () => {
|
const handleCreateNew = async () => {
|
||||||
if (onNavigate) {
|
|
||||||
onNavigate(createUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(createUrl);
|
router.push(createUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -106,10 +100,6 @@ export function CanvasFileListPanel({
|
|||||||
}
|
}
|
||||||
const targetUrl = buildProjectUrl(id);
|
const targetUrl = buildProjectUrl(id);
|
||||||
onClose(); // 关闭文件列表面板
|
onClose(); // 关闭文件列表面板
|
||||||
if (onNavigate) {
|
|
||||||
onNavigate(targetUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 强制整页跳转,避免仅更新 URL 不刷新
|
// 强制整页跳转,避免仅更新 URL 不刷新
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.assign(targetUrl);
|
window.location.assign(targetUrl);
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { createContext, useContext } from "react";
|
|
||||||
import type { CanvasState } from "@/types/canvas";
|
|
||||||
|
|
||||||
export type CanvasRemoteOperationResult = {
|
|
||||||
projectId: string;
|
|
||||||
canvasData: CanvasState;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
export type CanvasRemoteOperation = (() => Promise<void>) | (() => Promise<CanvasRemoteOperationResult | undefined>);
|
|
||||||
|
|
||||||
export const CanvasRemoteOperationContext = createContext<
|
|
||||||
{
|
|
||||||
run: (operation: CanvasRemoteOperation) => Promise<void>;
|
|
||||||
applySnapshot: (result: CanvasRemoteOperationResult) => boolean;
|
|
||||||
} | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
export function useCanvasRemoteOperation() {
|
|
||||||
return useContext(CanvasRemoteOperationContext)?.run || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCanvasRemoteSnapshot() {
|
|
||||||
return useContext(CanvasRemoteOperationContext)?.applySnapshot || null;
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
|
||||||
|
|
||||||
type CanvasSurfaceHeaderProps = {
|
|
||||||
onAddImage: () => void;
|
|
||||||
onHome: () => void;
|
|
||||||
onOpenFiles: () => void;
|
|
||||||
projectTitle: string;
|
|
||||||
rightInset: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconButtonClass =
|
|
||||||
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
|
|
||||||
|
|
||||||
export function CanvasSurfaceHeader({
|
|
||||||
onAddImage,
|
|
||||||
onHome,
|
|
||||||
onOpenFiles,
|
|
||||||
projectTitle,
|
|
||||||
rightInset,
|
|
||||||
}: CanvasSurfaceHeaderProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="absolute left-0 top-0 z-20 flex h-[40px] items-center justify-between border-b border-[#ededed] bg-white px-3 dark:border-white/10 dark:bg-zinc-950"
|
|
||||||
data-canvas-topbar="true"
|
|
||||||
style={{ right: rightInset }}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<button
|
|
||||||
aria-label="返回首页"
|
|
||||||
className={iconButtonClass}
|
|
||||||
onClick={onHome}
|
|
||||||
title="返回首页"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ReferenceIcon name="branding" size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-label="添加参考素材"
|
|
||||||
className={iconButtonClass}
|
|
||||||
onClick={onAddImage}
|
|
||||||
title="添加参考素材"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ReferenceIcon name="reference" size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-label="查看项目文件"
|
|
||||||
className={iconButtonClass}
|
|
||||||
onClick={onOpenFiles}
|
|
||||||
title="查看项目文件"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ReferenceIcon name="brand" size={16} />
|
|
||||||
</button>
|
|
||||||
<div className="mx-1 h-4 w-px bg-[#ededed] dark:bg-white/10" />
|
|
||||||
<span className="inline-flex h-7 min-w-0 items-center gap-2 rounded-[8px] bg-[#ededed] px-3 text-[13px] text-[#333333] dark:bg-white/10 dark:text-white">
|
|
||||||
<ReferenceIcon name="page" size={16} />
|
|
||||||
<span className="max-w-[160px] truncate">
|
|
||||||
{projectTitle || "页面 1"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
aria-label="添加到画布"
|
|
||||||
className={iconButtonClass}
|
|
||||||
onClick={onAddImage}
|
|
||||||
title="添加到画布"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ReferenceIcon name="plus" size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,10 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import {
|
|
||||||
ReferenceIcon,
|
|
||||||
type ReferenceIconName,
|
|
||||||
} from "@/components/lovart/reference-icon";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { CanvasToolbarItemConfig } from "@/lib/canvas-toolbar-config";
|
import type { CanvasToolbarItemConfig } from "@/lib/canvas-toolbar-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -16,12 +12,13 @@ type CanvasToolbarIconProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
referenceName?: ReferenceIconName;
|
|
||||||
referenceSize?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function isSvgIconUrl(url: string) {
|
function isSvgIconUrl(url: string) {
|
||||||
return /^data:image\/svg\+xml/i.test(url) || /\.svg(?:[?#].*)?$/i.test(url);
|
return (
|
||||||
|
/^data:image\/svg\+xml/i.test(url) ||
|
||||||
|
/\.svg(?:[?#].*)?$/i.test(url)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripImageFilterClasses(className?: string) {
|
function stripImageFilterClasses(className?: string) {
|
||||||
@ -45,8 +42,6 @@ export function CanvasToolbarIcon({
|
|||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
active = false,
|
active = false,
|
||||||
referenceName,
|
|
||||||
referenceSize = 18,
|
|
||||||
}: CanvasToolbarIconProps) {
|
}: CanvasToolbarIconProps) {
|
||||||
const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim();
|
const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim();
|
||||||
|
|
||||||
@ -80,9 +75,7 @@ export function CanvasToolbarIcon({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"object-contain",
|
"object-contain",
|
||||||
active
|
active ? "brightness-0 invert opacity-95" : "brightness-0 opacity-80 dark:invert",
|
||||||
? "brightness-0 invert opacity-95"
|
|
||||||
: "brightness-0 opacity-80 dark:invert",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
src={resolvedIconUrl}
|
src={resolvedIconUrl}
|
||||||
@ -91,15 +84,5 @@ export function CanvasToolbarIcon({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (referenceName) {
|
|
||||||
return (
|
|
||||||
<ReferenceIcon
|
|
||||||
name={referenceName}
|
|
||||||
size={referenceSize}
|
|
||||||
className={className}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <FallbackIcon className={className} style={style} />;
|
return <FallbackIcon className={className} style={style} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,13 +12,8 @@ import { UiScreenNode } from "./nodes/UiScreenNode";
|
|||||||
import { VideoNode } from "./nodes/VideoNode";
|
import { VideoNode } from "./nodes/VideoNode";
|
||||||
import { WorkflowNode } from "./nodes/WorkflowNode";
|
import { WorkflowNode } from "./nodes/WorkflowNode";
|
||||||
import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
|
import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
|
||||||
import { CanvasBlankContextMenu } from "./CanvasBlankContextMenu";
|
|
||||||
import { getCanvasFitViewport } from "@/lib/canvas/viewport-fit";
|
|
||||||
import { downloadCanvasSelectionZip } from "@/lib/client/canvas-zip-download";
|
|
||||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { imageGenerationService } from "@/services/canvas";
|
import { imageGenerationService } from "@/services/canvas";
|
||||||
import {
|
import {
|
||||||
@ -33,7 +28,6 @@ import {
|
|||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
Loader2,
|
|
||||||
Minus,
|
Minus,
|
||||||
Pin,
|
Pin,
|
||||||
Plus,
|
Plus,
|
||||||
@ -396,11 +390,6 @@ export function CanvasWorkspace({
|
|||||||
} | null;
|
} | null;
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const contentLayerRef = useRef<HTMLDivElement>(null);
|
const contentLayerRef = useRef<HTMLDivElement>(null);
|
||||||
const contextImageInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const contextVideoInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const contextCanvasPointRef = useRef<Position>({ x: 0, y: 0 });
|
|
||||||
const [blankContextPosition, setBlankContextPosition] = useState<Position | null>(null);
|
|
||||||
const [isContextVideoUploading, setIsContextVideoUploading] = useState(false);
|
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
|
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
|
||||||
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
|
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
|
||||||
@ -738,6 +727,10 @@ export function CanvasWorkspace({
|
|||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imageLayoutReplace"
|
"imageLayoutReplace"
|
||||||
);
|
);
|
||||||
|
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
||||||
|
const selectedImageLocalEditEnabled = true;
|
||||||
|
// 排版替换同样是主无限画布图片的核心 AI 入口,旧配置缺少该 key 时也保持可见。
|
||||||
|
const selectedImageLayoutReplaceEnabled = true;
|
||||||
|
|
||||||
// Store actions
|
// Store actions
|
||||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||||
@ -1113,61 +1106,42 @@ export function CanvasWorkspace({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const fitAllImageNodesIntoViewport = useCallback(() => {
|
const fitAllImageNodesIntoViewport = useCallback(() => {
|
||||||
const fit = getCanvasFitViewport(
|
const imageNodes = nodes.filter(
|
||||||
nodes.filter((node) => node.type === "image" && Boolean((node.data as any)?.src)),
|
(node) => node.type === "image" && Boolean((node.data as any)?.src)
|
||||||
containerSize, occlusionRight
|
|
||||||
);
|
);
|
||||||
if (fit) setViewport(fit);
|
if (imageNodes.length === 0) return;
|
||||||
}, [containerSize, nodes, occlusionRight, setViewport]);
|
if (containerSize.width <= 0 || containerSize.height <= 0) return;
|
||||||
|
|
||||||
const fitAllCanvasNodesIntoViewport = useCallback(() => {
|
const padding = 80;
|
||||||
const fit = getCanvasFitViewport(nodes, containerSize, occlusionRight);
|
const minX = Math.min(...imageNodes.map((node) => node.position.x));
|
||||||
if (fit) setViewport(fit);
|
const minY = Math.min(...imageNodes.map((node) => node.position.y));
|
||||||
}, [containerSize, nodes, occlusionRight, setViewport]);
|
const maxX = Math.max(
|
||||||
|
...imageNodes.map((node) => node.position.x + node.size.width)
|
||||||
|
);
|
||||||
|
const maxY = Math.max(
|
||||||
|
...imageNodes.map((node) => node.position.y + node.size.height)
|
||||||
|
);
|
||||||
|
|
||||||
const handleBlankCanvasContextMenu = useCallback((event: React.MouseEvent) => {
|
const contentWidth = Math.max(1, maxX - minX + padding * 2);
|
||||||
if (isWorkflowCanvas || hasBlockingDialogOpen() || canvasImageSelectionSession) return;
|
const contentHeight = Math.max(1, maxY - minY + padding * 2);
|
||||||
if (!(event.target instanceof Element) || event.target.closest(
|
|
||||||
'[data-canvas-node-id], [data-canvas-overlay], [data-canvas-no-drag="true"], button, input, textarea, select, a, [contenteditable="true"]'
|
|
||||||
)) return;
|
|
||||||
const rect = containerRef.current?.getBoundingClientRect();
|
|
||||||
if (!rect) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
contextCanvasPointRef.current = {
|
|
||||||
x: (event.clientX - rect.left - viewportRef.current.x) / viewportRef.current.zoom,
|
|
||||||
y: (event.clientY - rect.top - viewportRef.current.y) / viewportRef.current.zoom,
|
|
||||||
};
|
|
||||||
setBlankContextPosition({ x: event.clientX, y: event.clientY });
|
|
||||||
setAgentSlotContextMenu(null);
|
|
||||||
}, [canvasImageSelectionSession, hasBlockingDialogOpen, isWorkflowCanvas]);
|
|
||||||
|
|
||||||
const zoomCanvasFromContextMenu = useCallback((factor: number) => {
|
const visibleWidth = Math.max(240, containerSize.width - Math.max(0, occlusionRight));
|
||||||
const current = useCanvasStore.getState().viewport;
|
const fitZoomX = visibleWidth / contentWidth;
|
||||||
const zoom = Math.max(0.1, Math.min(4, current.zoom * factor));
|
const fitZoomY = containerSize.height / contentHeight;
|
||||||
const centerX = Math.max(1, containerSize.width - occlusionRight) / 2;
|
const targetZoom = Math.max(0.05, Math.min(4, Math.min(fitZoomX, fitZoomY)));
|
||||||
const centerY = containerSize.height / 2;
|
|
||||||
const ratio = zoom / current.zoom;
|
|
||||||
setViewport({ x: centerX - (centerX - current.x) * ratio, y: centerY - (centerY - current.y) * ratio, zoom });
|
|
||||||
}, [containerSize, occlusionRight, setViewport]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
const centerX = (minX + maxX) / 2;
|
||||||
if (isWorkflowCanvas) return;
|
const centerY = (minY + maxY) / 2;
|
||||||
const handleFitShortcut = (event: KeyboardEvent) => {
|
const visibleCenterX = visibleWidth / 2;
|
||||||
if (!event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.code !== "Digit1") return;
|
const nextViewportX = visibleCenterX - centerX * targetZoom;
|
||||||
if (hasBlockingDialogOpen() || (event.target instanceof Element && event.target.closest(
|
const nextViewportY = containerSize.height / 2 - centerY * targetZoom;
|
||||||
'input, textarea, [contenteditable="true"], [role="textbox"]'
|
|
||||||
))) return;
|
setViewport({
|
||||||
const active = document.activeElement;
|
x: nextViewportX,
|
||||||
if (active && active !== document.body && !containerRef.current?.contains(active)
|
y: nextViewportY,
|
||||||
&& !active.closest('[data-canvas-floating-toolbar], [data-canvas-blank-menu="true"]')) return;
|
zoom: targetZoom,
|
||||||
event.preventDefault();
|
});
|
||||||
fitAllCanvasNodesIntoViewport();
|
}, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]);
|
||||||
setBlankContextPosition(null);
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handleFitShortcut);
|
|
||||||
return () => window.removeEventListener("keydown", handleFitShortcut);
|
|
||||||
}, [fitAllCanvasNodesIntoViewport, hasBlockingDialogOpen, isWorkflowCanvas]);
|
|
||||||
|
|
||||||
const getNodeInputAnchor = useCallback(
|
const getNodeInputAnchor = useCallback(
|
||||||
(node: CanvasNode): Position => ({
|
(node: CanvasNode): Position => ({
|
||||||
@ -1284,7 +1258,6 @@ export function CanvasWorkspace({
|
|||||||
if (canvasImageSelectionSession) return;
|
if (canvasImageSelectionSession) return;
|
||||||
if (hasBlockingDialogOpen()) return;
|
if (hasBlockingDialogOpen()) return;
|
||||||
setAgentSlotContextMenu(null);
|
setAgentSlotContextMenu(null);
|
||||||
setBlankContextPosition(null);
|
|
||||||
if (!isEventFromCanvasContainer(e.target)) return;
|
if (!isEventFromCanvasContainer(e.target)) return;
|
||||||
if (suppressCanvasClickRef.current) {
|
if (suppressCanvasClickRef.current) {
|
||||||
suppressCanvasClickRef.current = false;
|
suppressCanvasClickRef.current = false;
|
||||||
@ -1321,7 +1294,7 @@ export function CanvasWorkspace({
|
|||||||
!isPointOnNodeElement(e.target);
|
!isPointOnNodeElement(e.target);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(tool === 'pan' && e.button === 0) ||
|
tool === 'pan' ||
|
||||||
e.button === 1 ||
|
e.button === 1 ||
|
||||||
(e.button === 0 && e.altKey) ||
|
(e.button === 0 && e.altKey) ||
|
||||||
shouldPanBlankWorkflowCanvas
|
shouldPanBlankWorkflowCanvas
|
||||||
@ -1684,105 +1657,6 @@ export function CanvasWorkspace({
|
|||||||
}
|
}
|
||||||
}, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]);
|
}, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]);
|
||||||
|
|
||||||
const getContextUploadClientPoint = useCallback(() => {
|
|
||||||
const rect = containerRef.current?.getBoundingClientRect();
|
|
||||||
const current = viewportRef.current;
|
|
||||||
return {
|
|
||||||
x: (rect?.left ?? 0) + current.x + contextCanvasPointRef.current.x * current.zoom,
|
|
||||||
y: (rect?.top ?? 0) + current.y + contextCanvasPointRef.current.y * current.zoom,
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const pasteCanvasText = useCallback((text: string, point: Position) => {
|
|
||||||
if (!text.trim()) return;
|
|
||||||
addNode({
|
|
||||||
type: "text", position: point, size: { width: 320, height: 160 },
|
|
||||||
data: { content: text, color: "#111827" }, connections: [],
|
|
||||||
});
|
|
||||||
}, [addNode]);
|
|
||||||
|
|
||||||
const uploadContextVideos = useCallback(async (files: File[]) => {
|
|
||||||
if (!files.length || isContextVideoUploading) return;
|
|
||||||
const position = { ...contextCanvasPointRef.current };
|
|
||||||
setIsContextVideoUploading(true);
|
|
||||||
try {
|
|
||||||
for (const [index, file] of files.entries()) {
|
|
||||||
if (!["video/mp4", "video/quicktime", "video/webm"].includes(file.type)) {
|
|
||||||
toast.error("请选择 MP4、MOV 或 WebM 视频");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (file.size > 50 * 1024 * 1024) {
|
|
||||||
toast.error("视频文件不能超过 50MB");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const loadingToast = toast.loading(`正在上传 ${file.name}`);
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
formData.append("purpose", "canvas-video-process");
|
|
||||||
const response = await fetchUploadWithRetry(formData);
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok || !result.url) throw new Error(result.error || "视频上传失败");
|
|
||||||
addNode({
|
|
||||||
type: "video", position: { x: position.x + index * 24, y: position.y + index * 24 },
|
|
||||||
size: { width: 320, height: 180 },
|
|
||||||
data: { src: result.url, assetId: result.assetId, status: "completed", prompt: file.name,
|
|
||||||
metadata: { source: "local-upload", fileName: file.name, contentType: file.type } },
|
|
||||||
connections: [],
|
|
||||||
});
|
|
||||||
toast.success("视频已添加到画布");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "视频上传失败,请重试");
|
|
||||||
} finally {
|
|
||||||
toast.dismiss(loadingToast);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsContextVideoUploading(false);
|
|
||||||
}
|
|
||||||
}, [addNode, isContextVideoUploading]);
|
|
||||||
|
|
||||||
const pasteFromContextMenu = useCallback(async () => {
|
|
||||||
const pastePoint = { ...contextCanvasPointRef.current };
|
|
||||||
const clientPoint = getContextUploadClientPoint();
|
|
||||||
try {
|
|
||||||
const files: File[] = [];
|
|
||||||
let text = "";
|
|
||||||
if (typeof navigator.clipboard?.read === "function") {
|
|
||||||
const items = await navigator.clipboard.read();
|
|
||||||
for (const [index, item] of items.entries()) {
|
|
||||||
const imageType = item.types.find((type) => type.startsWith("image/"));
|
|
||||||
if (imageType) {
|
|
||||||
const blob = await item.getType(imageType);
|
|
||||||
files.push(new File([blob], `粘贴图片-${Date.now()}-${index}.${imageType.split("/")[1]}`, { type: imageType }));
|
|
||||||
} else if (item.types.includes("text/plain")) {
|
|
||||||
text += await (await item.getType("text/plain")).text();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (typeof navigator.clipboard?.readText === "function") {
|
|
||||||
text = await navigator.clipboard.readText();
|
|
||||||
} else {
|
|
||||||
throw new Error("Clipboard unavailable");
|
|
||||||
}
|
|
||||||
if (files.length) {
|
|
||||||
await processDroppedFiles(files, clientPoint.x, clientPoint.y);
|
|
||||||
} else if (text.trim()) {
|
|
||||||
pasteCanvasText(text, pastePoint);
|
|
||||||
} else {
|
|
||||||
toast.info("剪贴板中没有可粘贴的图片或文字");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.info(`无法读取剪贴板,请使用 ${isMacPlatform ? "⌘V" : "Ctrl+V"} 粘贴`);
|
|
||||||
}
|
|
||||||
}, [getContextUploadClientPoint, isMacPlatform, pasteCanvasText, processDroppedFiles]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!blankContextPosition) return;
|
|
||||||
const close = () => setBlankContextPosition(null);
|
|
||||||
window.addEventListener("resize", close);
|
|
||||||
return () => window.removeEventListener("resize", close);
|
|
||||||
}, [blankContextPosition]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleWindowDrop = async (e: DragEvent) => {
|
const handleWindowDrop = async (e: DragEvent) => {
|
||||||
if (!isFileDrag(e.dataTransfer)) return;
|
if (!isFileDrag(e.dataTransfer)) return;
|
||||||
@ -1845,44 +1719,24 @@ export function CanvasWorkspace({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handlePaste = async (e: ClipboardEvent) => {
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
const activeElement = document.activeElement;
|
if (hasBlockingDialogOpen() || isEditablePasteTarget(e.target)) return;
|
||||||
const fromContextMenu = Boolean(activeElement?.closest('[data-canvas-blank-menu="true"]'));
|
|
||||||
if (hasBlockingDialogOpen() || (!fromContextMenu && isEditablePasteTarget(e.target))) return;
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
const activeElement = document.activeElement;
|
||||||
if (
|
if (
|
||||||
activeElement instanceof HTMLElement &&
|
activeElement instanceof HTMLElement &&
|
||||||
activeElement !== document.body &&
|
activeElement !== document.body &&
|
||||||
!container.contains(activeElement) && !fromContextMenu
|
!container.contains(activeElement)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = extractImageFilesFromDataTransfer(e.clipboardData);
|
const files = extractImageFilesFromDataTransfer(e.clipboardData);
|
||||||
const text = e.clipboardData?.getData("text/plain") || "";
|
if (files.length === 0) return;
|
||||||
if (files.length === 0 && (isWorkflowCanvas || !text.trim())) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
if (fromContextMenu) {
|
|
||||||
setBlankContextPosition(null);
|
|
||||||
if (files.length) {
|
|
||||||
const point = getContextUploadClientPoint();
|
|
||||||
await processDroppedFiles(files, point.x, point.y);
|
|
||||||
} else {
|
|
||||||
pasteCanvasText(text, { ...contextCanvasPointRef.current });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (files.length === 0) {
|
|
||||||
const current = viewportRef.current;
|
|
||||||
pasteCanvasText(text, {
|
|
||||||
x: (rect.width / 2 - current.x) / current.zoom,
|
|
||||||
y: (rect.height / 2 - current.y) / current.zoom,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await processDroppedFiles(
|
await processDroppedFiles(
|
||||||
files,
|
files,
|
||||||
rect.left + rect.width / 2,
|
rect.left + rect.width / 2,
|
||||||
@ -1892,7 +1746,7 @@ export function CanvasWorkspace({
|
|||||||
|
|
||||||
window.addEventListener("paste", handlePaste);
|
window.addEventListener("paste", handlePaste);
|
||||||
return () => window.removeEventListener("paste", handlePaste);
|
return () => window.removeEventListener("paste", handlePaste);
|
||||||
}, [getContextUploadClientPoint, hasBlockingDialogOpen, isWorkflowCanvas, pasteCanvasText, processDroppedFiles]);
|
}, [hasBlockingDialogOpen, processDroppedFiles]);
|
||||||
|
|
||||||
const handleDownloadSelectedImages = useCallback(async () => {
|
const handleDownloadSelectedImages = useCallback(async () => {
|
||||||
if (selectedImageNodes.length === 0) {
|
if (selectedImageNodes.length === 0) {
|
||||||
@ -1915,10 +1769,10 @@ export function CanvasWorkspace({
|
|||||||
const rawImageUrl = String(nodeData?.src || "").trim();
|
const rawImageUrl = String(nodeData?.src || "").trim();
|
||||||
if (!rawImageUrl) return null;
|
if (!rawImageUrl) return null;
|
||||||
let imageUrl = inferOriginalImageUrl(rawImageUrl) || rawImageUrl;
|
let imageUrl = inferOriginalImageUrl(rawImageUrl) || rawImageUrl;
|
||||||
if (browserOrigin && rawImageUrl.startsWith("//")) {
|
if (browserOrigin && rawImageUrl.startsWith("/")) {
|
||||||
imageUrl = `${window.location.protocol}${rawImageUrl}`;
|
|
||||||
} else if (browserOrigin && rawImageUrl.startsWith("/")) {
|
|
||||||
imageUrl = `${browserOrigin}${rawImageUrl}`;
|
imageUrl = `${browserOrigin}${rawImageUrl}`;
|
||||||
|
} else if (browserOrigin && rawImageUrl.startsWith("//")) {
|
||||||
|
imageUrl = `${window.location.protocol}${rawImageUrl}`;
|
||||||
}
|
}
|
||||||
const name =
|
const name =
|
||||||
String(nodeData?.title || "").trim() ||
|
String(nodeData?.title || "").trim() ||
|
||||||
@ -1934,18 +1788,32 @@ export function CanvasWorkspace({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsDownloadSubmitting(true);
|
setIsDownloadSubmitting(true);
|
||||||
const toastId = toast.loading("正在准备所选图片...");
|
|
||||||
try {
|
try {
|
||||||
const result = await downloadCanvasSelectionZip({ folderName, files,
|
const form = document.createElement("form");
|
||||||
selectionKey: `${useCanvasStore.getState().projectId}:${selectedImageNodes.map(node => node.id).sort().join(",")}`,
|
form.method = "POST";
|
||||||
onProgress: task => toast.loading(`${task.stage}(${task.progress}%)`, { id: toastId }),
|
form.action = "/api/canvas/download-zip";
|
||||||
|
form.style.display = "none";
|
||||||
|
|
||||||
|
const payloadInput = document.createElement("input");
|
||||||
|
payloadInput.type = "hidden";
|
||||||
|
payloadInput.name = "payload";
|
||||||
|
payloadInput.value = JSON.stringify({
|
||||||
|
folderName,
|
||||||
|
files,
|
||||||
});
|
});
|
||||||
if (result.failedCount) toast.warning(`已开始下载 ${result.successCount} 张图片,${result.failedCount} 张未能读取;ZIP 内附失败清单`, { id: toastId, duration: 10000 });
|
form.appendChild(payloadInput);
|
||||||
else toast.success(`ZIP 已准备完成,正在下载 ${result.successCount} 张图片`, { id: toastId });
|
|
||||||
} catch (error) {
|
document.body.appendChild(form);
|
||||||
toast.error(error instanceof Error ? error.message : "打包下载失败,请稍后重试", { id: toastId });
|
form.submit();
|
||||||
|
form.remove();
|
||||||
|
|
||||||
|
toast.success("已开始打包下载,请留意浏览器下载栏");
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("下载失败,请稍后重试");
|
||||||
} finally {
|
} finally {
|
||||||
|
window.setTimeout(() => {
|
||||||
setIsDownloadSubmitting(false);
|
setIsDownloadSubmitting(false);
|
||||||
|
}, 800);
|
||||||
}
|
}
|
||||||
}, [isDownloadSubmitting, selectedImageNodes]);
|
}, [isDownloadSubmitting, selectedImageNodes]);
|
||||||
|
|
||||||
@ -2401,47 +2269,12 @@ export function CanvasWorkspace({
|
|||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
onDoubleClick={handleBlankCanvasDoubleClick}
|
onDoubleClick={handleBlankCanvasDoubleClick}
|
||||||
onContextMenu={handleBlankCanvasContextMenu}
|
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
{!isWorkflowCanvas && (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={contextImageInputRef} type="file" accept="image/*" multiple className="hidden"
|
|
||||||
aria-label="从右键菜单上传图片"
|
|
||||||
onChange={(event) => {
|
|
||||||
const files = Array.from(event.target.files || []);
|
|
||||||
event.target.value = "";
|
|
||||||
const point = getContextUploadClientPoint();
|
|
||||||
void processDroppedFiles(files, point.x, point.y);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={contextVideoInputRef} type="file" accept="video/mp4,video/quicktime,video/webm,.mp4,.mov,.webm" multiple className="hidden"
|
|
||||||
aria-label="从右键菜单上传视频"
|
|
||||||
onChange={(event) => {
|
|
||||||
const files = Array.from(event.target.files || []);
|
|
||||||
event.target.value = "";
|
|
||||||
void uploadContextVideos(files);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CanvasBlankContextMenu
|
|
||||||
position={blankContextPosition} isMac={isMacPlatform} videoUploading={isContextVideoUploading}
|
|
||||||
onClose={() => setBlankContextPosition(null)}
|
|
||||||
onUploadImage={() => contextImageInputRef.current?.click()}
|
|
||||||
onUploadVideo={() => contextVideoInputRef.current?.click()}
|
|
||||||
onPaste={() => { void pasteFromContextMenu(); }}
|
|
||||||
onZoomIn={() => zoomCanvasFromContextMenu(1.2)}
|
|
||||||
onZoomOut={() => zoomCanvasFromContextMenu(1 / 1.2)}
|
|
||||||
onFitAll={fitAllCanvasNodesIntoViewport}
|
|
||||||
onResetZoom={() => setViewport({ x: 0, y: 0, zoom: 1 })}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isWorkflowCanvas ? (
|
{isWorkflowCanvas ? (
|
||||||
<>
|
<>
|
||||||
<div className="pointer-events-none absolute inset-0 pattern-grid-lg text-white opacity-[0.065]" />
|
<div className="pointer-events-none absolute inset-0 pattern-grid-lg text-white opacity-[0.065]" />
|
||||||
@ -2733,14 +2566,10 @@ export function CanvasWorkspace({
|
|||||||
)}
|
)}
|
||||||
{!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
|
{!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="absolute bottom-16 left-4 z-30 flex items-center gap-2"
|
||||||
"absolute left-4 z-30 flex items-center gap-2",
|
|
||||||
isWorkflowCanvas ? "bottom-16" : "bottom-[20px]"
|
|
||||||
)}
|
|
||||||
data-canvas-overlay
|
data-canvas-overlay
|
||||||
data-canvas-selection-actions="true"
|
|
||||||
>
|
>
|
||||||
{isWorkflowCanvas && selectedImageNodes.length === 1 ? (
|
{selectedImageLocalEditEnabled && selectedImageNodes.length === 1 ? (
|
||||||
<Button
|
<Button
|
||||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -2762,7 +2591,7 @@ export function CanvasWorkspace({
|
|||||||
{selectedImageLocalEditItem.label}
|
{selectedImageLocalEditItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{isWorkflowCanvas &&
|
{selectedImageLayoutReplaceEnabled &&
|
||||||
selectedImageNodes.length >= 1 ? (
|
selectedImageNodes.length >= 1 ? (
|
||||||
<Button
|
<Button
|
||||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||||
@ -2791,48 +2620,27 @@ export function CanvasWorkspace({
|
|||||||
{selectedImageLayoutReplaceItem.label}
|
{selectedImageLayoutReplaceItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||||
size={isWorkflowCanvas ? "sm" : "icon"}
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
isWorkflowCanvas
|
"h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60",
|
||||||
? "h-8 gap-1.5 rounded-full border border-[#dbe2ee] bg-white px-3 text-xs text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
isWorkflowCanvas &&
|
||||||
: "h-[32px] w-[32px] rounded-[8px] border border-[#e9e9e9] bg-white p-0 text-[#333333] shadow-[0_1px_3px_rgba(0,0,0,0.035)] hover:bg-[#f5f5f5] dark:border-white/10 dark:bg-zinc-950 dark:text-white dark:hover:bg-white/10"
|
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||||
)}
|
)}
|
||||||
onClick={handleDownloadSelectedImages}
|
onClick={handleDownloadSelectedImages}
|
||||||
disabled={isDownloadSubmitting}
|
disabled={isDownloadSubmitting}
|
||||||
aria-label={
|
title={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}
|
||||||
isDownloadSubmitting
|
|
||||||
? "打包中..."
|
|
||||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{!isWorkflowCanvas && isDownloadSubmitting ? (
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className={isWorkflowCanvas ? "h-3.5 w-3.5" : "h-5 w-5"}
|
className="h-3.5 w-3.5"
|
||||||
fallback={Download}
|
fallback={Download}
|
||||||
referenceName={isWorkflowCanvas ? undefined : "image-download"}
|
|
||||||
referenceSize={20}
|
|
||||||
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
|
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{isWorkflowCanvas
|
|
||||||
? isDownloadSubmitting
|
|
||||||
? "打包中..."
|
|
||||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
|
|
||||||
: null}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
{isDownloadSubmitting
|
{isDownloadSubmitting
|
||||||
? "打包中..."
|
? "打包中..."
|
||||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
|
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
|
||||||
</TooltipContent>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -15,9 +15,7 @@ import { AudioGenerationComposer } from "./AudioGenerationComposer";
|
|||||||
import { TVstarPanel, type TVstarGenerationModel, type TVstarGenerationSelection, type TVstarModel, type TVstarNodeContext, type TVstarReference, type TVstarShot } from "./tvstar/TVstarPanel";
|
import { TVstarPanel, type TVstarGenerationModel, type TVstarGenerationSelection, type TVstarModel, type TVstarNodeContext, type TVstarReference, type TVstarShot } from "./tvstar/TVstarPanel";
|
||||||
import { resolveTVstarGenerationReferences } from "./tvstar/generation-references";
|
import { resolveTVstarGenerationReferences } from "./tvstar/generation-references";
|
||||||
import { tvstarTiming } from "@/lib/video-canvas/tvstar-timing";
|
import { tvstarTiming } from "@/lib/video-canvas/tvstar-timing";
|
||||||
import { tvstarOutputRequirements } from "@/lib/video-canvas/tvstar-output";
|
|
||||||
import { CharacterStudioPanel, type StudioCharacter, type StudioView } from "./tvstar/CharacterStudioPanel";
|
import { CharacterStudioPanel, type StudioCharacter, type StudioView } from "./tvstar/CharacterStudioPanel";
|
||||||
import { tvstarShotComposition } from "@/lib/video-canvas/tvstar-shot-input";
|
|
||||||
import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice";
|
import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice";
|
||||||
import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story";
|
import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
@ -104,11 +102,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ZCODE_VIDEO_CANVAS_AUTHORIZED } from "@/lib/ai/canvas-agent-runtime/authorization";
|
|
||||||
import { CanvasAgentUiListener } from "./chat-panel/canvas-agent-ui-listener";
|
|
||||||
import type { CanvasAgentUiCommand } from "@/lib/ai/canvas-agent-runtime/ui-command";
|
|
||||||
import { VideoCanvasAgentPanel } from "./chat-panel/video-canvas-agent-panel";
|
|
||||||
import { mergeVideoAgentResults } from "@/lib/ai/canvas-agent-runtime/video-protocol";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import {
|
import {
|
||||||
getGenerationCreditCostFromImageGenConfig,
|
getGenerationCreditCostFromImageGenConfig,
|
||||||
@ -821,28 +814,28 @@ const STARTER_CARDS = [
|
|||||||
title: "故事脚本生成",
|
title: "故事脚本生成",
|
||||||
subtitle: "脚本到分镜",
|
subtitle: "脚本到分镜",
|
||||||
coverUrl:
|
coverUrl:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png",
|
||||||
steps: ["故事脚本", "多模态文本模型Pro", "可灵 3.0", "预览"],
|
steps: ["故事脚本", "多模态文本模型Pro", "可灵 3.0", "预览"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "角色三视图",
|
title: "角色三视图",
|
||||||
subtitle: "角色资产沉淀",
|
subtitle: "角色资产沉淀",
|
||||||
coverUrl:
|
coverUrl:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png",
|
||||||
steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"],
|
steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "全能参考生视频",
|
title: "全能参考生视频",
|
||||||
subtitle: "参考图到成片",
|
subtitle: "参考图到成片",
|
||||||
coverUrl:
|
coverUrl:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png",
|
||||||
steps: ["上传图片", "首帧图", "可灵 3.0", "预览"],
|
steps: ["上传图片", "首帧图", "可灵 3.0", "预览"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "音频生视频",
|
title: "音频生视频",
|
||||||
subtitle: "音频驱动画面",
|
subtitle: "音频驱动画面",
|
||||||
coverUrl:
|
coverUrl:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png",
|
||||||
icon: "music",
|
icon: "music",
|
||||||
steps: ["上传音频", "音频主视频", "Wan 2.6", "预览"],
|
steps: ["上传音频", "音频主视频", "Wan 2.6", "预览"],
|
||||||
},
|
},
|
||||||
@ -865,7 +858,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
|||||||
badge: "预设",
|
badge: "预设",
|
||||||
category: "周星驰经典名场面",
|
category: "周星驰经典名场面",
|
||||||
cover:
|
cover:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png",
|
||||||
description: "左弧滑行,镜头从人物左侧弧线推进,带轻微喜剧节奏和夸张表情停顿。",
|
description: "左弧滑行,镜头从人物左侧弧线推进,带轻微喜剧节奏和夸张表情停顿。",
|
||||||
duration: "5s",
|
duration: "5s",
|
||||||
id: "left-arc-slide",
|
id: "left-arc-slide",
|
||||||
@ -880,7 +873,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
|||||||
badge: "商用",
|
badge: "商用",
|
||||||
category: "电商展示",
|
category: "电商展示",
|
||||||
cover:
|
cover:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png",
|
||||||
description: "电商手机弹出效果,产品从画面中心弹出并旋转定格,适合主图视频开场。",
|
description: "电商手机弹出效果,产品从画面中心弹出并旋转定格,适合主图视频开场。",
|
||||||
duration: "5s",
|
duration: "5s",
|
||||||
id: "phone-pop-product",
|
id: "phone-pop-product",
|
||||||
@ -895,7 +888,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
|||||||
badge: "商用",
|
badge: "商用",
|
||||||
category: "电商展示",
|
category: "电商展示",
|
||||||
cover:
|
cover:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png",
|
||||||
description: "咖啡杯出场,杯身从桌面滑入,蒸汽和柔光形成温暖的品牌氛围。",
|
description: "咖啡杯出场,杯身从桌面滑入,蒸汽和柔光形成温暖的品牌氛围。",
|
||||||
duration: "5s",
|
duration: "5s",
|
||||||
id: "coffee-cup-entry",
|
id: "coffee-cup-entry",
|
||||||
@ -910,7 +903,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
|||||||
badge: "热门",
|
badge: "热门",
|
||||||
category: "镜头运动",
|
category: "镜头运动",
|
||||||
cover:
|
cover:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png",
|
||||||
description: "360旋转展示,围绕主体做一圈稳定环绕,适合产品、人物和空间展示。",
|
description: "360旋转展示,围绕主体做一圈稳定环绕,适合产品、人物和空间展示。",
|
||||||
duration: "6s",
|
duration: "6s",
|
||||||
id: "orbit-360-showcase",
|
id: "orbit-360-showcase",
|
||||||
@ -924,7 +917,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
|||||||
author: "工业镜头",
|
author: "工业镜头",
|
||||||
category: "镜头运动",
|
category: "镜头运动",
|
||||||
cover:
|
cover:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-image.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png",
|
||||||
description: "机械臂视角,以第一视角穿过机械臂与产品线,增强科技感和空间纵深。",
|
description: "机械臂视角,以第一视角穿过机械臂与产品线,增强科技感和空间纵深。",
|
||||||
duration: "5s",
|
duration: "5s",
|
||||||
id: "robot-arm-pov",
|
id: "robot-arm-pov",
|
||||||
@ -939,7 +932,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
|||||||
badge: "NEW",
|
badge: "NEW",
|
||||||
category: "人物动画",
|
category: "人物动画",
|
||||||
cover:
|
cover:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-three-view.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-three-view.png",
|
||||||
description: "Live 2D,让单张角色图产生眨眼、微笑和轻微身体摆动,适合口播开场。",
|
description: "Live 2D,让单张角色图产生眨眼、微笑和轻微身体摆动,适合口播开场。",
|
||||||
duration: "4s",
|
duration: "4s",
|
||||||
id: "live-2d-character",
|
id: "live-2d-character",
|
||||||
@ -953,7 +946,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
|||||||
author: "电影运镜",
|
author: "电影运镜",
|
||||||
category: "镜头运动",
|
category: "镜头运动",
|
||||||
cover:
|
cover:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png",
|
||||||
description: "瞳孔拉近,镜头快速推向眼部并在反光里展示下一场景,适合悬疑转场。",
|
description: "瞳孔拉近,镜头快速推向眼部并在反光里展示下一场景,适合悬疑转场。",
|
||||||
duration: "5s",
|
duration: "5s",
|
||||||
id: "pupil-zoom",
|
id: "pupil-zoom",
|
||||||
@ -968,7 +961,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
|||||||
badge: "热门",
|
badge: "热门",
|
||||||
category: "人物动画",
|
category: "人物动画",
|
||||||
cover:
|
cover:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png",
|
||||||
description: "飞鸟解体,主体由细碎羽片散开再重组,适合视觉冲击强的短视频片头。",
|
description: "飞鸟解体,主体由细碎羽片散开再重组,适合视觉冲击强的短视频片头。",
|
||||||
duration: "6s",
|
duration: "6s",
|
||||||
id: "bird-dissolve",
|
id: "bird-dissolve",
|
||||||
@ -983,7 +976,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
|||||||
badge: "商用",
|
badge: "商用",
|
||||||
category: "电商展示",
|
category: "电商展示",
|
||||||
cover:
|
cover:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png",
|
||||||
description: "商品震撼登场,产品带冲击波与局部高光进入画面,适合活动主推款。",
|
description: "商品震撼登场,产品带冲击波与局部高光进入画面,适合活动主推款。",
|
||||||
duration: "5s",
|
duration: "5s",
|
||||||
id: "product-hero-entry",
|
id: "product-hero-entry",
|
||||||
@ -997,7 +990,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
|
|||||||
author: "旅拍模板",
|
author: "旅拍模板",
|
||||||
category: "镜头运动",
|
category: "镜头运动",
|
||||||
cover:
|
cover:
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png",
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png",
|
||||||
description: "旅拍转场,前景遮挡切换到新地点,带自然手持感和轻微速度变化。",
|
description: "旅拍转场,前景遮挡切换到新地点,带自然手持感和轻微速度变化。",
|
||||||
duration: "5s",
|
duration: "5s",
|
||||||
id: "travel-whip-transition",
|
id: "travel-whip-transition",
|
||||||
@ -1160,9 +1153,9 @@ const NINE_GRID_CELL_VISUALS: GridCellVisual[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TARGET_AVATAR =
|
const TARGET_AVATAR =
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/target-avatar.jpg";
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/target-avatar.jpg";
|
||||||
const PUBLISH_MODAL_BG_URL =
|
const PUBLISH_MODAL_BG_URL =
|
||||||
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/publish-modal-bg.png";
|
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/publish-modal-bg.png";
|
||||||
const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本";
|
const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本";
|
||||||
const AUDIO_TO_VIDEO_PROMPT =
|
const AUDIO_TO_VIDEO_PROMPT =
|
||||||
"根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。";
|
"根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。";
|
||||||
@ -4573,8 +4566,6 @@ export function LiblibVideoCanvasReplica({
|
|||||||
);
|
);
|
||||||
const [nodes, setNodes] = useState<CloneNode[]>([]);
|
const [nodes, setNodes] = useState<CloneNode[]>([]);
|
||||||
const activeGenerationSubmissions = useRef(new Set<string>());
|
const activeGenerationSubmissions = useRef(new Set<string>());
|
||||||
const [isVideoAgentOpen, setIsVideoAgentOpen] = useState(false);
|
|
||||||
const receivedAgentNodeIds = useRef(new Set<string>());
|
|
||||||
const [edges, setEdges] = useState<CloneEdge[]>([]);
|
const [edges, setEdges] = useState<CloneEdge[]>([]);
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -4767,7 +4758,6 @@ export function LiblibVideoCanvasReplica({
|
|||||||
setNodes(migrateLegacyGroups(restored));
|
setNodes(migrateLegacyGroups(restored));
|
||||||
setMultiSelection([]);
|
setMultiSelection([]);
|
||||||
setGroupTemplates((project.canvasData as any)?.groupTemplates||[]);
|
setGroupTemplates((project.canvasData as any)?.groupTemplates||[]);
|
||||||
receivedAgentNodeIds.current = new Set(restored.map((node) => node.id));
|
|
||||||
setEdges(restoreEdges(project.canvasData));
|
setEdges(restoreEdges(project.canvasData));
|
||||||
const storedViewport = data?.viewport;
|
const storedViewport = data?.viewport;
|
||||||
setViewport(storedViewport && [storedViewport.x, storedViewport.y, storedViewport.zoom].every(Number.isFinite)
|
setViewport(storedViewport && [storedViewport.x, storedViewport.y, storedViewport.zoom].every(Number.isFinite)
|
||||||
@ -4873,66 +4863,6 @@ export function LiblibVideoCanvasReplica({
|
|||||||
applyMergedSnapshot,
|
applyMergedSnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshVideoAgentResults = useCallback(async (projectId: string, resultNodeIds: string[]) => {
|
|
||||||
if (projectId !== canvasId) return;
|
|
||||||
const result = await canvasService.getProject(canvasId);
|
|
||||||
if (!result.success || !result.data) return;
|
|
||||||
const incoming = restoreNodes(result.data.canvasData).filter((node) => resultNodeIds.includes(node.id));
|
|
||||||
const unseen = incoming.filter((node) => !receivedAgentNodeIds.current.has(node.id));
|
|
||||||
for (const node of incoming) receivedAgentNodeIds.current.add(node.id);
|
|
||||||
setNodes((current) => mergeVideoAgentResults(current.map((node) => {
|
|
||||||
const remote = incoming.find((item) => item.id === node.id);
|
|
||||||
return remote && remote.draftValues?.temporaryVideoUrl === node.draftValues?.mediaUrl && remote.draftValues?.mediaUrl !== node.draftValues?.mediaUrl ? { ...node, draftValues: { ...node.draftValues, mediaUrl: remote.draftValues?.mediaUrl, url: remote.draftValues?.url } } : node;
|
|
||||||
}), unseen));
|
|
||||||
setEdges((current) => mergeVideoAgentResults(current, restoreEdges(result.data!.canvasData).filter((edge) => unseen.some((node) => node.id === edge.target))));
|
|
||||||
}, [canvasId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = async (event: Event) => {
|
|
||||||
const change = (event as CustomEvent<{ projectId: string; createdNodeIds?: string[]; updatedNodeIds?: string[]; deletedNodeIds?: string[]; action?: string; nodeIds?: string[]; viewportChanged?: boolean }>).detail;
|
|
||||||
if (change?.projectId !== canvasId) return;
|
|
||||||
const response = await canvasService.getProject(canvasId);
|
|
||||||
if (!response.success || !response.data) return;
|
|
||||||
const remote = restoreNodes(response.data.canvasData);
|
|
||||||
const changed = new Set([...(change.createdNodeIds || []), ...(change.updatedNodeIds || [])]);
|
|
||||||
const deleted = new Set(change.deletedNodeIds || []);
|
|
||||||
setNodes((current) => mergeVideoAgentResults(current.filter((node) => !deleted.has(node.id)).map((node) => changed.has(node.id) ? remote.find((item) => item.id === node.id) || node : node), remote.filter((node) => changed.has(node.id))));
|
|
||||||
setEdges((current) => {
|
|
||||||
const kept = current.filter((edge) => !deleted.has(edge.source) && !deleted.has(edge.target) && !(change.action === "disconnect" && edge.source === change.nodeIds?.[0] && edge.target === change.nodeIds?.[1]));
|
|
||||||
return change.action === "connect" ? mergeVideoAgentResults(kept, restoreEdges(response.data!.canvasData).filter((edge) => edge.source === change.nodeIds?.[0] && edge.target === change.nodeIds?.[1])) : kept;
|
|
||||||
});
|
|
||||||
if (change.viewportChanged && response.data.canvasData?.viewport) setViewport(response.data.canvasData.viewport);
|
|
||||||
};
|
|
||||||
window.addEventListener("video-canvas-agent-mutation", handler);
|
|
||||||
return () => window.removeEventListener("video-canvas-agent-mutation", handler);
|
|
||||||
}, [canvasId]);
|
|
||||||
|
|
||||||
const saveBeforeVideoAgent = useCallback(async () => {
|
|
||||||
const statusResponse = await fetch(`/api/canvas/agent-runs?${new URLSearchParams({ chatId: canvasId, projectId: canvasId })}`, { cache: "no-store" });
|
|
||||||
if (statusResponse.ok && (await statusResponse.json()).run?.status === "running") throw new Error("当前视频任务还在执行,请使用补充要求。");
|
|
||||||
const remote = await canvasService.getProject(canvasId);
|
|
||||||
if (!remote.success || !remote.data) throw new Error("无法读取视频画布,请稍后重试");
|
|
||||||
const remoteNodes = restoreNodes(remote.data.canvasData);
|
|
||||||
const unseen = remoteNodes.filter((node) => node.draftValues?.canvasAgentRunId && !receivedAgentNodeIds.current.has(node.id));
|
|
||||||
const syncedNodes = nodes.map((node) => {
|
|
||||||
const archived = remoteNodes.find((item) => item.id === node.id && item.draftValues?.temporaryVideoUrl === node.draftValues?.mediaUrl);
|
|
||||||
return archived ? { ...node, draftValues: { ...node.draftValues, mediaUrl: archived.draftValues?.mediaUrl, url: archived.draftValues?.url, temporaryVideoUrl: archived.draftValues?.temporaryVideoUrl } } : node;
|
|
||||||
});
|
|
||||||
const mergedNodes = mergeVideoAgentResults(syncedNodes, unseen);
|
|
||||||
const mergedEdges = mergeVideoAgentResults(edges, restoreEdges(remote.data.canvasData).filter((edge) => unseen.some((node) => node.id === edge.target)));
|
|
||||||
const response = await fetch("/api/canvas/video-agent", {
|
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ projectId: canvasId, baseRevision: remote.data.updatedAt, canvasData: {
|
|
||||||
nodes: serializeNodes(mergedNodes), edges: serializeEdges(mergedEdges),
|
|
||||||
selectedNodeIds: selectedNodeId ? [selectedNodeId] : [], viewport, tool: "select",
|
|
||||||
} }),
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok) throw new Error(result.error || "画布保存失败,请稍后重试");
|
|
||||||
if (result.success) { setNodes(mergedNodes); setEdges(mergedEdges); for (const node of unseen) receivedAgentNodeIds.current.add(node.id); }
|
|
||||||
if (!result.success) throw new Error(result.error || "画布保存失败,请稍后重试");
|
|
||||||
}, [canvasId, nodes, edges, selectedNodeId, viewport]);
|
|
||||||
|
|
||||||
const commitTitle = useCallback(() => {
|
const commitTitle = useCallback(() => {
|
||||||
const nextTitle = draftTitle.trim() || "未命名";
|
const nextTitle = draftTitle.trim() || "未命名";
|
||||||
setProjectTitle(nextTitle);
|
setProjectTitle(nextTitle);
|
||||||
@ -5620,7 +5550,7 @@ export function LiblibVideoCanvasReplica({
|
|||||||
const draft = (d.smartEditDraft || {}) as any, plan = draft.plans?.[draft.selectedPlanIndex || 0] || d.editPlan;
|
const draft = (d.smartEditDraft || {}) as any, plan = draft.plans?.[draft.selectedPlanIndex || 0] || d.editPlan;
|
||||||
const changes = sourceEdges.some(e => e.target === node.id && groupMembers(sourceNodes, groupId).some(m => m.id === e.source && (/GENERATE|UPSCALE/.test(String(m.draftValues?.action)) || m.draftValues?.generationTaskId)));
|
const changes = sourceEdges.some(e => e.target === node.id && groupMembers(sourceNodes, groupId).some(m => m.id === e.source && (/GENERATE|UPSCALE/.test(String(m.draftValues?.action)) || m.draftValues?.generationTaskId)));
|
||||||
if (plan && !changes) return { kind: "smart-edit", params: { operation: "render", plan } };
|
if (plan && !changes) return { kind: "smart-edit", params: { operation: "render", plan } };
|
||||||
return { kind: "smart-edit", params: { operation: "plan", autoRender: true, mode: draft.mode || "sequence", targetDuration: draft.targetDuration || 300, shotDurations: draft.shotDurations, shotDescriptions: draft.shotDescriptions, ratio: draft.ratio || "16:9", resolution: draft.resolution || "720p", semantic: draft.semantic ?? false, muteAudio: draft.muteAudio === true, preferredModelRef: draft.preferredModelRef, prompt: draft.prompt || "", removeSilence: draft.removeSilence || false, transcribe: draft.transcribe || false, music: draft.music, beatSync: draft.beatSync || false, variants: 1 } };
|
return { kind: "smart-edit", params: { operation: "plan", autoRender: true, mode: draft.mode || "sequence", targetDuration: draft.targetDuration || 300, shotDurations: draft.shotDurations, shotDescriptions: draft.shotDescriptions, ratio: draft.ratio || "16:9", resolution: draft.resolution || "720p", semantic: draft.semantic ?? false, preferredModelRef: draft.preferredModelRef, prompt: draft.prompt || "", removeSilence: draft.removeSilence || false, transcribe: draft.transcribe || false, music: draft.music, beatSync: draft.beatSync || false, variants: 1 } };
|
||||||
}
|
}
|
||||||
const key = String(p.functionKey || d.functionKey || (node.inputType === "video" ? "text-to-video" : node.inputType === "image" ? "" : "story-script"));
|
const key = String(p.functionKey || d.functionKey || (node.inputType === "video" ? "text-to-video" : node.inputType === "image" ? "" : "story-script"));
|
||||||
const definition = key ? getConfiguredFunction(videoCanvasFunctionConfig, key) : { key: "image-generate", kind: "image-edit" as const, route: "canvas-generate-image" as const, enabled: true, label: "图片生成", prompt: "", description: "" };
|
const definition = key ? getConfiguredFunction(videoCanvasFunctionConfig, key) : { key: "image-generate", kind: "image-edit" as const, route: "canvas-generate-image" as const, enabled: true, label: "图片生成", prompt: "", description: "" };
|
||||||
@ -5638,10 +5568,6 @@ export function LiblibVideoCanvasReplica({
|
|||||||
|
|
||||||
const applyTVstarShots = useCallback(async (shots: TVstarShot[], goal: string, references: TVstarReference[] = [], selection: TVstarGenerationSelection = {}, runMode: "submit" | "prepare" = "submit") => {
|
const applyTVstarShots = useCallback(async (shots: TVstarShot[], goal: string, references: TVstarReference[] = [], selection: TVstarGenerationSelection = {}, runMode: "submit" | "prepare" = "submit") => {
|
||||||
if (!shots.length) return false;
|
if (!shots.length) return false;
|
||||||
let output: ReturnType<typeof tvstarOutputRequirements>;
|
|
||||||
let timing: ReturnType<typeof tvstarTiming>;
|
|
||||||
try { output = tvstarOutputRequirements(goal); timing = tvstarTiming(goal, shots); }
|
|
||||||
catch (error) { toast.error(error instanceof Error ? error.message : "成片要求无效"); return false; }
|
|
||||||
const videoDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "first-frame-video");
|
const videoDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "first-frame-video");
|
||||||
if (runMode === "submit" && (!videoCanvasFunctionConfig.enabled || !videoDefinition.enabled)) {
|
if (runMode === "submit" && (!videoCanvasFunctionConfig.enabled || !videoDefinition.enabled)) {
|
||||||
toast.error("首帧生视频功能尚未启用,无法创建可执行分镜");
|
toast.error("首帧生视频功能尚未启用,无法创建可执行分镜");
|
||||||
@ -5657,9 +5583,8 @@ export function LiblibVideoCanvasReplica({
|
|||||||
if (selection.videoModelRef && !filterCanvasModels(availableModels, "video", "first-frame")
|
if (selection.videoModelRef && !filterCanvasModels(availableModels, "video", "first-frame")
|
||||||
.some(model => model.id === selection.videoModelRef || model.modelId === selection.videoModelRef))
|
.some(model => model.id === selection.videoModelRef || model.modelId === selection.videoModelRef))
|
||||||
throw new Error("所选视频模型不支持首帧生视频,请换一个模型");
|
throw new Error("所选视频模型不支持首帧生视频,请换一个模型");
|
||||||
videoSettings = shots.map((_shot, index) => chooseStoryVideoSettings(
|
videoSettings = shots.map(shot => chooseStoryVideoSettings(
|
||||||
availableModels, true, timing.shotDurations[index], preferredModel,
|
availableModels, true, Number.parseFloat(shot.duration) || 5, preferredModel,
|
||||||
{ ...output, minimumDuration: true, minimumResolution: true, strictModel: Boolean(selection.videoModelRef) },
|
|
||||||
));
|
));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (runMode === "submit") {
|
if (runMode === "submit") {
|
||||||
@ -5669,7 +5594,7 @@ export function LiblibVideoCanvasReplica({
|
|||||||
videoSettings = shots.map(shot => ({
|
videoSettings = shots.map(shot => ({
|
||||||
mode: "first-frame" as const, model: selection.videoModelRef || "",
|
mode: "first-frame" as const, model: selection.videoModelRef || "",
|
||||||
duration: Math.max(1, Math.min(10, Math.ceil(Number.parseFloat(shot.duration) || 5))),
|
duration: Math.max(1, Math.min(10, Math.ceil(Number.parseFloat(shot.duration) || 5))),
|
||||||
ratio: output.ratio, resolution: output.resolution, generateAudio: output.generateAudio ?? false,
|
ratio: "16:9", resolution: "720p", generateAudio: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
let referenceImages: string[];
|
let referenceImages: string[];
|
||||||
@ -5699,35 +5624,31 @@ export function LiblibVideoCanvasReplica({
|
|||||||
x, y,
|
x, y,
|
||||||
});
|
});
|
||||||
script.draftValues = { ...script.draftValues, tvstarShots: shots, tvstarReferences: references, source: "tvstar" };
|
script.draftValues = { ...script.draftValues, tvstarShots: shots, tvstarReferences: references, source: "tvstar" };
|
||||||
|
const timing = tvstarTiming(goal, shots);
|
||||||
const nextNodes: CloneNode[] = [script];
|
const nextNodes: CloneNode[] = [script];
|
||||||
const nextEdges: CloneEdge[] = [];
|
const nextEdges: CloneEdge[] = [];
|
||||||
const videoNodeIds: string[] = [];
|
const videoNodeIds: string[] = [];
|
||||||
shots.forEach((shot, index) => {
|
shots.forEach((shot, index) => {
|
||||||
const settings = videoSettings[index];
|
const settings = videoSettings[index];
|
||||||
const rowY = y + index * 450;
|
const rowY = y + index * 450;
|
||||||
const imagePrompt = [`仅生成镜头 ${shot.shot} 的一张电影关键帧。`, shot.imagePrompt || shot.sceneDescription, tvstarShotComposition(goal)].join("\n");
|
const imagePrompt = shot.imagePrompt || shot.sceneDescription;
|
||||||
const image = createTargetModuleNode("image", x + 560, rowY, nodes.length + index * 2 + 1);
|
const image = createTargetModuleNode("image", x + 560, rowY, nodes.length + index * 2 + 1);
|
||||||
image.title = `镜头 ${shot.shot} · 关键帧`;
|
image.title = `镜头 ${shot.shot} · 关键帧`;
|
||||||
image.draftValues = {
|
image.draftValues = {
|
||||||
...image.draftValues,
|
...image.draftValues,
|
||||||
prompt: imagePrompt,
|
prompt: imagePrompt,
|
||||||
params: { prompt: imagePrompt, aspectRatio: output.ratio, referenceImages, ...(imageModelRef ? { model: imageModelRef } : {}) },
|
params: { prompt: imagePrompt, aspectRatio: "16:9", referenceImages, ...(imageModelRef ? { model: imageModelRef } : {}) },
|
||||||
tvstarShot: shot.shot,
|
tvstarShot: shot.shot,
|
||||||
source: "tvstar",
|
source: "tvstar",
|
||||||
};
|
};
|
||||||
const video = createTargetModuleNode("video", x + 1020, rowY, nodes.length + index * 2 + 2);
|
const video = createTargetModuleNode("video", x + 1020, rowY, nodes.length + index * 2 + 2);
|
||||||
video.title = `镜头 ${shot.shot} · 视频`;
|
video.title = `镜头 ${shot.shot} · 视频`;
|
||||||
const videoPrompt = [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement,
|
|
||||||
...(settings.generateAudio ? [shot.dialogue, shot.backgroundMusic, shot.voiceAndSfx] : []),
|
|
||||||
`整片背景要求(当前只拍镜头 ${shot.shot},不执行其他镜头):${goal}`,
|
|
||||||
tvstarShotComposition(goal), "保持当前镜头连续运动,不生成片中片,不切换到其他分镜。",
|
|
||||||
].filter(Boolean).join("。");
|
|
||||||
video.draftValues = {
|
video.draftValues = {
|
||||||
...video.draftValues,
|
...video.draftValues,
|
||||||
prompt: videoPrompt,
|
prompt: [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。"),
|
||||||
params: {
|
params: {
|
||||||
mode: "first-frame",
|
mode: "first-frame",
|
||||||
prompt: videoPrompt,
|
prompt: [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。"),
|
||||||
model: settings.model,
|
model: settings.model,
|
||||||
duration: settings.duration,
|
duration: settings.duration,
|
||||||
ratio: settings.ratio,
|
ratio: settings.ratio,
|
||||||
@ -5751,9 +5672,8 @@ export function LiblibVideoCanvasReplica({
|
|||||||
source: "tvstar",
|
source: "tvstar",
|
||||||
smartEditDraft: {
|
smartEditDraft: {
|
||||||
mode: "sequence",
|
mode: "sequence",
|
||||||
ratio: output.ratio,
|
ratio: "16:9",
|
||||||
resolution: output.resolution,
|
resolution: "720p",
|
||||||
muteAudio: output.generateAudio === false,
|
|
||||||
targetDuration: timing.targetDuration,
|
targetDuration: timing.targetDuration,
|
||||||
shotDurations: timing.shotDurations,
|
shotDurations: timing.shotDurations,
|
||||||
shotDescriptions: shots.map(shot => [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。").slice(0, 600)),
|
shotDescriptions: shots.map(shot => [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。").slice(0, 600)),
|
||||||
@ -5807,9 +5727,6 @@ export function LiblibVideoCanvasReplica({
|
|||||||
const applyTVstarEdit = useCallback(async (references: TVstarReference[], goal: string) => {
|
const applyTVstarEdit = useCallback(async (references: TVstarReference[], goal: string) => {
|
||||||
const videos = references.filter(item => item.type === "video").slice(0, 12);
|
const videos = references.filter(item => item.type === "video").slice(0, 12);
|
||||||
if (!videos.length) { toast.error("请先引用至少一个视频片段"); return false; }
|
if (!videos.length) { toast.error("请先引用至少一个视频片段"); return false; }
|
||||||
let output: ReturnType<typeof tvstarOutputRequirements>;
|
|
||||||
try { output = tvstarOutputRequirements(goal); }
|
|
||||||
catch (error) { toast.error(error instanceof Error ? error.message : "成片要求无效"); return false; }
|
|
||||||
const timing = tvstarTiming(goal, videos.map(item => ({ duration: `${Math.min(item.duration || 5, 5)}s` })));
|
const timing = tvstarTiming(goal, videos.map(item => ({ duration: `${Math.min(item.duration || 5, 5)}s` })));
|
||||||
const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
|
const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
|
||||||
const x = Math.round(center.x - 380);
|
const x = Math.round(center.x - 380);
|
||||||
@ -5830,7 +5747,7 @@ export function LiblibVideoCanvasReplica({
|
|||||||
const result = createTargetModuleNode("smart-edit", x + 1140, y + 90, nodes.length + videos.length + 1);
|
const result = createTargetModuleNode("smart-edit", x + 1140, y + 90, nodes.length + videos.length + 1);
|
||||||
result.title = `TVstar · ${timing.targetDuration} 秒智能成片`;
|
result.title = `TVstar · ${timing.targetDuration} 秒智能成片`;
|
||||||
result.draftValues = { ...result.draftValues, source: "tvstar", smartEditDraft: {
|
result.draftValues = { ...result.draftValues, source: "tvstar", smartEditDraft: {
|
||||||
mode: "sequence", ratio: output.ratio, resolution: output.resolution, muteAudio: output.generateAudio === false,
|
mode: "sequence", ratio: "16:9", resolution: "720p",
|
||||||
targetDuration: timing.targetDuration, shotDurations: timing.shotDurations,
|
targetDuration: timing.targetDuration, shotDurations: timing.shotDurations,
|
||||||
shotDescriptions: videos.map(item => `从《${item.title}》选取符合用户要求的真实高光画面:${goal}`.slice(0, 600)),
|
shotDescriptions: videos.map(item => `从《${item.title}》选取符合用户要求的真实高光画面:${goal}`.slice(0, 600)),
|
||||||
semantic: true, preferredModelRef: videoCanvasFunctionConfig.defaultMultimodalModel || undefined,
|
semantic: true, preferredModelRef: videoCanvasFunctionConfig.defaultMultimodalModel || undefined,
|
||||||
@ -5862,18 +5779,9 @@ export function LiblibVideoCanvasReplica({
|
|||||||
|
|
||||||
const applyCharacterStudio = useCallback(async (character: StudioCharacter, style: string, views: StudioView[], referenceUrl?: string): Promise<boolean> => {
|
const applyCharacterStudio = useCallback(async (character: StudioCharacter, style: string, views: StudioView[], referenceUrl?: string): Promise<boolean> => {
|
||||||
if (!views.length) return false;
|
if (!views.length) return false;
|
||||||
const requiresIdentityReference = Boolean(referenceUrl) || views.length > 1;
|
const imageCandidates = filterCanvasModels(canvasModelOptions, referenceUrl ? "image-edit" : "image");
|
||||||
// Recover a missed initial catalog load before deciding that generation is unavailable.
|
if (referenceUrl && !imageCandidates.length) {
|
||||||
let availableModels = canvasModelOptions;
|
toast.error("没有支持参考图的图片模型,无法按角色参考图出图");
|
||||||
if (!availableModels.length) {
|
|
||||||
const response = await fetch("/api/models", { cache: "no-store" });
|
|
||||||
if (!response.ok) throw Error("角色出图模型暂时无法读取,请稍后重试");
|
|
||||||
const catalog = await response.json();
|
|
||||||
availableModels = Array.isArray(catalog.models) ? catalog.models : [];
|
|
||||||
}
|
|
||||||
const imageCandidates = filterCanvasModels(availableModels, requiresIdentityReference ? "image-edit" : "image");
|
|
||||||
if (!imageCandidates.length) {
|
|
||||||
toast.error(requiresIdentityReference ? "没有支持参考图的图片模型,无法保持多视图角色一致性" : "没有可执行的图片模型");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const imageModelRef = imageCandidates.find(model => model.id === videoCanvasFunctionConfig.defaultImageModel
|
const imageModelRef = imageCandidates.find(model => model.id === videoCanvasFunctionConfig.defaultImageModel
|
||||||
@ -5881,44 +5789,31 @@ export function LiblibVideoCanvasReplica({
|
|||||||
const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
|
const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
|
||||||
const x = Math.round(center.x - 360);
|
const x = Math.round(center.x - 360);
|
||||||
const y = Math.round(center.y - 160);
|
const y = Math.round(center.y - 160);
|
||||||
// Appearance is shared; generated prompt framing must not override each view's composition.
|
const identity = [character.name, character.description, character.look, character.wardrobe, character.prompt, `风格:${style}`].filter(Boolean).join("。 ");
|
||||||
const identity = [character.name, character.description, character.look, character.wardrobe,
|
|
||||||
...(character.look || character.wardrobe ? [] : [character.prompt]), `风格:${style}`].filter(Boolean).join("。 ");
|
|
||||||
const source = createTextResourceNode({ content: identity, title: `${character.name} · 角色设定`, x, y });
|
const source = createTextResourceNode({ content: identity, title: `${character.name} · 角色设定`, x, y });
|
||||||
source.draftValues = { ...source.draftValues, source: "character-studio", characterIdentity: identity };
|
source.draftValues = { ...source.draftValues, source: "character-studio", characterIdentity: identity };
|
||||||
const viewPrompts: Record<StudioView, string> = {
|
const viewPrompts: Record<StudioView, string> = {
|
||||||
"角色立绘": "全身角色立绘,站姿自然,人物完整入画,纯净背景,清晰展示稳定面部特征、服装和道具。",
|
"角色立绘": "全身角色立绘,站姿自然,人物完整入画,纯净背景,清晰展示稳定面部特征、服装和道具。",
|
||||||
"脸部近景": "只拍同一角色脸部近景,脸部占据画幅主体,最多露出肩部,不出现全身或半身立绘;保持五官、发型与标志性特征完全一致,柔和电影布光。",
|
"脸部近景": "同一角色脸部近景,保持五官、发型与标志性特征完全一致,柔和电影布光。",
|
||||||
"表情参考": "同一角色表情设定图,四宫格分别展示平静、喜悦、惊讶、愤怒,五官与服装细节一致。",
|
"表情参考": "同一角色表情设定图,四宫格分别展示平静、喜悦、惊讶、愤怒,五官与服装细节一致。",
|
||||||
"角色三视图": "同一角色正面、侧面、背面三视图并排,站姿和服装统一,清晰标注轮廓,纯净背景。",
|
"角色三视图": "同一角色正面、侧面、背面三视图并排,站姿和服装统一,清晰标注轮廓,纯净背景。",
|
||||||
};
|
};
|
||||||
const nextNodes: CloneNode[] = [source];
|
const nextNodes: CloneNode[] = [source];
|
||||||
const nextEdges: CloneEdge[] = [];
|
const nextEdges: CloneEdge[] = [];
|
||||||
// A single generated identity anchor feeds every other view. Text alone
|
views.forEach((view, index) => {
|
||||||
// does not constrain an image model to reproduce the same face.
|
const prompt = `${identity}\n${viewPrompts[view]} 不改变角色身份,不增加多余人物。`;
|
||||||
const orderedViews: StudioView[] = views.includes("角色立绘")
|
|
||||||
? ["角色立绘", ...views.filter(view => view !== "角色立绘")]
|
|
||||||
: views;
|
|
||||||
let identityAnchorId: string | undefined;
|
|
||||||
orderedViews.forEach((view, index) => {
|
|
||||||
const singleView = view === "角色立绘" || view === "脸部近景"
|
|
||||||
? "只生成一张单一完整构图,禁止拼贴、画中画、分屏、宫格和附加小窗;参考图只用于人物身份,不复用其构图。" : "";
|
|
||||||
const prompt = `${identity}\n${viewPrompts[view]} 不改变角色身份,不增加多余人物。${singleView}`;
|
|
||||||
const node = createTargetModuleNode("image", x + 500 + index * 440, y, nodes.length + index + 1);
|
const node = createTargetModuleNode("image", x + 500 + index * 440, y, nodes.length + index + 1);
|
||||||
node.title = `${character.name} · ${view}`;
|
node.title = `${character.name} · ${view}`;
|
||||||
node.draftValues = {
|
node.draftValues = {
|
||||||
...node.draftValues,
|
...node.draftValues,
|
||||||
prompt,
|
prompt,
|
||||||
params: { prompt, aspectRatio: view === "角色三视图" || view === "表情参考" ? "16:9" : "3:4", ...(referenceUrl && index === 0 ? { referenceImages: [referenceUrl] } : {}), ...(imageModelRef ? { model: imageModelRef } : {}) },
|
params: { prompt, aspectRatio: view === "角色三视图" || view === "表情参考" ? "16:9" : "3:4", ...(referenceUrl ? { referenceImages: [referenceUrl] } : {}), ...(imageModelRef ? { model: imageModelRef } : {}) },
|
||||||
characterIdentity: identity,
|
characterIdentity: identity,
|
||||||
characterView: view,
|
characterView: view,
|
||||||
characterAnchorId: identityAnchorId,
|
|
||||||
source: "character-studio",
|
source: "character-studio",
|
||||||
};
|
};
|
||||||
nextNodes.push(node);
|
nextNodes.push(node);
|
||||||
nextEdges.push({ id: makeId("edge"), source: source.id, target: node.id });
|
nextEdges.push({ id: makeId("edge"), source: source.id, target: node.id });
|
||||||
if (identityAnchorId) nextEdges.push({ id: makeId("edge"), source: identityAnchorId, target: node.id });
|
|
||||||
else identityAnchorId = node.id;
|
|
||||||
});
|
});
|
||||||
const groupId = makeId("character-group");
|
const groupId = makeId("character-group");
|
||||||
const groupNodes = makeCanvasGroup(nextNodes, nextNodes.map(node => node.id), groupId, `${character.name} · 角色造型`);
|
const groupNodes = makeCanvasGroup(nextNodes, nextNodes.map(node => node.id), groupId, `${character.name} · 角色造型`);
|
||||||
@ -10966,50 +10861,6 @@ export function LiblibVideoCanvasReplica({
|
|||||||
};
|
};
|
||||||
}, [lineConnectState, nodes]);
|
}, [lineConnectState, nodes]);
|
||||||
|
|
||||||
const handleAgentVideoUiCommand = async (command: CanvasAgentUiCommand) => {
|
|
||||||
const target = nodes.find((node) => node.id === (command.nodeIds[0] || selectedNodeId));
|
|
||||||
if (command.nodeIds.some((id) => !nodes.some((node) => node.id === id))) throw new Error("目标节点不属于当前视频画布");
|
|
||||||
if (target) { setSelectedNodeId(target.id); setSelectedEdgeId(null); }
|
|
||||||
if (command.featureId.startsWith("video.function.")) {
|
|
||||||
const key = command.featureId.slice("video.function.".length);
|
|
||||||
const tool = configuredTools.find((item) => item.toolKey === key);
|
|
||||||
if (!tool) throw new Error("此视频功能已关闭或尚未配置");
|
|
||||||
if ((key === "grid-split" || key === "image-crop") && target) { await runSelectedImageToolAction(target.id, "crop"); return; }
|
|
||||||
if (key === "angle-edit" && target) { setAngleEditorNodeId(target.id); return; }
|
|
||||||
const created = createToolNode(tool, target ? target.x + target.width + 72 : 360, target?.y || 300);
|
|
||||||
addNodes([created], command.nodeIds.map((id) => ({ id: makeId("edge"), source: id, target: created.id })));
|
|
||||||
setInspectorNodeId(created.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const action = command.featureId.replace("video.ui.", "");
|
|
||||||
const panels: Record<string, Exclude<ActivePanel, null>> = { assets: "assets", roles: "roleLibrary", styles: "styleLibrary", effects: "effectLibrary", history: "history", workflows: "workflow", share: "share", publish: "publish", skills: "skills", help: "help" };
|
|
||||||
if (panels[action]) { setActivePanel(panels[action]); return; }
|
|
||||||
if (action.startsWith("upload-")) { addTargetModule(action === "upload-video" ? "video" : action === "upload-audio" ? "audio" : "image"); toast.message("已添加上传模块,请选择本机文件"); return; }
|
|
||||||
if (action === "save") { if (!(await persistence.saveAndConfirm())) throw new Error("画布保存尚未确认,请稍后重试"); return; }
|
|
||||||
if (action === "export") {
|
|
||||||
const url = URL.createObjectURL(new Blob([JSON.stringify({ title: projectTitle, workspaceType: "video", canvasData: { nodes: serializeNodes(nodes), edges: serializeEdges(edges), viewport } }, null, 2)], { type: "application/json" }));
|
|
||||||
const link = document.createElement("a"); link.href = url; link.download = `${projectTitle}.json`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); return;
|
|
||||||
}
|
|
||||||
if (action === "import") {
|
|
||||||
const picker = document.createElement("input"); picker.type = "file"; picker.accept = ".json,application/json";
|
|
||||||
picker.onchange = async () => { try { const file = picker.files?.[0]; if (!file) return; const data = JSON.parse(await file.text()); const imported = restoreNodes(data.canvasData || data); if (!imported.length) throw new Error("文件中没有可用的视频画布节点"); if (!window.confirm("确认将文件里的节点添加到当前画布?")) return; const ids = new Map(imported.map((node) => [node.id, makeId("import")])); addNodes(imported.map((node) => ({ ...node, id: ids.get(node.id)! })), restoreEdges(data.canvasData || data).filter((edge) => ids.has(edge.source) && ids.has(edge.target)).map((edge) => ({ id: makeId("edge"), source: ids.get(edge.source)!, target: ids.get(edge.target)! }))); } catch (error) { toast.error(error instanceof Error ? error.message : "导入失败"); } }; picker.click(); return;
|
|
||||||
}
|
|
||||||
if (!target) throw new Error("请先选中具体素材节点");
|
|
||||||
if (action === "download") { downloadMediaNode(target); return; }
|
|
||||||
if (action === "preview") { setPreviewNode(target); return; }
|
|
||||||
if (action === "local-edit") { await runSelectedImageToolAction(target.id, "localEdit"); return; }
|
|
||||||
if (action === "annotate") { await runSelectedImageToolAction(target.id, "annotate"); return; }
|
|
||||||
if (action === "director") { setDirectorConsoleNodeId(target.id); return; }
|
|
||||||
if (action === "storyboard") { openStoryScriptWorkflow(target.id); return; }
|
|
||||||
if (action === "batch-storyboard" || action === "batch-video") { openStoryScriptWorkflow(target.id); toast.message("请检查分镜及费用后在工作台提交批量任务"); return; }
|
|
||||||
if (action === "video-hd") { setInspectorNodeId(target.id); toast.message("当前视频 AI 高清尚缺真实后端,不能将原视频冒充高清结果"); return; }
|
|
||||||
if (action === "video-clip") { setActiveVideoToolbarMode("clip"); return; }
|
|
||||||
if (action === "video-crop") { setActiveVideoToolbarMode("crop"); toast.message("当前裁剪导出后端尚未接入,不能当作已完成的视频裁剪"); return; }
|
|
||||||
if (action === "subtitle-erase") { setActiveVideoToolbarMode("subtitleErase"); toast.message("当前去字幕尚未接入真实处理后端"); return; }
|
|
||||||
if (["audio-split", "voice-split", "background-split", "frame-first", "frame-last", "frame-current"].includes(action)) { setPreviewNode(target); toast.message("已授权入口;当前页面此功能仍为模拟流程,Agent 不会生成占位素材或伪报完成"); return; }
|
|
||||||
throw new Error("此入口需要在相应的视频工作区打开");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="video-canvas-workspace fixed inset-0 h-dvh w-full overflow-clip bg-[#151515] text-white"
|
className="video-canvas-workspace fixed inset-0 h-dvh w-full overflow-clip bg-[#151515] text-white"
|
||||||
@ -11052,7 +10903,6 @@ export function LiblibVideoCanvasReplica({
|
|||||||
void addUploadedFilesAtPoint(files, toCanvasPoint(event.clientX, event.clientY));
|
void addUploadedFilesAtPoint(files, toCanvasPoint(event.clientX, event.clientY));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CanvasAgentUiListener projectId={canvasId} workspace="video" onVideoAction={handleAgentVideoUiCommand} />
|
|
||||||
<div className="pointer-events-none absolute inset-0" style={{backgroundImage:"radial-gradient(circle,rgba(255,255,255,0.14) 0.7px,transparent 0.7px)",backgroundPosition:"0.5px 0.5px",backgroundSize:"16px 16px"}} />
|
<div className="pointer-events-none absolute inset-0" style={{backgroundImage:"radial-gradient(circle,rgba(255,255,255,0.14) 0.7px,transparent 0.7px)",backgroundPosition:"0.5px 0.5px",backgroundSize:"16px 16px"}} />
|
||||||
{fileDragActive && (
|
{fileDragActive && (
|
||||||
<div className="pointer-events-none absolute inset-3 z-[1800] flex items-center justify-center rounded-2xl border-2 border-dashed border-cyan-300 bg-cyan-300/5" role="status" aria-live="polite">
|
<div className="pointer-events-none absolute inset-3 z-[1800] flex items-center justify-center rounded-2xl border-2 border-dashed border-cyan-300 bg-cyan-300/5" role="status" aria-live="polite">
|
||||||
@ -11941,29 +11791,6 @@ export function LiblibVideoCanvasReplica({
|
|||||||
onResolve={persistence.resolveConflict}
|
onResolve={persistence.resolveConflict}
|
||||||
onDownload={persistence.downloadDraft}
|
onDownload={persistence.downloadDraft}
|
||||||
/>
|
/>
|
||||||
{ZCODE_VIDEO_CANVAS_AUTHORIZED && (isVideoAgentOpen ? (
|
|
||||||
<VideoCanvasAgentPanel
|
|
||||||
key={canvasId}
|
|
||||||
projectId={canvasId}
|
|
||||||
selectedNodeIds={selectedNodeId ? [selectedNodeId] : []}
|
|
||||||
videoModels={canvasModelOptions.filter((model) => isVideoModelDescriptor(model))}
|
|
||||||
imageModels={canvasModelOptions.filter((model) => isImageGenerationModelDescriptor(model))}
|
|
||||||
beforeSend={saveBeforeVideoAgent}
|
|
||||||
onResults={(projectId, ids) => void refreshVideoAgentResults(projectId, ids)}
|
|
||||||
onClose={() => setIsVideoAgentOpen(false)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-canvas-overlay="true"
|
|
||||||
onClick={() => { setInspectorNodeId(null); setIsVideoAgentOpen(true); }}
|
|
||||||
className="absolute right-4 top-[76px] z-40 flex items-center gap-2 rounded-xl border border-white/10 bg-[#252525] px-4 py-3 text-sm text-white shadow-lg hover:bg-[#303030]"
|
|
||||||
aria-label="打开视频创作 Agent"
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4" />视频 Agent
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<TopBar
|
<TopBar
|
||||||
canvasId={canvasId}
|
canvasId={canvasId}
|
||||||
canvasViewMode={canvasViewMode}
|
canvasViewMode={canvasViewMode}
|
||||||
@ -21834,8 +21661,8 @@ const STYLE_MARKET_CATEGORIES = [
|
|||||||
"全部",
|
"全部",
|
||||||
];
|
];
|
||||||
|
|
||||||
const STYLE_MARKET_THUMBNAIL_URL = "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png";
|
const STYLE_MARKET_THUMBNAIL_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png";
|
||||||
const EFFECT_MARKET_THUMBNAIL_URL = "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png";
|
const EFFECT_MARKET_THUMBNAIL_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png";
|
||||||
|
|
||||||
const STYLE_MARKET_CARDS: AssetMarketCard[] = [
|
const STYLE_MARKET_CARDS: AssetMarketCard[] = [
|
||||||
["复古马卡龙", "算力潮汐", 177],
|
["复古马卡龙", "算力潮汐", 177],
|
||||||
|
|||||||
@ -1,452 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ChevronRight, X } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Children,
|
|
||||||
cloneElement,
|
|
||||||
forwardRef,
|
|
||||||
isValidElement,
|
|
||||||
useState,
|
|
||||||
type HTMLAttributes,
|
|
||||||
type MouseEvent,
|
|
||||||
type ReactElement,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
|
||||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import {
|
|
||||||
saveImageToolbarPreferences,
|
|
||||||
useImageToolbarPreferences,
|
|
||||||
type ImageToolbarPreferences,
|
|
||||||
} from "@/lib/canvas-image-toolbar-preferences";
|
|
||||||
|
|
||||||
type ToolProps = HTMLAttributes<HTMLButtonElement> & {
|
|
||||||
"data-tool-key"?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
children?: ReactNode;
|
|
||||||
};
|
|
||||||
type Tool = ReactElement<ToolProps>;
|
|
||||||
type Props = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
referencePresentation: boolean;
|
|
||||||
defaultPinnedKeys: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function toolLabel(tool: Tool) {
|
|
||||||
return tool.props.title || "";
|
|
||||||
}
|
|
||||||
function toolIcon(tool: Tool, size: number) {
|
|
||||||
const icon = Children.toArray(tool.props.children).find(isValidElement);
|
|
||||||
if (
|
|
||||||
!isValidElement<{
|
|
||||||
className?: string;
|
|
||||||
style?: object;
|
|
||||||
referenceSize?: number;
|
|
||||||
}>(icon)
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
return cloneElement(icon, {
|
|
||||||
className: "shrink-0",
|
|
||||||
style: { width: size, height: size },
|
|
||||||
...(icon.type === CanvasToolbarIcon ? { referenceSize: size } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function toolCredit(tool: Tool) {
|
|
||||||
return Children.toArray(tool.props.children).find(
|
|
||||||
(child) =>
|
|
||||||
isValidElement<{ compact?: boolean; label?: string }>(child) &&
|
|
||||||
child.props.compact &&
|
|
||||||
child.props.label
|
|
||||||
) as
|
|
||||||
| ReactElement<{ compact?: boolean; label?: string; className?: string }>
|
|
||||||
| undefined;
|
|
||||||
}
|
|
||||||
function tooltipButton(tool: Tool, showNames: boolean) {
|
|
||||||
const name = toolLabel(tool);
|
|
||||||
// Clone the existing action so its handler, busy state and administrator icon override remain intact.
|
|
||||||
const button = cloneElement(tool, {
|
|
||||||
title: undefined,
|
|
||||||
"aria-label": name,
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
{toolIcon(tool, 25)}
|
|
||||||
{showNames && <span className="lovart-image-tool-label">{name}</span>}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<Tooltip key={tool.props["data-tool-key"] || name}>
|
|
||||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
className="lovart-image-tool-tooltip"
|
|
||||||
side="top"
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
{toolCredit(tool) &&
|
|
||||||
cloneElement(toolCredit(tool)!, {
|
|
||||||
compact: false,
|
|
||||||
className: "lovart-image-tool-cost",
|
|
||||||
})}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReferenceImageToolbar = forwardRef<HTMLDivElement, Props>(
|
|
||||||
function ReferenceImageToolbar(
|
|
||||||
{ referencePresentation, defaultPinnedKeys, children, ...rootProps },
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
const saved = useImageToolbarPreferences();
|
|
||||||
const [draft, setDraft] = useState<ImageToolbarPreferences | null>(null);
|
|
||||||
const [draggedKey, setDraggedKey] = useState<string | null>(null);
|
|
||||||
const elements = Children.toArray(children).filter(
|
|
||||||
isValidElement
|
|
||||||
) as ReactElement<ToolProps>[];
|
|
||||||
const tools = elements.filter(
|
|
||||||
(element) =>
|
|
||||||
element.props["data-tool-key"] &&
|
|
||||||
!["addToChat", "download"].includes(element.props["data-tool-key"]!)
|
|
||||||
);
|
|
||||||
const toolMap = new Map(
|
|
||||||
tools.map((tool) => [tool.props["data-tool-key"]!, tool])
|
|
||||||
);
|
|
||||||
const preferences = saved || {
|
|
||||||
pinnedKeys: defaultPinnedKeys,
|
|
||||||
showNames: false,
|
|
||||||
};
|
|
||||||
const pinned = preferences.pinnedKeys.filter((key) => toolMap.has(key));
|
|
||||||
const moreTools = tools.filter(
|
|
||||||
(tool) => !pinned.includes(tool.props["data-tool-key"]!)
|
|
||||||
);
|
|
||||||
const addToChat = elements.find(
|
|
||||||
(element) => element.props["data-tool-key"] === "addToChat"
|
|
||||||
);
|
|
||||||
const download = elements.find(
|
|
||||||
(element) => element.props["data-tool-key"] === "download"
|
|
||||||
);
|
|
||||||
const originalMenu = elements.find(
|
|
||||||
(element) => element.type === DropdownMenu
|
|
||||||
);
|
|
||||||
const menuChildren = originalMenu
|
|
||||||
? (Children.toArray(originalMenu.props.children).filter(
|
|
||||||
isValidElement
|
|
||||||
) as ReactElement<{ children?: ReactNode }>[])
|
|
||||||
: [];
|
|
||||||
const menuTrigger = menuChildren.find(
|
|
||||||
(element) => element.type === DropdownMenuTrigger
|
|
||||||
);
|
|
||||||
const moreButton =
|
|
||||||
menuTrigger &&
|
|
||||||
(Children.toArray(menuTrigger.props.children).find(isValidElement) as
|
|
||||||
| Tool
|
|
||||||
| undefined);
|
|
||||||
const extras = menuChildren.find(
|
|
||||||
(element) => element.type === DropdownMenuContent
|
|
||||||
)?.props.children;
|
|
||||||
const openCustomizer = () =>
|
|
||||||
setDraft({
|
|
||||||
pinnedKeys: [...preferences.pinnedKeys],
|
|
||||||
showNames: preferences.showNames,
|
|
||||||
});
|
|
||||||
const toggle = (key: string) =>
|
|
||||||
setDraft((current) =>
|
|
||||||
current
|
|
||||||
? {
|
|
||||||
...current,
|
|
||||||
pinnedKeys: current.pinnedKeys.includes(key)
|
|
||||||
? current.pinnedKeys.filter((item) => item !== key)
|
|
||||||
: [...current.pinnedKeys, key],
|
|
||||||
}
|
|
||||||
: current
|
|
||||||
);
|
|
||||||
const move = (key: string, target: string) =>
|
|
||||||
setDraft((current) => {
|
|
||||||
if (!current || key === target) return current;
|
|
||||||
const next = [...current.pinnedKeys];
|
|
||||||
const from = next.indexOf(key),
|
|
||||||
to = next.indexOf(target);
|
|
||||||
if (from < 0 || to < 0) return current;
|
|
||||||
next.splice(from, 1);
|
|
||||||
next.splice(to, 0, key);
|
|
||||||
return { ...current, pinnedKeys: next };
|
|
||||||
});
|
|
||||||
if (!referencePresentation)
|
|
||||||
return (
|
|
||||||
<div {...rootProps} ref={ref}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
{...rootProps}
|
|
||||||
ref={ref}
|
|
||||||
style={{ ...rootProps.style, overflowX: "auto" }}
|
|
||||||
data-show-tool-names={preferences.showNames ? "true" : "false"}
|
|
||||||
>
|
|
||||||
{addToChat && tooltipButton(addToChat, preferences.showNames)}
|
|
||||||
{addToChat && <div className="w-px" />}
|
|
||||||
{pinned.map((key) =>
|
|
||||||
tooltipButton(toolMap.get(key)!, preferences.showNames)
|
|
||||||
)}
|
|
||||||
<DropdownMenu>
|
|
||||||
{moreButton && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
{cloneElement(moreButton, {
|
|
||||||
title: undefined,
|
|
||||||
"aria-label": toolLabel(moreButton),
|
|
||||||
children: toolIcon(moreButton, 25),
|
|
||||||
})}
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
className="lovart-image-tool-tooltip"
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
{toolLabel(moreButton)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="lovart-image-action-menu lovart-image-custom-more"
|
|
||||||
side="bottom"
|
|
||||||
align="end"
|
|
||||||
>
|
|
||||||
{moreTools.map((tool) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={tool.props["data-tool-key"]}
|
|
||||||
disabled={tool.props.disabled}
|
|
||||||
onClick={(event) =>
|
|
||||||
tool.props.onClick?.(
|
|
||||||
event as unknown as MouseEvent<HTMLButtonElement>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{toolIcon(tool, 18)}
|
|
||||||
<span>{toolLabel(tool)}</span>
|
|
||||||
{toolCredit(tool) &&
|
|
||||||
cloneElement(toolCredit(tool)!, {
|
|
||||||
compact: false,
|
|
||||||
className: "lovart-image-tool-cost",
|
|
||||||
})}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
{extras}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="lovart-customize-entry"
|
|
||||||
onSelect={openCustomizer}
|
|
||||||
>
|
|
||||||
自定义工具栏
|
|
||||||
<ChevronRight size={14} />
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{download && (
|
|
||||||
<>
|
|
||||||
<div className="w-px" />
|
|
||||||
{tooltipButton(download, preferences.showNames)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Dialog
|
|
||||||
open={Boolean(draft)}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setDraft(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent
|
|
||||||
hideCloseButton
|
|
||||||
className="lovart-toolbar-customizer"
|
|
||||||
overlayClassName="lovart-toolbar-customizer-overlay"
|
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<header className="lovart-customizer-header">
|
|
||||||
<div>
|
|
||||||
<DialogTitle>自定义工具栏</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
选择你想在编辑栏中使用的工具
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
<div className="lovart-customizer-header-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="恢复默认工具栏"
|
|
||||||
onClick={() =>
|
|
||||||
setDraft({
|
|
||||||
pinnedKeys: [...defaultPinnedKeys],
|
|
||||||
showNames: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ReferenceIcon name="toolbar-reset" size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="关闭自定义工具栏"
|
|
||||||
onClick={() => setDraft(null)}
|
|
||||||
>
|
|
||||||
<ReferenceIcon name="toolbar-close" size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div className="lovart-customizer-body">
|
|
||||||
<div
|
|
||||||
className="lovart-customizer-preview"
|
|
||||||
aria-label="工具栏预览"
|
|
||||||
>
|
|
||||||
<div className="lovart-customizer-preview-bar">
|
|
||||||
{addToChat && (
|
|
||||||
<span className="lovart-preview-fixed">
|
|
||||||
{toolIcon(addToChat, 18)}
|
|
||||||
{draft?.showNames && toolLabel(addToChat)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{draft?.pinnedKeys
|
|
||||||
.filter((key) => toolMap.has(key))
|
|
||||||
.map((key, index, keys) => {
|
|
||||||
const tool = toolMap.get(key)!;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="lovart-preview-chip"
|
|
||||||
key={key}
|
|
||||||
draggable
|
|
||||||
onDragStart={(event) => {
|
|
||||||
setDraggedKey(key);
|
|
||||||
event.dataTransfer.setData("text/plain", key);
|
|
||||||
event.dataTransfer.effectAllowed = "move";
|
|
||||||
}}
|
|
||||||
onDragEnd={() => setDraggedKey(null)}
|
|
||||||
onDragOver={(event) => event.preventDefault()}
|
|
||||||
onDrop={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (draggedKey) move(draggedKey, key);
|
|
||||||
setDraggedKey(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="lovart-preview-handle"
|
|
||||||
aria-label={`${toolLabel(tool)},按左右方向键调整顺序`}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
const offset =
|
|
||||||
event.key === "ArrowLeft"
|
|
||||||
? -1
|
|
||||||
: event.key === "ArrowRight"
|
|
||||||
? 1
|
|
||||||
: 0;
|
|
||||||
if (offset && keys[index + offset]) {
|
|
||||||
event.preventDefault();
|
|
||||||
move(key, keys[index + offset]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{toolIcon(tool, 18)}
|
|
||||||
{draft.showNames && <span>{toolLabel(tool)}</span>}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`移除${toolLabel(tool)}`}
|
|
||||||
onClick={() => toggle(key)}
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="lovart-customizer-tools">
|
|
||||||
{tools.map((tool) => {
|
|
||||||
const key = tool.props["data-tool-key"]!;
|
|
||||||
const isPinned = Boolean(draft?.pinnedKeys.includes(key));
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
type="button"
|
|
||||||
aria-label={`${isPinned ? "取消固定" : "固定"}${toolLabel(tool)}`}
|
|
||||||
aria-pressed={isPinned}
|
|
||||||
onClick={() => toggle(key)}
|
|
||||||
>
|
|
||||||
{toolIcon(tool, 18)}
|
|
||||||
<span>{toolLabel(tool)}</span>
|
|
||||||
<ReferenceIcon
|
|
||||||
name={
|
|
||||||
isPinned
|
|
||||||
? "toolbar-pin-filled"
|
|
||||||
: "toolbar-pin-outline"
|
|
||||||
}
|
|
||||||
size={13}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer className="lovart-customizer-footer">
|
|
||||||
<label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-label="显示工具名称"
|
|
||||||
aria-checked={Boolean(draft?.showNames)}
|
|
||||||
className="lovart-customizer-switch"
|
|
||||||
onClick={() =>
|
|
||||||
setDraft((current) =>
|
|
||||||
current
|
|
||||||
? { ...current, showNames: !current.showNames }
|
|
||||||
: current
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span />
|
|
||||||
</button>
|
|
||||||
显示工具名称
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<button type="button" onClick={() => setDraft(null)}>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="lovart-customizer-save"
|
|
||||||
onClick={() => {
|
|
||||||
if (draft && saveImageToolbarPreferences(draft))
|
|
||||||
setDraft(null);
|
|
||||||
else
|
|
||||||
toast.error("无法保存工具栏设置,请检查浏览器存储权限");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { CircleAlert, Check, ChevronDown, Loader2, RotateCcw, Square } from "lucide-react";
|
|
||||||
import type { AgentRunView } from "@/lib/ai/canvas-agent-runtime/protocol";
|
|
||||||
import { canvasAgentProgressLabel, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
|
|
||||||
|
|
||||||
export function CanvasAgentProgressMessage(props: {
|
|
||||||
progress: CanvasAgentProgress; run?: AgentRunView | null; busy?: boolean;
|
|
||||||
stopping?: boolean; sending?: boolean; onCancel?: () => void; onResume?: () => void;
|
|
||||||
}) {
|
|
||||||
const { progress, run } = props;
|
|
||||||
const running = progress.status === "running";
|
|
||||||
const label = progress.status === "completed" ? "处理完成"
|
|
||||||
: props.stopping && running ? "正在停止后续步骤,已完成的结果会保留"
|
|
||||||
: canvasAgentProgressLabel(progress.message || "正在处理你的任务");
|
|
||||||
return (
|
|
||||||
<div className="my-2 w-full min-w-0 text-xs text-zinc-500 dark:text-zinc-400" data-testid="canvas-agent-progress" data-agent-message-id={run?.assistantMessageId}>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<details className="min-w-0 flex-1" open={running}>
|
|
||||||
<summary className="flex cursor-pointer list-none items-center gap-2 py-1 [&::-webkit-details-marker]:hidden">
|
|
||||||
{running ? <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" /> : progress.status === "completed" ? <Check className="h-3.5 w-3.5 shrink-0" /> : <CircleAlert className="h-3.5 w-3.5 shrink-0" />}
|
|
||||||
<span className="min-w-0 flex-1" role="status" aria-live="polite">{label}</span>
|
|
||||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
|
||||||
</summary>
|
|
||||||
{progress.activities?.length ? (
|
|
||||||
<ol className="ml-1.5 mt-1 space-y-1.5 border-l border-zinc-200 pl-4 dark:border-zinc-800" aria-label="任务执行步骤">
|
|
||||||
{progress.activities.map(activity => (
|
|
||||||
<li key={activity.id} className="flex items-center gap-2">
|
|
||||||
{activity.status === "started" ? <Loader2 className="h-3 w-3 shrink-0 animate-spin" /> : <span className="h-1 w-1 shrink-0 rounded-full bg-current" />}
|
|
||||||
<span>{canvasAgentProgressLabel(activity.title)}</span>
|
|
||||||
<span className="text-[11px] opacity-75">{activity.status === "succeeded" ? "已完成" : activity.status === "failed" ? "未完成" : activity.status === "queued" ? "待执行" : "进行中"}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
) : null}
|
|
||||||
{run?.inputs.slice(-2).map(input => (
|
|
||||||
<p key={input.id} className="mt-2 whitespace-pre-wrap break-words">{input.applied ? "已应用补充要求" : "已收到补充要求"}:{input.text}</p>
|
|
||||||
))}
|
|
||||||
</details>
|
|
||||||
{running && props.onCancel ? (
|
|
||||||
<button type="button" onClick={props.onCancel} disabled={props.sending || props.stopping} className="rounded-md p-1.5 hover:bg-zinc-100 disabled:opacity-40 dark:hover:bg-white/10" title="停止后续步骤" aria-label="停止后续步骤"><Square className="h-3.5 w-3.5" /></button>
|
|
||||||
) : run?.resumable && props.onResume ? (
|
|
||||||
<button type="button" onClick={props.onResume} disabled={props.busy} className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 hover:bg-zinc-100 disabled:opacity-40 dark:hover:bg-white/10"><RotateCcw className="h-3.5 w-3.5" />继续任务</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
"use client";
|
|
||||||
// Compatibility wrapper for the paused video entry. Image chat observes the run
|
|
||||||
// once and renders its activity in the corresponding assistant message.
|
|
||||||
import { CanvasAgentProgressMessage } from "./canvas-agent-progress";
|
|
||||||
import { useCanvasAgentRun, type CanvasAgentRunOptions } from "./use-canvas-agent-run";
|
|
||||||
import { canvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
|
|
||||||
export function CanvasAgentRunPanel(props: CanvasAgentRunOptions & { onResume: (runId: string) => void }) {
|
|
||||||
const task = useCanvasAgentRun(props);
|
|
||||||
if (!task.run) return null;
|
|
||||||
return <CanvasAgentProgressMessage progress={canvasAgentProgress(task.run)} run={task.run} busy={props.busy}
|
|
||||||
stopping={task.stopPending} sending={task.sending} onCancel={() => void task.control("cancel")}
|
|
||||||
onResume={() => props.onResume(task.run!.id)} />;
|
|
||||||
}
|
|
||||||
@ -152,7 +152,16 @@ export function shouldRetryCanvasViewOperationAfterRefresh(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status";
|
export type ImageGenerationStatusStreamPayload = {
|
||||||
|
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
||||||
|
providerType?: string;
|
||||||
|
modelName?: string;
|
||||||
|
imageCount?: number;
|
||||||
|
queuePosition?: number;
|
||||||
|
sameModelRunningCount?: number;
|
||||||
|
concurrencyLimit?: number;
|
||||||
|
estimatedWaitMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AgentTargetLockRefPreview = {
|
export type AgentTargetLockRefPreview = {
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@ -1,78 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { getCanvasAgentAccessManifest } from "@/lib/ai/canvas-agent-runtime/capability-access";
|
|
||||||
import {
|
|
||||||
canvasAgentUiCommandSchema,
|
|
||||||
CANVAS_AGENT_UI_COMMAND_EVENT,
|
|
||||||
type CanvasAgentUiCommand,
|
|
||||||
} from "@/lib/ai/canvas-agent-runtime/ui-command";
|
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
|
||||||
|
|
||||||
export function CanvasAgentUiListener(props: {
|
|
||||||
projectId: string;
|
|
||||||
workspace: "canvas" | "video";
|
|
||||||
onVideoAction?: (command: CanvasAgentUiCommand) => void | Promise<void>;
|
|
||||||
}) {
|
|
||||||
const latest = useRef(props);
|
|
||||||
latest.current = props;
|
|
||||||
const seen = useRef(new Set<string>());
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = async (event: Event) => {
|
|
||||||
const parsed = canvasAgentUiCommandSchema.safeParse((event as CustomEvent).detail);
|
|
||||||
if (!parsed.success) return;
|
|
||||||
const command = parsed.data,
|
|
||||||
current = latest.current;
|
|
||||||
if (
|
|
||||||
command.projectId !== current.projectId ||
|
|
||||||
command.workspace !== current.workspace ||
|
|
||||||
seen.current.has(command.requestId)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
seen.current.add(command.requestId);
|
|
||||||
try {
|
|
||||||
if (current.workspace === "video") {
|
|
||||||
await current.onVideoAction?.(command);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const feature = getCanvasAgentAccessManifest().find(
|
|
||||||
(item) => item.id === command.featureId && item.workspace === "canvas",
|
|
||||||
);
|
|
||||||
if (!feature || !feature.id.startsWith("canvas.ui.")) throw new Error("这个界面入口不存在");
|
|
||||||
const key = feature.id.slice("canvas.ui.".length);
|
|
||||||
if (/chatSend|chatStartGeneration/.test(key)) {
|
|
||||||
toast.message("已定位对话提交入口,请由你确认并发送;Agent 不会伪造下一条用户消息");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const state = useCanvasStore.getState();
|
|
||||||
if (state.projectId !== current.projectId) return;
|
|
||||||
if (command.nodeIds.length)
|
|
||||||
state.selectNodes(
|
|
||||||
command.nodeIds.filter((id) => state.nodes.some((node) => node.id === id)),
|
|
||||||
);
|
|
||||||
await new Promise<void>((resolve) =>
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
||||||
);
|
|
||||||
const element = Array.from(
|
|
||||||
document.querySelectorAll<HTMLElement>("[data-canvas-feature-key]"),
|
|
||||||
).find((item) => item.dataset.canvasFeatureKey === key && item.getClientRects().length);
|
|
||||||
const button = element?.closest<HTMLElement>("button,[role=button],a");
|
|
||||||
if (!button || button.hasAttribute("disabled"))
|
|
||||||
throw new Error(
|
|
||||||
"此入口当前未显示或不可用;请切换相应工作区。工具授权不会恢复被隐藏的按钮。",
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
/Delete|Generate|Confirm|Cancel|SaveText|realtimeSend/.test(key) &&
|
|
||||||
!window.confirm(`Agent 请求操作「${feature.title}」。确认在当前画布执行?`)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
button.click();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "无法打开画布入口");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener(CANVAS_AGENT_UI_COMMAND_EVENT, handler);
|
|
||||||
return () => window.removeEventListener(CANVAS_AGENT_UI_COMMAND_EVENT, handler);
|
|
||||||
}, []);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@ -448,7 +448,7 @@ function extractChatMediaUrls(text: string, kind: ChatMediaKind): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdownLinkPattern = /(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
|
const markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
|
||||||
while ((match = markdownLinkPattern.exec(text)) !== null) {
|
while ((match = markdownLinkPattern.exec(text)) !== null) {
|
||||||
const label = String(match[1] || "");
|
const label = String(match[1] || "");
|
||||||
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label)
|
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label)
|
||||||
@ -491,7 +491,7 @@ export function stripMediaMarkup(text: string): string {
|
|||||||
.replace(/<audio[\s\S]*?<\/audio>/gi, "")
|
.replace(/<audio[\s\S]*?<\/audio>/gi, "")
|
||||||
.replace(/<audio\b[^>]*\/?\s*>/gi, "")
|
.replace(/<audio\b[^>]*\/?\s*>/gi, "")
|
||||||
.replace(/<a[\s\S]*?<\/a>/gi, "")
|
.replace(/<a[\s\S]*?<\/a>/gi, "")
|
||||||
.replace(/(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
|
.replace(/\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
|
||||||
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
|
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
|
||||||
String(label || "")
|
String(label || "")
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,70 +0,0 @@
|
|||||||
import { normalizeImageIdentityUrl } from "./canvas-chat-media";
|
|
||||||
import type { ArchivedImageUrlMap } from "@/lib/storage/image-archive-urls";
|
|
||||||
|
|
||||||
type ImageResultNode = {
|
|
||||||
type: string;
|
|
||||||
data?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
function imageUrls(node: ImageResultNode): string[] {
|
|
||||||
if (node.type !== "image") return [];
|
|
||||||
const data = node.data as { src?: string; metadata?: { sourceImageKey?: string } } | undefined;
|
|
||||||
return [data?.src, data?.metadata?.sourceImageKey].filter(
|
|
||||||
(url): url is string => typeof url === "string" && Boolean(url.trim()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCanvasImageResultUrlKeys(
|
|
||||||
nodes: readonly ImageResultNode[],
|
|
||||||
aliases: ArchivedImageUrlMap = {},
|
|
||||||
) {
|
|
||||||
const keys = new Set<string>();
|
|
||||||
for (const node of nodes) {
|
|
||||||
for (const url of imageUrls(node)) {
|
|
||||||
keys.add(normalizeImageIdentityUrl(url));
|
|
||||||
if (aliases[url]) keys.add(normalizeImageIdentityUrl(aliases[url]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keys.delete("");
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A history refresh may already contain bucket URLs while this tab still shows
|
|
||||||
// provider previews. Resolve aliases only for an existing result of this message;
|
|
||||||
// a new generation never waits for storage before appearing on the canvas.
|
|
||||||
export async function resolveCanvasImageResultAliases(params: {
|
|
||||||
nodes: readonly ImageResultNode[];
|
|
||||||
messageId?: string;
|
|
||||||
imageUrls: string[];
|
|
||||||
resolveAliases: (urls: string[]) => Promise<ArchivedImageUrlMap>;
|
|
||||||
}): Promise<ArchivedImageUrlMap> {
|
|
||||||
const keys = getCanvasImageResultUrlKeys(params.nodes);
|
|
||||||
if (!params.messageId || params.imageUrls.every((url) => keys.has(normalizeImageIdentityUrl(url)))) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const urls = [...new Set(params.nodes.flatMap((node) => {
|
|
||||||
const data = node.data as { metadata?: { sourceMessageId?: string } } | undefined;
|
|
||||||
return data?.metadata?.sourceMessageId === params.messageId ? imageUrls(node) : [];
|
|
||||||
}))].filter((url) => /^https?:\/\//i.test(url));
|
|
||||||
if (!urls.length) return {};
|
|
||||||
const aliases: ArchivedImageUrlMap = {};
|
|
||||||
for (let index = 0; index < urls.length; index += 100) {
|
|
||||||
Object.assign(aliases, await params.resolveAliases(urls.slice(index, index + 100)));
|
|
||||||
}
|
|
||||||
return aliases;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call synchronously immediately before changing the store, after all image
|
|
||||||
// loading/network awaits. Both stream and history writers share this check.
|
|
||||||
export function commitCanvasImageResultIfMissing<T>(params: {
|
|
||||||
getNodes: () => readonly ImageResultNode[];
|
|
||||||
url: string;
|
|
||||||
aliases: ArchivedImageUrlMap;
|
|
||||||
commit: () => T;
|
|
||||||
}): T | undefined {
|
|
||||||
const key = normalizeImageIdentityUrl(params.url);
|
|
||||||
if (!key || getCanvasImageResultUrlKeys(params.getNodes(), params.aliases).has(key)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return params.commit();
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import type { AgentRunView } from "@/lib/ai/canvas-agent-runtime/protocol";
|
|
||||||
export type CanvasAgentRunOptions = {
|
|
||||||
enabled: boolean; chatId: string; projectId: string; busy: boolean;
|
|
||||||
onResults: (projectId: string, nodeIds: string[]) => void;
|
|
||||||
onRunChange?: (run: AgentRunView | null) => void;
|
|
||||||
};
|
|
||||||
export function useCanvasAgentRun(props: CanvasAgentRunOptions) {
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [stopping, setStopping] = useState(false);
|
|
||||||
const sendingRef = useRef(false);
|
|
||||||
const scope = `${props.chatId}:${props.projectId}`;
|
|
||||||
const latest = useRef({ props, scope }); latest.current = { props, scope };
|
|
||||||
const resultSignature = useRef("");
|
|
||||||
const key = props.enabled ? `/api/canvas/agent-runs?${new URLSearchParams({ chatId: props.chatId, projectId: props.projectId })}` : null;
|
|
||||||
const { data, mutate } = useSWR<{ run: AgentRunView | null }>(key, async (url: string) => {
|
|
||||||
const response = await fetch(url, { cache: "no-store" });
|
|
||||||
if (!response.ok) return { run: null };
|
|
||||||
return response.json();
|
|
||||||
}, { refreshInterval: latest => latest?.run?.status === "running" || props.busy ? 2000 : 0,
|
|
||||||
revalidateOnFocus: true, shouldRetryOnError: false });
|
|
||||||
const run = props.enabled && data?.run?.chatId === props.chatId && data.run.projectId === props.projectId ? data.run : null;
|
|
||||||
useEffect(() => { latest.current.props.onRunChange?.(run); }, [run]);
|
|
||||||
useEffect(() => { setSending(false); sendingRef.current = false; setStopping(false); resultSignature.current = ""; }, [scope]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!props.enabled) return;
|
|
||||||
const handler = (event: Event) => {
|
|
||||||
const update = (event as CustomEvent<AgentRunView>).detail;
|
|
||||||
if (update?.chatId === props.chatId && update?.projectId === props.projectId) void mutate({ run: update }, false);
|
|
||||||
};
|
|
||||||
window.addEventListener("canvas-agent-run:update", handler);
|
|
||||||
return () => window.removeEventListener("canvas-agent-run:update", handler);
|
|
||||||
}, [props.enabled, props.chatId, props.projectId, mutate]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!run?.resultNodeIds.length) return;
|
|
||||||
const signature = `${run.id}:${run.resultNodeIds.join(",")}:${run.status}`;
|
|
||||||
if (signature === resultSignature.current) return;
|
|
||||||
resultSignature.current = signature;
|
|
||||||
latest.current.props.onResults(run.projectId, run.resultNodeIds);
|
|
||||||
}, [run]);
|
|
||||||
const control = useCallback(async (action: "steer" | "cancel", text?: string) => {
|
|
||||||
if (!run || sendingRef.current) return false;
|
|
||||||
sendingRef.current = true;
|
|
||||||
const startedScope = scope;
|
|
||||||
setSending(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/canvas/agent-runs", { method: "POST", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ action, chatId: props.chatId, projectId: props.projectId, runId: run.id,
|
|
||||||
...(action === "steer" ? { inputId: crypto.randomUUID(), text: text?.trim() } : {}) }) });
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok) throw new Error(result.error || "操作失败,请重试");
|
|
||||||
if (startedScope !== latest.current.scope) return false;
|
|
||||||
await mutate(result, false);
|
|
||||||
if (action === "cancel") setStopping(true);
|
|
||||||
toast.message(action === "steer" ? "补充要求已收到,将在下一步应用" : "正在停止后续步骤,已完成的结果会保留");
|
|
||||||
return true;
|
|
||||||
} catch (error) { toast.error(error instanceof Error ? error.message : "操作失败"); return false; }
|
|
||||||
finally { if (startedScope === latest.current.scope) { sendingRef.current = false; setSending(false); } }
|
|
||||||
}, [run, sending, scope, props.chatId, props.projectId, mutate]);
|
|
||||||
return { run, sending, stopPending: stopping || Boolean(run?.cancelRequested), control };
|
|
||||||
}
|
|
||||||
@ -1,343 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
|
||||||
import { DefaultChatTransport, type UIMessage } from "ai";
|
|
||||||
import { ArrowUp, MessageSquare, X } from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { CanvasAgentRunPanel } from "./canvas-agent-run-panel";
|
|
||||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
|
||||||
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
|
|
||||||
import {
|
|
||||||
videoAgentSettingsSchema,
|
|
||||||
type VideoAgentSettings,
|
|
||||||
} from "@/lib/ai/canvas-agent-runtime/video-protocol";
|
|
||||||
|
|
||||||
type ModelOption = { id: string; displayName: string };
|
|
||||||
export function VideoCanvasAgentPanel(props: {
|
|
||||||
projectId: string;
|
|
||||||
selectedNodeIds: string[];
|
|
||||||
videoModels: ModelOption[];
|
|
||||||
imageModels: ModelOption[];
|
|
||||||
beforeSend: () => Promise<void>;
|
|
||||||
onResults: (projectId: string, nodeIds: string[]) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [settings, setSettings] = useState<VideoAgentSettings>(() =>
|
|
||||||
videoAgentSettingsSchema.parse({}),
|
|
||||||
);
|
|
||||||
const [preparing, setPreparing] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [backgroundRunning, setBackgroundRunning] = useState(false);
|
|
||||||
const latest = useRef({ props, settings });
|
|
||||||
latest.current = { props, settings };
|
|
||||||
const resumeRef = useRef<string | undefined>(undefined);
|
|
||||||
const mounted = useRef(true);
|
|
||||||
const resultIds = useRef<string[]>([]);
|
|
||||||
const restoredRunId = useRef("");
|
|
||||||
const { messages, setMessages, sendMessage, status } = useChat({
|
|
||||||
id: `video-agent-${props.projectId}`,
|
|
||||||
generateId: () => crypto.randomUUID(),
|
|
||||||
transport: new DefaultChatTransport({
|
|
||||||
api: "/api/canvas/agent-chat",
|
|
||||||
prepareSendMessagesRequest: ({ messages }) => {
|
|
||||||
const current = latest.current;
|
|
||||||
const resumeRunId = resumeRef.current;
|
|
||||||
resumeRef.current = undefined;
|
|
||||||
return {
|
|
||||||
body: {
|
|
||||||
id: current.props.projectId,
|
|
||||||
message: messages[messages.length - 1],
|
|
||||||
selectedChatModel: "agent",
|
|
||||||
selectedVisibilityType: "private",
|
|
||||||
composerMode: "agent",
|
|
||||||
workspaceVariant: "video",
|
|
||||||
videoAgentSettings: current.settings,
|
|
||||||
resumeRunId,
|
|
||||||
contextPayload: {
|
|
||||||
source: "canvas",
|
|
||||||
projectId: current.props.projectId,
|
|
||||||
selectedNodeIds: current.props.selectedNodeIds,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
onData: (part) => {
|
|
||||||
if (part.type === "data-videoCanvasMutation") window.dispatchEvent(new CustomEvent("video-canvas-agent-mutation", { detail: part.data }));
|
|
||||||
if (part.type === "data-canvasAgentUiCommand") window.dispatchEvent(new CustomEvent("canvas-agent-ui-command", { detail: part.data }));
|
|
||||||
if (part.type === "data-canvasAgentRun")
|
|
||||||
window.dispatchEvent(new CustomEvent("canvas-agent-run:update", { detail: part.data }));
|
|
||||||
},
|
|
||||||
onError: (error) => toast.error(error.message || "视频任务启动失败,请重试"),
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
mounted.current = true;
|
|
||||||
const controller = new AbortController();
|
|
||||||
void fetch(`/api/canvas/video-agent?projectId=${props.projectId}`, {
|
|
||||||
cache: "no-store",
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
.then(async (response) => {
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok) throw new Error(data.error || "读取视频对话失败");
|
|
||||||
if (!controller.signal.aborted) setMessages(data.messages as UIMessage[]);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (!controller.signal.aborted) toast.error(error.message);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!controller.signal.aborted) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
mounted.current = false;
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, [props.projectId, setMessages]);
|
|
||||||
const busy = backgroundRunning || preparing || status === "submitted" || status === "streaming";
|
|
||||||
useEffect(() => {
|
|
||||||
const draft = readCanvasComposerDraft(`video-agent-${props.projectId}`);
|
|
||||||
if (draft) setInput(draft.text);
|
|
||||||
}, [props.projectId]);
|
|
||||||
useEffect(() => {
|
|
||||||
const beforeRefresh = (event: Event) => {
|
|
||||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
|
||||||
if (busy) request.block("当前视频任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。");
|
|
||||||
request.save(async () => {
|
|
||||||
if (input.trim() && !writeCanvasComposerDraft(`video-agent-${props.projectId}`, { text: input, attachments: [] }))
|
|
||||||
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
}, [busy, input, props.projectId]);
|
|
||||||
const submit = useCallback(
|
|
||||||
async (resumeRunId?: string) => {
|
|
||||||
if (busy || loading) return;
|
|
||||||
const text =
|
|
||||||
input.trim() ||
|
|
||||||
(resumeRunId ? "请继续尚未完成的视频创作步骤,保留已完成的脚本、关键帧和视频。" : "");
|
|
||||||
if (!text) return;
|
|
||||||
setPreparing(true);
|
|
||||||
try {
|
|
||||||
await latest.current.props.beforeSend();
|
|
||||||
if (!mounted.current) return;
|
|
||||||
clearCanvasComposerDraft(`video-agent-${props.projectId}`);
|
|
||||||
resumeRef.current = resumeRunId;
|
|
||||||
setInput("");
|
|
||||||
await sendMessage({ text });
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "视频任务启动失败");
|
|
||||||
} finally {
|
|
||||||
resumeRef.current = undefined;
|
|
||||||
if (mounted.current) setPreparing(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[busy, loading, input, sendMessage],
|
|
||||||
);
|
|
||||||
const onRunChange = useCallback(
|
|
||||||
(run: import("@/lib/ai/canvas-agent-runtime/protocol").AgentRunView | null) => {
|
|
||||||
resultIds.current = run?.resultNodeIds || [];
|
|
||||||
if (run?.videoSettings && restoredRunId.current !== run.id) {
|
|
||||||
restoredRunId.current = run.id;
|
|
||||||
setSettings(run.videoSettings);
|
|
||||||
}
|
|
||||||
setBackgroundRunning(run?.status === "running");
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const onResults = useCallback(
|
|
||||||
(projectId: string, ids: string[]) => latest.current.props.onResults(projectId, ids),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
if (resultIds.current.length)
|
|
||||||
latest.current.props.onResults(latest.current.props.projectId, resultIds.current);
|
|
||||||
}, 10_000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
className="dark absolute bottom-3 right-3 top-[72px] z-40 flex w-[390px] max-w-[calc(100vw-24px)] flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#202020] text-zinc-100 shadow-2xl"
|
|
||||||
data-canvas-overlay="true"
|
|
||||||
data-testid="video-agent-panel"
|
|
||||||
aria-label="视频创作 Agent"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-3">
|
|
||||||
<MessageSquare className="h-4 w-4" />
|
|
||||||
<span className="flex-1 font-medium">视频创作 Agent</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={props.onClose}
|
|
||||||
aria-label="收起视频 Agent"
|
|
||||||
className="rounded-lg p-1 hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-4" aria-live="polite">
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-zinc-400">正在读取对话…</p>
|
|
||||||
) : messages.length === 0 ? (
|
|
||||||
<p className="text-sm leading-7 text-zinc-400">
|
|
||||||
选择画布素材,告诉我你想制作的视频。我可以分析参考、编写分镜、生成关键帧并继续生成视频。
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{messages.map((message) => (
|
|
||||||
<div
|
|
||||||
key={message.id}
|
|
||||||
className={
|
|
||||||
message.role === "user"
|
|
||||||
? "ml-5 whitespace-pre-wrap rounded-2xl bg-white/10 px-3 py-2 text-sm leading-6"
|
|
||||||
: "whitespace-pre-wrap text-sm leading-6 text-zinc-300"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{message.parts
|
|
||||||
.filter((part) => part.type === "text")
|
|
||||||
.map((part, i) => (
|
|
||||||
<span key={`${message.id}-${i}`}>{part.type === "text" ? part.text : ""}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-white/10 p-3">
|
|
||||||
<CanvasAgentRunPanel
|
|
||||||
enabled={!loading}
|
|
||||||
chatId={props.projectId}
|
|
||||||
projectId={props.projectId}
|
|
||||||
busy={busy}
|
|
||||||
onResume={(id) => void submit(id)}
|
|
||||||
onResults={onResults}
|
|
||||||
onRunChange={onRunChange}
|
|
||||||
/>
|
|
||||||
<div className="mb-2 grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<select
|
|
||||||
aria-label="视频模型"
|
|
||||||
disabled={busy}
|
|
||||||
value={settings.modelRef || ""}
|
|
||||||
onChange={(e) => setSettings({ ...settings, modelRef: e.target.value || undefined })}
|
|
||||||
className="min-w-0 rounded-lg border border-white/10 bg-zinc-800 p-2"
|
|
||||||
>
|
|
||||||
<option value="">默认视频模型</option>
|
|
||||||
{props.videoModels.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.displayName}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="关键帧模型"
|
|
||||||
disabled={busy}
|
|
||||||
value={settings.imageModelRef || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings({ ...settings, imageModelRef: e.target.value || undefined })
|
|
||||||
}
|
|
||||||
className="min-w-0 rounded-lg border border-white/10 bg-zinc-800 p-2"
|
|
||||||
>
|
|
||||||
<option value="">默认关键帧模型</option>
|
|
||||||
{props.imageModels.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.displayName}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="视频模式"
|
|
||||||
disabled={busy}
|
|
||||||
value={settings.mode}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings({ ...settings, mode: e.target.value as VideoAgentSettings["mode"] })
|
|
||||||
}
|
|
||||||
className="rounded-lg border border-white/10 bg-zinc-800 p-2"
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
["auto", "按参考图匹配模式"],
|
|
||||||
["text-to-video", "文生视频"],
|
|
||||||
["first-frame", "首帧视频"],
|
|
||||||
["first-last-frame", "首尾帧视频"],
|
|
||||||
["reference-images", "参考图视频"],
|
|
||||||
].map(([value, label]) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div className="flex items-center gap-1 rounded-lg border border-white/10 bg-zinc-800 px-2">
|
|
||||||
<input
|
|
||||||
aria-label="视频时长(秒)"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={120}
|
|
||||||
disabled={busy}
|
|
||||||
value={settings.duration}
|
|
||||||
onChange={(e) => setSettings({ ...settings, duration: Number(e.target.value) })}
|
|
||||||
className="w-12 min-w-0 bg-transparent py-2"
|
|
||||||
/>
|
|
||||||
<span>秒</span>
|
|
||||||
<select
|
|
||||||
aria-label="视频比例"
|
|
||||||
disabled={busy}
|
|
||||||
value={settings.ratio}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings({ ...settings, ratio: e.target.value as VideoAgentSettings["ratio"] })
|
|
||||||
}
|
|
||||||
className="min-w-0 flex-1 bg-zinc-800"
|
|
||||||
>
|
|
||||||
{["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"].map((v) => (
|
|
||||||
<option key={v}>{v}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
aria-label="视频分辨率"
|
|
||||||
disabled={busy}
|
|
||||||
value={settings.resolution}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings({
|
|
||||||
...settings,
|
|
||||||
resolution: e.target.value as VideoAgentSettings["resolution"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-lg border border-white/10 bg-zinc-800 p-2"
|
|
||||||
>
|
|
||||||
{["480p", "720p", "1080p"].map((v) => (
|
|
||||||
<option key={v}>{v}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void submit();
|
|
||||||
}}
|
|
||||||
className="rounded-2xl border border-white/10 bg-[#151515] p-3"
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
aria-label="视频创作要求"
|
|
||||||
value={input}
|
|
||||||
onChange={(event) => { clearCanvasComposerDraft(`video-agent-${props.projectId}`); setInput(event.target.value); }}
|
|
||||||
maxLength={2000}
|
|
||||||
rows={3}
|
|
||||||
placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频"
|
|
||||||
className="w-full resize-none bg-transparent text-sm outline-none placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
<div className="mt-2 flex items-center justify-between">
|
|
||||||
<span className="text-xs text-zinc-500">
|
|
||||||
已选择 {props.selectedNodeIds.length} 个素材
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
aria-label="开始视频任务"
|
|
||||||
disabled={busy || loading || !input.trim()}
|
|
||||||
className="rounded-full bg-zinc-100 p-2 text-zinc-900 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
<ArrowUp className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,7 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { CanvasPreviewImage } from "./image-node/canvas-preview-image";
|
|
||||||
import { canvasImagePreviewWidth } from "@/lib/canvas/image-preview-width";
|
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@ -45,7 +42,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -59,10 +56,8 @@ import { CanvasLayoutReplaceBatchDialog } from "@/components/canvas/CanvasLayout
|
|||||||
import { TextEditColorField } from "@/components/canvas/TextEditColorField";
|
import { TextEditColorField } from "@/components/canvas/TextEditColorField";
|
||||||
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
||||||
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||||||
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
|
|
||||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
||||||
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
|
|
||||||
import {
|
import {
|
||||||
areImageAdjustmentsEqual,
|
areImageAdjustmentsEqual,
|
||||||
buildImageAdjustmentFilterStyle,
|
buildImageAdjustmentFilterStyle,
|
||||||
@ -189,7 +184,7 @@ import {
|
|||||||
normalizeDetailUiSubjectAnalysis,
|
normalizeDetailUiSubjectAnalysis,
|
||||||
} from "@/lib/detail-ui-subject-analysis";
|
} from "@/lib/detail-ui-subject-analysis";
|
||||||
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
||||||
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
||||||
getDefaultThreeDModelAliasId,
|
getDefaultThreeDModelAliasId,
|
||||||
@ -235,8 +230,6 @@ import {
|
|||||||
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||||
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability";
|
|
||||||
import { useCanvasRemoteOperation } from "@/components/canvas/CanvasRemoteOperationContext";
|
|
||||||
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
||||||
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
@ -1314,8 +1307,6 @@ export function ImageNode({
|
|||||||
}: ImageNodeProps) {
|
}: ImageNodeProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const routeParams = useParams();
|
const routeParams = useParams();
|
||||||
const pathname = usePathname();
|
|
||||||
const useReferenceImageToolbar = /^\/canvas\/[^/]+\/?$/.test(pathname);
|
|
||||||
const canvasId = Array.isArray(routeParams?.id)
|
const canvasId = Array.isArray(routeParams?.id)
|
||||||
? routeParams.id[0] || ""
|
? routeParams.id[0] || ""
|
||||||
: typeof routeParams?.id === "string"
|
: typeof routeParams?.id === "string"
|
||||||
@ -1541,11 +1532,9 @@ export function ImageNode({
|
|||||||
.map((action) => action.key)
|
.map((action) => action.key)
|
||||||
);
|
);
|
||||||
const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) =>
|
const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) =>
|
||||||
useReferenceImageToolbar
|
selectedImageInlineActionKeys.has(key);
|
||||||
? enabledSelectedImageToolbarActions.some((action) => action.key === key)
|
|
||||||
: selectedImageInlineActionKeys.has(key);
|
|
||||||
const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) =>
|
const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) =>
|
||||||
!useReferenceImageToolbar && selectedImageMoreActionKeys.has(key);
|
selectedImageMoreActionKeys.has(key);
|
||||||
const hasHoverImageMorePrimaryActions =
|
const hasHoverImageMorePrimaryActions =
|
||||||
imageRemoveBackgroundEnabled ||
|
imageRemoveBackgroundEnabled ||
|
||||||
imageRemoveWatermarkEnabled ||
|
imageRemoveWatermarkEnabled ||
|
||||||
@ -1571,7 +1560,6 @@ export function ImageNode({
|
|||||||
const hasSelectedImageMorePrimaryActions =
|
const hasSelectedImageMorePrimaryActions =
|
||||||
selectedImageMoreActionKeys.size > 0;
|
selectedImageMoreActionKeys.size > 0;
|
||||||
const hasSelectedImageMoreMenu =
|
const hasSelectedImageMoreMenu =
|
||||||
useReferenceImageToolbar ||
|
|
||||||
hasSelectedImageMorePrimaryActions ||
|
hasSelectedImageMorePrimaryActions ||
|
||||||
(imageNodeMoreEnabled && hasCanvasAgentAnnotations) ||
|
(imageNodeMoreEnabled && hasCanvasAgentAnnotations) ||
|
||||||
(imageNodeMoreEnabled && imageDeleteEnabled);
|
(imageNodeMoreEnabled && imageDeleteEnabled);
|
||||||
@ -1714,7 +1702,6 @@ export function ImageNode({
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
||||||
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
||||||
const [fullscreenPreviewImage, setFullscreenPreviewImage] = useState<LoadedImagePreview | null>(null);
|
|
||||||
const [downloadProgress, setDownloadProgress] =
|
const [downloadProgress, setDownloadProgress] =
|
||||||
useState<CanvasImageDownloadProgress | null>(null);
|
useState<CanvasImageDownloadProgress | null>(null);
|
||||||
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
||||||
@ -2084,7 +2071,7 @@ export function ImageNode({
|
|||||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||||
const projectId = useCanvasStore((state) => state.projectId);
|
const projectId = useCanvasStore((state) => state.projectId);
|
||||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||||
const runCanvasRemoteOperation = useCanvasRemoteOperation();
|
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
|
||||||
const addPendingChatAttachment = useCanvasStore(
|
const addPendingChatAttachment = useCanvasStore(
|
||||||
(state) => state.addPendingChatAttachment
|
(state) => state.addPendingChatAttachment
|
||||||
);
|
);
|
||||||
@ -2504,13 +2491,13 @@ export function ImageNode({
|
|||||||
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
|
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
|
||||||
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
|
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
|
||||||
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
|
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
|
||||||
return canvasImagePreviewWidth(Math.max(
|
return Math.max(
|
||||||
minWidth,
|
minWidth,
|
||||||
Math.min(
|
Math.min(
|
||||||
maxWidth,
|
maxWidth,
|
||||||
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
|
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
|
||||||
)
|
)
|
||||||
), maxWidth);
|
);
|
||||||
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
|
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
|
||||||
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
|
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
|
||||||
const displayImageSrc = useMemo(
|
const displayImageSrc = useMemo(
|
||||||
@ -4685,44 +4672,106 @@ export function ImageNode({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!projectId || !runCanvasRemoteOperation) {
|
setIsCreatingDetachedResultNode(true);
|
||||||
toast.error("画布还在加载,请稍后再试");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCreatingDetachedResultNode(false);
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
const actionName = "框选抠图";
|
const actionName = "框选抠图";
|
||||||
|
const newNodeId = addNode({
|
||||||
try {
|
type: "image",
|
||||||
const command = createCanvasCapabilityClientCommand({
|
position: {
|
||||||
capabilityId: "image.box_cutout",
|
x: node.position.x + node.size.width + 20,
|
||||||
nodeIds: [node.id],
|
y: node.position.y,
|
||||||
projectId,
|
},
|
||||||
source: "toolbar",
|
size: { width: node.size.width, height: node.size.height },
|
||||||
args: {
|
data: {
|
||||||
...boxCutoutSelection,
|
src: "",
|
||||||
|
status: "generating",
|
||||||
|
prompt: `${actionName}处理中...`,
|
||||||
|
title: `${displayTitle}-${actionName}`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
metadata: {
|
||||||
|
cropRect: boxCutoutSelection,
|
||||||
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
connections: [node.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await imageGenerationService.boxCutout({
|
||||||
|
imageUrl: data.src,
|
||||||
|
cropRect: boxCutoutSelection,
|
||||||
|
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||||
|
sourceNodeId: node.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data?.url) {
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: result.data.url,
|
||||||
|
status: "completed",
|
||||||
|
prompt: `${actionName}结果`,
|
||||||
|
title: `${displayTitle}-${actionName}`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
metadata: {
|
||||||
|
cropRect: boxCutoutSelection,
|
||||||
|
cropRectApplied: result.data.cropRectApplied,
|
||||||
|
paddedCropRectApplied: result.data.paddedCropRectApplied,
|
||||||
|
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||||
|
editMode: "box-cutout",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
|
|
||||||
toast.success(`${actionName}完成`);
|
toast.success(`${actionName}完成`);
|
||||||
|
} else {
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: "",
|
||||||
|
status: "failed",
|
||||||
|
prompt: `${actionName}失败`,
|
||||||
|
title: `${displayTitle}-${actionName}-失败`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
metadata: {
|
||||||
|
cropRect: boxCutoutSelection,
|
||||||
|
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.error(result.error || `${actionName}失败`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("框选抠图失败:", error);
|
console.error("框选抠图失败:", error);
|
||||||
toast.error(error instanceof Error ? error.message : `${actionName}失败`);
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: "",
|
||||||
|
status: "failed",
|
||||||
|
prompt: `${actionName}失败`,
|
||||||
|
title: `${displayTitle}-${actionName}-失败`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
metadata: {
|
||||||
|
cropRect: boxCutoutSelection,
|
||||||
|
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.error(`${actionName}失败,请重试`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingDetachedResultNode(false);
|
setIsCreatingDetachedResultNode(false);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
resetBoxCutoutState();
|
resetBoxCutoutState();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
addNode,
|
||||||
boxCutoutSelection,
|
boxCutoutSelection,
|
||||||
data.src,
|
data.src,
|
||||||
|
displayTitle,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
node.id,
|
node.id,
|
||||||
projectId,
|
node.position.x,
|
||||||
|
node.position.y,
|
||||||
|
node.size.height,
|
||||||
|
node.size.width,
|
||||||
resetBoxCutoutState,
|
resetBoxCutoutState,
|
||||||
runCanvasRemoteOperation,
|
updateNode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const animateAddToChat = useCallback(async (previewUrl?: string) => {
|
const animateAddToChat = useCallback(async (previewUrl?: string) => {
|
||||||
@ -6652,7 +6701,7 @@ export function ImageNode({
|
|||||||
const executeSharedToolbarCapability = async (
|
const executeSharedToolbarCapability = async (
|
||||||
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
|
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
|
||||||
) => {
|
) => {
|
||||||
if (!projectId || !runCanvasRemoteOperation) {
|
if (!projectId) {
|
||||||
toast.error("画布还在加载,请稍后再试");
|
toast.error("画布还在加载,请稍后再试");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -6671,7 +6720,21 @@ export function ImageNode({
|
|||||||
projectId,
|
projectId,
|
||||||
source: "toolbar",
|
source: "toolbar",
|
||||||
});
|
});
|
||||||
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
|
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(command),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || payload?.success !== true) {
|
||||||
|
throw new Error(payload?.error || `${capability.title}失败`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectResult = await canvasService.getProject(projectId);
|
||||||
|
if (!projectResult.success || !projectResult.data?.canvasData) {
|
||||||
|
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
|
||||||
|
}
|
||||||
|
loadCanvas(projectResult.data.canvasData);
|
||||||
toast.success(`${capability.title}完成`);
|
toast.success(`${capability.title}完成`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
@ -8266,7 +8329,7 @@ export function ImageNode({
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onProgress: setDownloadProgress,
|
onProgress: setDownloadProgress,
|
||||||
});
|
});
|
||||||
toast.success("原图已开始下载");
|
toast.success("原图下载成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
toast.message("已取消下载");
|
toast.message("已取消下载");
|
||||||
@ -8370,11 +8433,7 @@ export function ImageNode({
|
|||||||
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
||||||
{data.src ? (
|
{data.src ? (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
{isRetiredStorageUrl(data.src) ? (
|
{!shouldRequestDisplayImage ? (
|
||||||
<div role="status" className="flex h-full w-full items-center justify-center bg-muted p-4 text-center text-sm text-muted-foreground">
|
|
||||||
{RETIRED_STORAGE_MESSAGE}
|
|
||||||
</div>
|
|
||||||
) : !shouldRequestDisplayImage ? (
|
|
||||||
<ImageNodeDeferredLoadState />
|
<ImageNodeDeferredLoadState />
|
||||||
) : isCompareMode && hasCompareSource ? (
|
) : isCompareMode && hasCompareSource ? (
|
||||||
<div
|
<div
|
||||||
@ -8401,16 +8460,15 @@ export function ImageNode({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
|
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
|
||||||
<CanvasPreviewImage
|
<img
|
||||||
sourceKey={data.src}
|
|
||||||
alt={displayTitle}
|
alt={displayTitle}
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
loading="eager"
|
loading="lazy"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
if (event.currentTarget.src !== data.src) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
event.currentTarget.src = data.src;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoad={handleDisplayImageLoad}
|
onLoad={handleDisplayImageLoad}
|
||||||
@ -8431,16 +8489,15 @@ export function ImageNode({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
|
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
|
||||||
<CanvasPreviewImage
|
<img
|
||||||
sourceKey={compareSource}
|
|
||||||
alt={`${displayTitle}-原图`}
|
alt={`${displayTitle}-原图`}
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
loading="eager"
|
loading="lazy"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
|
if (event.currentTarget.src !== compareSource) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(compareSource);
|
event.currentTarget.src = compareSource;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
src={displayCompareSrc}
|
src={displayCompareSrc}
|
||||||
@ -8482,8 +8539,7 @@ export function ImageNode({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CanvasPreviewImage
|
<img
|
||||||
sourceKey={data.src}
|
|
||||||
alt={displayTitle}
|
alt={displayTitle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full w-full bg-slate-50 object-contain dark:bg-zinc-900",
|
"h-full w-full bg-slate-50 object-contain dark:bg-zinc-900",
|
||||||
@ -8495,7 +8551,7 @@ export function ImageNode({
|
|||||||
)}
|
)}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
loading="eager"
|
loading="lazy"
|
||||||
onClick={
|
onClick={
|
||||||
isThisNodePicking && !boxCutoutMode
|
isThisNodePicking && !boxCutoutMode
|
||||||
? handlePickerClick
|
? handlePickerClick
|
||||||
@ -8504,17 +8560,12 @@ export function ImageNode({
|
|||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
|
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setFullscreenPreviewImage({
|
|
||||||
src: e.currentTarget.currentSrc || e.currentTarget.src,
|
|
||||||
width: e.currentTarget.naturalWidth,
|
|
||||||
height: e.currentTarget.naturalHeight,
|
|
||||||
});
|
|
||||||
setFullscreenPreviewOpen(true);
|
setFullscreenPreviewOpen(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
if (event.currentTarget.src !== data.src) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
event.currentTarget.src = data.src;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoad={handleDisplayImageLoad}
|
onLoad={handleDisplayImageLoad}
|
||||||
@ -9425,8 +9476,6 @@ export function ImageNode({
|
|||||||
) : (
|
) : (
|
||||||
<ImageNodeEmptyState
|
<ImageNodeEmptyState
|
||||||
detailProgressLabel={detailProgressLabel}
|
detailProgressLabel={detailProgressLabel}
|
||||||
failure={data.metadata?.generationFailure}
|
|
||||||
requestId={data.metadata?.generationRequestId}
|
|
||||||
fileInputRef={fileInputRef}
|
fileInputRef={fileInputRef}
|
||||||
generationProgress={generationProgress}
|
generationProgress={generationProgress}
|
||||||
onImageUpload={handleImageUpload}
|
onImageUpload={handleImageUpload}
|
||||||
@ -9591,8 +9640,6 @@ export function ImageNode({
|
|||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={LayoutTemplate}
|
fallback={LayoutTemplate}
|
||||||
item={imageLayoutReplaceItem}
|
item={imageLayoutReplaceItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
|
|
||||||
referenceSize={18}
|
|
||||||
/>
|
/>
|
||||||
<span>{imageLayoutReplaceItem.label}</span>
|
<span>{imageLayoutReplaceItem.label}</span>
|
||||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||||
@ -10145,12 +10192,8 @@ export function ImageNode({
|
|||||||
typeof document !== "undefined" &&
|
typeof document !== "undefined" &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<ReferenceImageToolbar
|
<div
|
||||||
referencePresentation={useReferenceImageToolbar}
|
className="fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-white/70 bg-white/82 px-2 py-1.5 text-gray-800 shadow-[0_18px_52px_rgba(15,23,42,0.20)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-[-10px] before:-z-10 before:rounded-[32px] before:bg-white/35 before:shadow-[0_18px_64px_rgba(15,23,42,0.18)] before:backdrop-blur-2xl before:content-[''] supports-[backdrop-filter]:bg-white/72 dark:border-white/12 dark:bg-[#08090c]/72 dark:text-zinc-100 dark:shadow-[0_24px_72px_rgba(0,0,0,0.58)] dark:before:bg-black/35 dark:before:shadow-[0_24px_80px_rgba(0,0,0,0.62)] supports-[backdrop-filter]:dark:bg-[#08090c]/62"
|
||||||
defaultPinnedKeys={enabledSelectedImageToolbarActions.slice(0, selectedImageInlineActionCount).map((action) => action.key)}
|
|
||||||
className={cn("fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-white/70 bg-white/82 px-2 py-1.5 text-gray-800 shadow-[0_18px_52px_rgba(15,23,42,0.20)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-[-10px] before:-z-10 before:rounded-[32px] before:bg-white/35 before:shadow-[0_18px_64px_rgba(15,23,42,0.18)] before:backdrop-blur-2xl before:content-[''] supports-[backdrop-filter]:bg-white/72 dark:border-white/12 dark:bg-[#08090c]/72 dark:text-zinc-100 dark:shadow-[0_24px_72px_rgba(0,0,0,0.58)] dark:before:bg-black/35 dark:before:shadow-[0_24px_80px_rgba(0,0,0,0.62)] supports-[backdrop-filter]:dark:bg-[#08090c]/62", useReferenceImageToolbar && "lovart-image-action-bar")}
|
|
||||||
data-selected-image-toolbar="true"
|
|
||||||
data-reference-presentation={useReferenceImageToolbar ? "true" : undefined}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
@ -10174,7 +10217,6 @@ export function ImageNode({
|
|||||||
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
||||||
disabled={isProcessing || isAddingToChat}
|
disabled={isProcessing || isAddingToChat}
|
||||||
onClick={handleAddToChat}
|
onClick={handleAddToChat}
|
||||||
data-tool-key="addToChat"
|
|
||||||
title={imageAddToChatItem.label}
|
title={imageAddToChatItem.label}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
@ -10186,8 +10228,6 @@ export function ImageNode({
|
|||||||
className="h-4 w-4 dark:invert-0"
|
className="h-4 w-4 dark:invert-0"
|
||||||
fallback={MessageSquarePlus}
|
fallback={MessageSquarePlus}
|
||||||
item={imageAddToChatItem}
|
item={imageAddToChatItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="whitespace-nowrap">{imageAddToChatItem.label}</span>
|
<span className="whitespace-nowrap">{imageAddToChatItem.label}</span>
|
||||||
@ -10202,7 +10242,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("pickObject") ? (
|
{isSelectedImageActionInline("pickObject") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="pickObject"
|
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing || isPickerLoading || isThisNodePicking}
|
disabled={isProcessing || isPickerLoading || isThisNodePicking}
|
||||||
onClick={handleStartPicking}
|
onClick={handleStartPicking}
|
||||||
@ -10213,8 +10252,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={Crosshair}
|
fallback={Crosshair}
|
||||||
item={imagePickObjectItem}
|
item={imagePickObjectItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-move-object" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imagePickObjectItem.label}
|
{imagePickObjectItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
@ -10222,7 +10259,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("enhance") ? (
|
{isSelectedImageActionInline("enhance") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="enhance"
|
|
||||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("enhance")}
|
onClick={() => handleAction("enhance")}
|
||||||
@ -10233,8 +10269,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={Sparkles}
|
fallback={Sparkles}
|
||||||
item={imageEnhanceItem}
|
item={imageEnhanceItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-upscale" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imageEnhanceItem.label}
|
{imageEnhanceItem.label}
|
||||||
<ImageActionCreditBadge compact label={enhanceCostShortLabel} />
|
<ImageActionCreditBadge compact label={enhanceCostShortLabel} />
|
||||||
@ -10243,7 +10277,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("removeBackground") ? (
|
{isSelectedImageActionInline("removeBackground") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="removeBackground"
|
|
||||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("rmbg")}
|
onClick={() => handleAction("rmbg")}
|
||||||
@ -10254,8 +10287,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={Scissors}
|
fallback={Scissors}
|
||||||
item={imageRemoveBackgroundItem}
|
item={imageRemoveBackgroundItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-remove-background" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imageRemoveBackgroundItem.label}
|
{imageRemoveBackgroundItem.label}
|
||||||
<ImageActionCreditBadge
|
<ImageActionCreditBadge
|
||||||
@ -10267,7 +10298,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("aiWashQuick") ? (
|
{isSelectedImageActionInline("aiWashQuick") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="aiWashQuick"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative h-10 rounded-xl px-3 font-medium text-[13px] transition",
|
"group relative h-10 rounded-xl px-3 font-medium text-[13px] transition",
|
||||||
aiWashQuickPanelOpen
|
aiWashQuickPanelOpen
|
||||||
@ -10277,7 +10307,6 @@ export function ImageNode({
|
|||||||
disabled={isProcessing || isSubmittingAiWashQuick}
|
disabled={isProcessing || isSubmittingAiWashQuick}
|
||||||
onClick={() => handleAction("ai-wash")}
|
onClick={() => handleAction("ai-wash")}
|
||||||
title={aiWashQuickModeLabel}
|
title={aiWashQuickModeLabel}
|
||||||
data-reference-active={aiWashQuickPanelOpen ? "true" : undefined}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
{isSubmittingAiWashQuick ? (
|
{isSubmittingAiWashQuick ? (
|
||||||
@ -10288,8 +10317,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={Sparkles}
|
fallback={Sparkles}
|
||||||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||||||
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{aiWashQuickModeLabel}
|
{aiWashQuickModeLabel}
|
||||||
@ -10302,7 +10329,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("textEdit") ? (
|
{isSelectedImageActionInline("textEdit") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="textEdit"
|
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
|
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
|
||||||
onClick={() => handleAction("text-edit")}
|
onClick={() => handleAction("text-edit")}
|
||||||
@ -10313,8 +10339,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={Type}
|
fallback={Type}
|
||||||
item={imageTextEditItem}
|
item={imageTextEditItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-edit-text" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imageTextEditItem.label}
|
{imageTextEditItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
@ -10322,7 +10346,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("boxCutout") ? (
|
{isSelectedImageActionInline("boxCutout") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="boxCutout"
|
|
||||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("box-cutout")}
|
onClick={() => handleAction("box-cutout")}
|
||||||
@ -10333,8 +10356,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={Scan}
|
fallback={Scan}
|
||||||
item={imageBoxCutoutItem}
|
item={imageBoxCutoutItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-vector" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imageBoxCutoutItem.label}
|
{imageBoxCutoutItem.label}
|
||||||
<ImageActionCreditBadge compact label={boxCutoutCostShortLabel} />
|
<ImageActionCreditBadge compact label={boxCutoutCostShortLabel} />
|
||||||
@ -10343,7 +10364,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("localEdit") ? (
|
{isSelectedImageActionInline("localEdit") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="localEdit"
|
|
||||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing || isSubmittingLocalEdit}
|
disabled={isProcessing || isSubmittingLocalEdit}
|
||||||
onClick={() => handleAction("local-edit")}
|
onClick={() => handleAction("local-edit")}
|
||||||
@ -10354,8 +10374,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={Wand2}
|
fallback={Wand2}
|
||||||
item={imageLocalEditItem}
|
item={imageLocalEditItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imageLocalEditItem.label}
|
{imageLocalEditItem.label}
|
||||||
<ImageActionCreditBadge
|
<ImageActionCreditBadge
|
||||||
@ -10367,7 +10385,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("layoutReplace") ? (
|
{isSelectedImageActionInline("layoutReplace") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="layoutReplace"
|
|
||||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("layout-replace")}
|
onClick={() => handleAction("layout-replace")}
|
||||||
@ -10378,8 +10395,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={LayoutTemplate}
|
fallback={LayoutTemplate}
|
||||||
item={imageLayoutReplaceItem}
|
item={imageLayoutReplaceItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
|
|
||||||
referenceSize={18}
|
|
||||||
/>
|
/>
|
||||||
{imageLayoutReplaceItem.label}
|
{imageLayoutReplaceItem.label}
|
||||||
<ImageActionCreditBadge
|
<ImageActionCreditBadge
|
||||||
@ -10391,7 +10406,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("removeWatermark") ? (
|
{isSelectedImageActionInline("removeWatermark") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="removeWatermark"
|
|
||||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("watermark")}
|
onClick={() => handleAction("watermark")}
|
||||||
@ -10402,8 +10416,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={Eraser}
|
fallback={Eraser}
|
||||||
item={imageRemoveWatermarkItem}
|
item={imageRemoveWatermarkItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-erase" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imageRemoveWatermarkItem.label}
|
{imageRemoveWatermarkItem.label}
|
||||||
<ImageActionCreditBadge
|
<ImageActionCreditBadge
|
||||||
@ -10415,7 +10427,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("resize") ? (
|
{isSelectedImageActionInline("resize") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="resize"
|
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("resize")}
|
onClick={() => handleAction("resize")}
|
||||||
@ -10433,7 +10444,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("multiAngle") ? (
|
{isSelectedImageActionInline("multiAngle") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="multiAngle"
|
|
||||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("angle")}
|
onClick={() => handleAction("angle")}
|
||||||
@ -10444,8 +10454,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={Box}
|
fallback={Box}
|
||||||
item={imageMultiAngleItem}
|
item={imageMultiAngleItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-angle" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imageMultiAngleItem.label}
|
{imageMultiAngleItem.label}
|
||||||
<ImageActionCreditBadge
|
<ImageActionCreditBadge
|
||||||
@ -10457,7 +10465,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("generate3dModel") ? (
|
{isSelectedImageActionInline("generate3dModel") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="generate3dModel"
|
|
||||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("generate-3d-model")}
|
onClick={() => handleAction("generate-3d-model")}
|
||||||
@ -10479,7 +10486,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("generate3dWorld") ? (
|
{isSelectedImageActionInline("generate3dWorld") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="generate3dWorld"
|
|
||||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("generate-3d-world")}
|
onClick={() => handleAction("generate-3d-world")}
|
||||||
@ -10501,7 +10507,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("layerSplit") ? (
|
{isSelectedImageActionInline("layerSplit") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="layerSplit"
|
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("layer-split")}
|
onClick={() => handleAction("layer-split")}
|
||||||
@ -10512,8 +10517,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={Layers}
|
fallback={Layers}
|
||||||
item={imageLayerSplitItem}
|
item={imageLayerSplitItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-extract-layer" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imageLayerSplitItem.label}
|
{imageLayerSplitItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
@ -10521,7 +10524,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("extend") ? (
|
{isSelectedImageActionInline("extend") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="extend"
|
|
||||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("extend")}
|
onClick={() => handleAction("extend")}
|
||||||
@ -10532,8 +10534,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={Expand}
|
fallback={Expand}
|
||||||
item={imageExtendItem}
|
item={imageExtendItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-outpaint" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imageExtendItem.label}
|
{imageExtendItem.label}
|
||||||
<ImageActionCreditBadge compact label={extendCostShortLabel} />
|
<ImageActionCreditBadge compact label={extendCostShortLabel} />
|
||||||
@ -10542,7 +10542,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("crop") ? (
|
{isSelectedImageActionInline("crop") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="crop"
|
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("crop")}
|
onClick={() => handleAction("crop")}
|
||||||
@ -10553,8 +10552,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={Crop}
|
fallback={Crop}
|
||||||
item={imageCropItem}
|
item={imageCropItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-crop" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imageCropItem.label}
|
{imageCropItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
@ -10562,7 +10559,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("adjust") ? (
|
{isSelectedImageActionInline("adjust") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="adjust"
|
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("adjust")}
|
onClick={() => handleAction("adjust")}
|
||||||
@ -10573,8 +10569,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={SlidersHorizontal}
|
fallback={SlidersHorizontal}
|
||||||
item={imageAdjustItem}
|
item={imageAdjustItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-adjust" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imageAdjustItem.label}
|
{imageAdjustItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
@ -10582,7 +10576,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("flipRotate") ? (
|
{isSelectedImageActionInline("flipRotate") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="flipRotate"
|
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("flip-rotate")}
|
onClick={() => handleAction("flip-rotate")}
|
||||||
@ -10593,8 +10586,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={FlipHorizontal2}
|
fallback={FlipHorizontal2}
|
||||||
item={imageFlipRotateItem}
|
item={imageFlipRotateItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-flip-rotate" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imageFlipRotateItem.label}
|
{imageFlipRotateItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
@ -10602,7 +10593,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("promptRegenerate") ? (
|
{isSelectedImageActionInline("promptRegenerate") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="promptRegenerate"
|
|
||||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("prompt-regenerate")}
|
onClick={() => handleAction("prompt-regenerate")}
|
||||||
@ -10624,7 +10614,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
{isSelectedImageActionInline("note") ? (
|
{isSelectedImageActionInline("note") ? (
|
||||||
<Button
|
<Button
|
||||||
data-tool-key="note"
|
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("note")}
|
onClick={() => handleAction("note")}
|
||||||
@ -10635,8 +10624,6 @@ export function ImageNode({
|
|||||||
className="mr-1.5 h-4 w-4"
|
className="mr-1.5 h-4 w-4"
|
||||||
fallback={StickyNote}
|
fallback={StickyNote}
|
||||||
item={imageNoteItem}
|
item={imageNoteItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
{imageNoteItem.label}
|
{imageNoteItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
@ -10661,14 +10648,12 @@ export function ImageNode({
|
|||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={MoreHorizontal}
|
fallback={MoreHorizontal}
|
||||||
item={imageNodeMoreItem}
|
item={imageNodeMoreItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-more" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="center"
|
align="center"
|
||||||
className={cn("bg-white opacity-100 backdrop-blur-none dark:bg-zinc-950 dark:text-zinc-100", useReferenceImageToolbar && "lovart-image-action-menu")}
|
className="bg-white opacity-100 backdrop-blur-none dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
{isSelectedImageActionInMore("boxCutout") ? (
|
{isSelectedImageActionInMore("boxCutout") ? (
|
||||||
@ -10677,8 +10662,6 @@ export function ImageNode({
|
|||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={Scan}
|
fallback={Scan}
|
||||||
item={imageBoxCutoutItem}
|
item={imageBoxCutoutItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-vector" : undefined}
|
|
||||||
referenceSize={18}
|
|
||||||
/>
|
/>
|
||||||
<span>{imageBoxCutoutItem.label}</span>
|
<span>{imageBoxCutoutItem.label}</span>
|
||||||
<ImageActionCreditBadge label={boxCutoutCostShortLabel} />
|
<ImageActionCreditBadge label={boxCutoutCostShortLabel} />
|
||||||
@ -10690,8 +10673,6 @@ export function ImageNode({
|
|||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={Wand2}
|
fallback={Wand2}
|
||||||
item={imageLocalEditItem}
|
item={imageLocalEditItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
|
|
||||||
referenceSize={18}
|
|
||||||
/>
|
/>
|
||||||
<span>{imageLocalEditItem.label}</span>
|
<span>{imageLocalEditItem.label}</span>
|
||||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||||
@ -10705,8 +10686,6 @@ export function ImageNode({
|
|||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={LayoutTemplate}
|
fallback={LayoutTemplate}
|
||||||
item={imageLayoutReplaceItem}
|
item={imageLayoutReplaceItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
|
|
||||||
referenceSize={18}
|
|
||||||
/>
|
/>
|
||||||
<span>{imageLayoutReplaceItem.label}</span>
|
<span>{imageLayoutReplaceItem.label}</span>
|
||||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||||
@ -10718,8 +10697,6 @@ export function ImageNode({
|
|||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={Eraser}
|
fallback={Eraser}
|
||||||
item={imageRemoveWatermarkItem}
|
item={imageRemoveWatermarkItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-erase" : undefined}
|
|
||||||
referenceSize={18}
|
|
||||||
/>
|
/>
|
||||||
<span>{imageRemoveWatermarkItem.label}</span>
|
<span>{imageRemoveWatermarkItem.label}</span>
|
||||||
<ImageActionCreditBadge
|
<ImageActionCreditBadge
|
||||||
@ -10743,8 +10720,6 @@ export function ImageNode({
|
|||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={Box}
|
fallback={Box}
|
||||||
item={imageMultiAngleItem}
|
item={imageMultiAngleItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-angle" : undefined}
|
|
||||||
referenceSize={18}
|
|
||||||
/>
|
/>
|
||||||
<span>{imageMultiAngleItem.label}</span>
|
<span>{imageMultiAngleItem.label}</span>
|
||||||
<ImageActionCreditBadge
|
<ImageActionCreditBadge
|
||||||
@ -10786,8 +10761,6 @@ export function ImageNode({
|
|||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={Layers}
|
fallback={Layers}
|
||||||
item={imageLayerSplitItem}
|
item={imageLayerSplitItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-extract-layer" : undefined}
|
|
||||||
referenceSize={18}
|
|
||||||
/>
|
/>
|
||||||
{imageLayerSplitItem.label}
|
{imageLayerSplitItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -10798,8 +10771,6 @@ export function ImageNode({
|
|||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={Expand}
|
fallback={Expand}
|
||||||
item={imageExtendItem}
|
item={imageExtendItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-outpaint" : undefined}
|
|
||||||
referenceSize={18}
|
|
||||||
/>
|
/>
|
||||||
<span>{imageExtendItem.label}</span>
|
<span>{imageExtendItem.label}</span>
|
||||||
<ImageActionCreditBadge label={extendCostShortLabel} />
|
<ImageActionCreditBadge label={extendCostShortLabel} />
|
||||||
@ -10811,8 +10782,6 @@ export function ImageNode({
|
|||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={Crop}
|
fallback={Crop}
|
||||||
item={imageCropItem}
|
item={imageCropItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-crop" : undefined}
|
|
||||||
referenceSize={18}
|
|
||||||
/>
|
/>
|
||||||
{imageCropItem.label}
|
{imageCropItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -10823,8 +10792,6 @@ export function ImageNode({
|
|||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={SlidersHorizontal}
|
fallback={SlidersHorizontal}
|
||||||
item={imageAdjustItem}
|
item={imageAdjustItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-adjust" : undefined}
|
|
||||||
referenceSize={18}
|
|
||||||
/>
|
/>
|
||||||
{imageAdjustItem.label}
|
{imageAdjustItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -10835,8 +10802,6 @@ export function ImageNode({
|
|||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={FlipHorizontal2}
|
fallback={FlipHorizontal2}
|
||||||
item={imageFlipRotateItem}
|
item={imageFlipRotateItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-flip-rotate" : undefined}
|
|
||||||
referenceSize={18}
|
|
||||||
/>
|
/>
|
||||||
{imageFlipRotateItem.label}
|
{imageFlipRotateItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -10862,8 +10827,6 @@ export function ImageNode({
|
|||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
fallback={StickyNote}
|
fallback={StickyNote}
|
||||||
item={imageNoteItem}
|
item={imageNoteItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
|
|
||||||
referenceSize={18}
|
|
||||||
/>
|
/>
|
||||||
{imageNoteItem.label}
|
{imageNoteItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -10903,7 +10866,6 @@ export function ImageNode({
|
|||||||
void handleDownload();
|
void handleDownload();
|
||||||
}}
|
}}
|
||||||
size="icon"
|
size="icon"
|
||||||
data-tool-key="download"
|
|
||||||
title={imageDownloadItem.label}
|
title={imageDownloadItem.label}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
@ -10914,13 +10876,11 @@ export function ImageNode({
|
|||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Download}
|
fallback={Download}
|
||||||
item={imageDownloadItem}
|
item={imageDownloadItem}
|
||||||
referenceName={useReferenceImageToolbar ? "image-download" : undefined}
|
|
||||||
referenceSize={25}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</ReferenceImageToolbar>
|
</div>
|
||||||
</TooltipProvider>,
|
</TooltipProvider>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
@ -11916,7 +11876,6 @@ export function ImageNode({
|
|||||||
{/* 双击放大预览弹窗 */}
|
{/* 双击放大预览弹窗 */}
|
||||||
{fullscreenPreviewOpen &&
|
{fullscreenPreviewOpen &&
|
||||||
data.src &&
|
data.src &&
|
||||||
fullscreenPreviewImage &&
|
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
|
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
|
||||||
@ -11924,7 +11883,7 @@ export function ImageNode({
|
|||||||
>
|
>
|
||||||
{/* 关闭按钮 */}
|
{/* 关闭按钮 */}
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-4 z-10 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
|
className="absolute top-4 right-4 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
|
||||||
onClick={() => setFullscreenPreviewOpen(false)}
|
onClick={() => setFullscreenPreviewOpen(false)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -11942,7 +11901,7 @@ export function ImageNode({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-16 z-10 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
|
className="absolute top-4 right-16 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
disabled={isDownloadingOriginalImage}
|
disabled={isDownloadingOriginalImage}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -11961,11 +11920,13 @@ export function ImageNode({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 图片 */}
|
{/* 图片 */}
|
||||||
<ProgressiveImagePreview
|
<img
|
||||||
alt={data.prompt || "图片预览"}
|
alt={data.prompt || "图片预览"}
|
||||||
className="max-h-[95vh] max-w-[95vw] object-contain"
|
className="max-h-[95vh] max-w-[95vw] object-contain"
|
||||||
src={resolveOriginalImageUrl(data.src) || data.src}
|
decoding="async"
|
||||||
preview={fullscreenPreviewImage}
|
draggable={false}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
src={fullscreenPreviewSrc}
|
||||||
style={activeImageAdjustmentsStyle}
|
style={activeImageAdjustmentsStyle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { forwardRef, type ImgHTMLAttributes, useState } from "react";
|
|
||||||
|
|
||||||
type Props = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & { src: string; sourceKey: string };
|
|
||||||
|
|
||||||
export const CanvasPreviewImage = forwardRef<HTMLImageElement, Props>(function CanvasPreviewImage(
|
|
||||||
{ src, sourceKey, onLoad, style, ...props }, ref,
|
|
||||||
) {
|
|
||||||
const [displayed, setDisplayed] = useState({ sourceKey, src });
|
|
||||||
const visibleSrc = displayed.sourceKey === sourceKey ? displayed.src : src;
|
|
||||||
const sources = visibleSrc === src ? [src] : [visibleSrc, src];
|
|
||||||
return <>{sources.map(candidate => {
|
|
||||||
const visible = candidate === visibleSrc;
|
|
||||||
return <img
|
|
||||||
{...props}
|
|
||||||
key={candidate}
|
|
||||||
ref={visible ? ref : undefined}
|
|
||||||
src={candidate}
|
|
||||||
alt={visible ? props.alt : ""}
|
|
||||||
aria-hidden={visible ? props["aria-hidden"] : true}
|
|
||||||
style={visible ? style : { ...style, position: "absolute", inset: 0, opacity: 0, pointerEvents: "none" }}
|
|
||||||
onLoad={event => {
|
|
||||||
const image = event.currentTarget;
|
|
||||||
onLoad?.(event);
|
|
||||||
void image.decode().then(() => {
|
|
||||||
if (image.isConnected && candidate === src) setDisplayed({ sourceKey, src: candidate });
|
|
||||||
}).catch(() => undefined);
|
|
||||||
}}
|
|
||||||
/>;
|
|
||||||
})}</>;
|
|
||||||
});
|
|
||||||
@ -3,14 +3,11 @@
|
|||||||
import { AlertCircle, ImagePlus, Loader2 } from "lucide-react";
|
import { AlertCircle, ImagePlus, Loader2 } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
|
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
|
||||||
import type { RequestFailureDetails } from "@/lib/request-error";
|
|
||||||
|
|
||||||
interface ImageNodeEmptyStateProps {
|
interface ImageNodeEmptyStateProps {
|
||||||
detailProgressLabel?: string;
|
detailProgressLabel?: string;
|
||||||
fileInputRef: React.Ref<HTMLInputElement>;
|
fileInputRef: React.Ref<HTMLInputElement>;
|
||||||
generationProgress?: number | null;
|
generationProgress?: number | null;
|
||||||
failure?: RequestFailureDetails;
|
|
||||||
requestId?: string;
|
|
||||||
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
status?: string;
|
status?: string;
|
||||||
@ -55,8 +52,6 @@ export function ImageNodeEmptyState({
|
|||||||
detailProgressLabel,
|
detailProgressLabel,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
generationProgress,
|
generationProgress,
|
||||||
failure,
|
|
||||||
requestId,
|
|
||||||
onImageUpload,
|
onImageUpload,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
status,
|
status,
|
||||||
@ -94,7 +89,7 @@ export function ImageNodeEmptyState({
|
|||||||
if (status === "failed") {
|
if (status === "failed") {
|
||||||
const failedTitle = detailProgressLabel || "生成失败";
|
const failedTitle = detailProgressLabel || "生成失败";
|
||||||
return (
|
return (
|
||||||
<div role="alert" className="flex h-full w-full flex-col items-center justify-center overflow-auto bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
|
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
|
||||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]">
|
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]">
|
||||||
<AlertCircle className="h-5 w-5" />
|
<AlertCircle className="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
@ -102,9 +97,8 @@ export function ImageNodeEmptyState({
|
|||||||
{failedTitle}
|
{failedTitle}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 text-xs text-[#b77480]">
|
<span className="mt-1 text-xs text-[#b77480]">
|
||||||
{failure?.recommendation || (failedTitle.includes("上传") ? "请重新选择文件" : "请查看对话中的错误原因和处理建议")}
|
{failedTitle.includes("上传") ? "请重新选择文件" : "请重新上传或再试一次"}
|
||||||
</span>
|
</span>
|
||||||
{requestId ? <span className="mt-2 select-text break-all text-xs text-[#b77480]">请求编号:{requestId}</span> : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -89,12 +89,6 @@ function isShot(value: unknown): value is TVstarShot {
|
|||||||
return Boolean(value && typeof value === "object" && typeof (value as TVstarShot).sceneDescription === "string");
|
return Boolean(value && typeof value === "object" && typeof (value as TVstarShot).sceneDescription === "string");
|
||||||
}
|
}
|
||||||
|
|
||||||
function productionShots(value: unknown) {
|
|
||||||
const shots = Array.isArray(value) ? value.filter(isShot) : [];
|
|
||||||
if (shots.length > 12) throw Error("自动视频制作最多支持12个镜头,请拆分制作;不会截掉多余镜头");
|
|
||||||
return shots;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TVstarPanel({
|
export function TVstarPanel({
|
||||||
canvasId,
|
canvasId,
|
||||||
models,
|
models,
|
||||||
@ -181,7 +175,6 @@ export function TVstarPanel({
|
|||||||
const active = sessions.find(item => item.id === activeId);
|
const active = sessions.find(item => item.id === activeId);
|
||||||
const activeTasks = active?.groupId ? tasks.filter(task => task.request.workflow?.groupId === active.groupId) : [];
|
const activeTasks = active?.groupId ? tasks.filter(task => task.request.workflow?.groupId === active.groupId) : [];
|
||||||
const finalTask = activeTasks.find(task => task.request.kind === "smart-edit" && task.request.params.operation === "render");
|
const finalTask = activeTasks.find(task => task.request.kind === "smart-edit" && task.request.params.operation === "render");
|
||||||
const failedTask = activeTasks.find(task => (task.status === "failed" || task.status === "interrupted") && task.error && !task.error.startsWith("上游任务失败")) || finalTask;
|
|
||||||
const finalUrl = typeof finalTask?.result?.url === "string" ? finalTask.result.url : "";
|
const finalUrl = typeof finalTask?.result?.url === "string" ? finalTask.result.url : "";
|
||||||
const productionStatus = active?.preparedOnly ? "待运行工作流已建好,检查后可开始制作"
|
const productionStatus = active?.preparedOnly ? "待运行工作流已建好,检查后可开始制作"
|
||||||
: activeTasks.some(task => task.status === "failed" || task.status === "interrupted")
|
: activeTasks.some(task => task.status === "failed" || task.status === "interrupted")
|
||||||
@ -372,7 +365,7 @@ export function TVstarPanel({
|
|||||||
}
|
}
|
||||||
setError("");
|
setError("");
|
||||||
if (active) { await reviseSession(active, input); return; }
|
if (active) { await reviseSession(active, input); return; }
|
||||||
const directShots = mode === "script" || mode === "storyboard" || mode === "full";
|
const directShots = mode === "script" || mode === "storyboard";
|
||||||
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(input);
|
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(input);
|
||||||
setBusy(mode === "edit" ? "submit" : directShots ? "shots" : "plan");
|
setBusy(mode === "edit" ? "submit" : directShots ? "shots" : "plan");
|
||||||
let item: Session = {
|
let item: Session = {
|
||||||
@ -403,14 +396,13 @@ export function TVstarPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await runAnalysis(directShots ? "story-script" : "agent-plan", input, item.references || [], item.nodeContexts || []);
|
const data = await runAnalysis(directShots ? "story-script" : "agent-plan", input, item.references || [], item.nodeContexts || []);
|
||||||
const shots = directShots ? productionShots(data.analysisRows) : undefined;
|
const shots = directShots && Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : undefined;
|
||||||
if (directShots && !shots?.length) throw new Error("模型没有返回有效分镜,请调整创作目标后重试");
|
if (directShots && !shots?.length) throw new Error("模型没有返回有效分镜,请调整创作目标后重试");
|
||||||
item = {
|
item = {
|
||||||
...item,
|
...item,
|
||||||
plan: directShots && mode !== "full" ? undefined : {
|
plan: directShots ? undefined : {
|
||||||
summary: String(data.summary || ""),
|
summary: String(data.summary || ""),
|
||||||
agentPlan: Array.isArray(data.agentPlan) ? data.agentPlan.map(String)
|
agentPlan: Array.isArray(data.agentPlan) ? data.agentPlan.map(String) : [],
|
||||||
: Array.isArray(data.recommendedNextSteps) ? data.recommendedNextSteps.map(String) : [],
|
|
||||||
deliverables: Array.isArray(data.deliverables) ? data.deliverables.map(String) : [],
|
deliverables: Array.isArray(data.deliverables) ? data.deliverables.map(String) : [],
|
||||||
recommendedNodes: Array.isArray(data.recommendedNodes) ? data.recommendedNodes.map(String) : [],
|
recommendedNodes: Array.isArray(data.recommendedNodes) ? data.recommendedNodes.map(String) : [],
|
||||||
model: typeof data.model === "string" ? data.model : undefined,
|
model: typeof data.model === "string" ? data.model : undefined,
|
||||||
@ -432,9 +424,9 @@ export function TVstarPanel({
|
|||||||
}
|
}
|
||||||
if (mode === "full") {
|
if (mode === "full") {
|
||||||
setBusy("shots");
|
setBusy("shots");
|
||||||
const storyboard = item.shots?.length ? { analysisRows: item.shots } : await runAnalysis("story-script", [input, item.plan?.summary,
|
const storyboard = await runAnalysis("story-script", [input, item.plan?.summary,
|
||||||
item.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), item.references || [], item.nodeContexts || []);
|
item.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), item.references || [], item.nodeContexts || []);
|
||||||
const generated = productionShots(storyboard.analysisRows);
|
const generated = Array.isArray(storyboard.analysisRows) ? storyboard.analysisRows.filter(isShot).slice(0, 12) : [];
|
||||||
if (!generated.length) throw new Error("模型没有返回有效分镜,请重试");
|
if (!generated.length) throw new Error("模型没有返回有效分镜,请重试");
|
||||||
item = { ...item, shots: generated, pendingStage: allowAutoRun ? "submit" : undefined, messages: [
|
item = { ...item, shots: generated, pendingStage: allowAutoRun ? "submit" : undefined, messages: [
|
||||||
...(item.messages || []), message("assistant", allowAutoRun
|
...(item.messages || []), message("assistant", allowAutoRun
|
||||||
@ -518,7 +510,7 @@ export function TVstarPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await runAnalysis("story-script", prompt, mergedRefs, mergedContexts);
|
const data = await runAnalysis("story-script", prompt, mergedRefs, mergedContexts);
|
||||||
const shots = productionShots(data.analysisRows);
|
const shots = Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : [];
|
||||||
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试修改要求");
|
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试修改要求");
|
||||||
let updated: Session = { ...pending, shots, runSubmitted: false, pendingStage: session.mode === "full" && allowAutoRun ? "submit" : undefined,
|
let updated: Session = { ...pending, shots, runSubmitted: false, pendingStage: session.mode === "full" && allowAutoRun ? "submit" : undefined,
|
||||||
messages: [...(pending.messages || []), message("assistant", `已按新要求更新 ${shots.length} 个分镜。`)] };
|
messages: [...(pending.messages || []), message("assistant", `已按新要求更新 ${shots.length} 个分镜。`)] };
|
||||||
@ -553,7 +545,7 @@ export function TVstarPanel({
|
|||||||
try {
|
try {
|
||||||
const data = await runAnalysis("story-script", [active.goal, active.plan?.summary,
|
const data = await runAnalysis("story-script", [active.goal, active.plan?.summary,
|
||||||
active.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), active.references || [], active.nodeContexts || []);
|
active.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), active.references || [], active.nodeContexts || []);
|
||||||
const shots = productionShots(data.analysisRows);
|
const shots = Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : [];
|
||||||
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试");
|
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试");
|
||||||
const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal;
|
const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal;
|
||||||
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(latest);
|
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(latest);
|
||||||
@ -795,7 +787,7 @@ export function TVstarPanel({
|
|||||||
].map(([label, action]) => <button className="flex h-10 w-full items-center justify-between rounded-xl px-3 text-left text-[13px] text-white/60 hover:bg-white/5 hover:text-white" key={label as string} onClick={action as () => void}><span><Sparkles className="mr-2 inline size-3.5" />{label as string}</span><span>→</span></button>)}</div>
|
].map(([label, action]) => <button className="flex h-10 w-full items-center justify-between rounded-xl px-3 text-left text-[13px] text-white/60 hover:bg-white/5 hover:text-white" key={label as string} onClick={action as () => void}><span><Sparkles className="mr-2 inline size-3.5" />{label as string}</span><span>→</span></button>)}</div>
|
||||||
</div> : <div className="space-y-4 pt-4">
|
</div> : <div className="space-y-4 pt-4">
|
||||||
{(active.messages || []).length ? <div aria-label="TVstar 对话记录" className="space-y-2">{active.messages?.map(item => <p className={`max-w-[92%] whitespace-pre-wrap rounded-xl px-3 py-2 text-xs leading-5 ${item.role === "user" ? "ml-auto bg-[#343b48] text-white" : "bg-[#292929] text-white/70"}`} key={item.id}>{item.content}</p>)}</div> : null}
|
{(active.messages || []).length ? <div aria-label="TVstar 对话记录" className="space-y-2">{active.messages?.map(item => <p className={`max-w-[92%] whitespace-pre-wrap rounded-xl px-3 py-2 text-xs leading-5 ${item.role === "user" ? "ml-auto bg-[#343b48] text-white" : "bg-[#292929] text-white/70"}`} key={item.id}>{item.content}</p>)}</div> : null}
|
||||||
{active.groupId ? <div aria-label="TVstar 制作进度" className={`${panel} p-3`}><p className="text-xs text-cyan-200" role="status">{productionStatus}</p>{!active.preparedOnly ? <p className="mt-1 text-[11px] text-white/45">{activeTasks.filter(task => task.status === "succeeded").length}/{activeTasks.length || "…"} 项任务完成</p> : <p className="mt-1 text-[11px] text-white/45">画布节点已保存,尚未启动模型或扣费</p>}{finalUrl ? <video className="mt-3 w-full rounded-lg bg-black" controls playsInline preload="metadata" src={finalUrl} /> : null}{failedTask?.error ? <p className="mt-2 text-xs text-red-200" role="alert">{failedTask.request.node.title}:{failedTask.error}</p> : null}</div> : null}
|
{active.groupId ? <div aria-label="TVstar 制作进度" className={`${panel} p-3`}><p className="text-xs text-cyan-200" role="status">{productionStatus}</p>{!active.preparedOnly ? <p className="mt-1 text-[11px] text-white/45">{activeTasks.filter(task => task.status === "succeeded").length}/{activeTasks.length || "…"} 项任务完成</p> : <p className="mt-1 text-[11px] text-white/45">画布节点已保存,尚未启动模型或扣费</p>}{finalUrl ? <video className="mt-3 w-full rounded-lg bg-black" controls playsInline preload="metadata" src={finalUrl} /> : null}{finalTask?.error ? <p className="mt-2 text-xs text-red-200">{finalTask.error}</p> : null}</div> : null}
|
||||||
<div className={`${panel} p-3`}><p className="mb-2 text-[11px] text-white/40">创作目标</p><p className="whitespace-pre-wrap text-[13px] leading-6">{active.goal}</p></div>
|
<div className={`${panel} p-3`}><p className="mb-2 text-[11px] text-white/40">创作目标</p><p className="whitespace-pre-wrap text-[13px] leading-6">{active.goal}</p></div>
|
||||||
{active.plan ? <div className={`${panel} p-4`}><div className="mb-3 flex items-center gap-2 text-[13px] font-medium"><Sparkles className="size-4 text-cyan-300" />创作规划</div><p className="mb-3 text-[13px] leading-6 text-white/75">{active.plan.summary}</p><ol className="space-y-2">{active.plan.agentPlan.map((step, index) => <li className="flex gap-2 text-xs leading-5 text-white/70" key={`${index}-${step}`}><span className="text-cyan-300">{String(index + 1).padStart(2, "0")}</span><span>{step}</span></li>)}</ol>{active.plan.deliverables.length ? <p className="mt-4 border-t border-white/10 pt-3 text-[11px] text-white/45">交付物 · {active.plan.deliverables.join(" / ")}</p> : null}</div> : null}
|
{active.plan ? <div className={`${panel} p-4`}><div className="mb-3 flex items-center gap-2 text-[13px] font-medium"><Sparkles className="size-4 text-cyan-300" />创作规划</div><p className="mb-3 text-[13px] leading-6 text-white/75">{active.plan.summary}</p><ol className="space-y-2">{active.plan.agentPlan.map((step, index) => <li className="flex gap-2 text-xs leading-5 text-white/70" key={`${index}-${step}`}><span className="text-cyan-300">{String(index + 1).padStart(2, "0")}</span><span>{step}</span></li>)}</ol>{active.plan.deliverables.length ? <p className="mt-4 border-t border-white/10 pt-3 text-[11px] text-white/45">交付物 · {active.plan.deliverables.join(" / ")}</p> : null}</div> : null}
|
||||||
{active.shots?.length ? <div className="space-y-2"><h3 className="text-[13px] font-medium">分镜脚本 · {active.shots.length} 镜头</h3>{active.shots.map((shot, index) => <article className={`${panel} p-3`} key={`${index}-${shot.shot}`}><div className="mb-1 flex items-center justify-between text-xs"><span className="text-white/90">镜头 {shot.shot} · {shot.scene || "场景"}</span><span className="text-white/40">{shot.duration}</span></div><p className="text-xs leading-5 text-white/60">{shot.sceneDescription}</p><p className="mt-2 text-[11px] text-cyan-200/70">{shot.shotSize} · {shot.cameraMovement}</p></article>)}</div> : null}
|
{active.shots?.length ? <div className="space-y-2"><h3 className="text-[13px] font-medium">分镜脚本 · {active.shots.length} 镜头</h3>{active.shots.map((shot, index) => <article className={`${panel} p-3`} key={`${index}-${shot.shot}`}><div className="mb-1 flex items-center justify-between text-xs"><span className="text-white/90">镜头 {shot.shot} · {shot.scene || "场景"}</span><span className="text-white/40">{shot.duration}</span></div><p className="text-xs leading-5 text-white/60">{shot.sceneDescription}</p><p className="mt-2 text-[11px] text-cyan-200/70">{shot.shotSize} · {shot.cameraMovement}</p></article>)}</div> : null}
|
||||||
|
|||||||
@ -15,10 +15,7 @@ import {
|
|||||||
splitOpenClawChartBlocks,
|
splitOpenClawChartBlocks,
|
||||||
} from "@/components/elements/openclaw-chart-card";
|
} from "@/components/elements/openclaw-chart-card";
|
||||||
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
|
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
|
||||||
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
|
|
||||||
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
|
|
||||||
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@ -29,7 +26,6 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
type ResponseProps = ComponentProps<typeof Streamdown> & {
|
type ResponseProps = ComponentProps<typeof Streamdown> & {
|
||||||
collapsibleImageAnalysis?: boolean;
|
collapsibleImageAnalysis?: boolean;
|
||||||
isMarkdownStreaming?: boolean;
|
|
||||||
enablePdfDownload?: boolean;
|
enablePdfDownload?: boolean;
|
||||||
pdfFileName?: string;
|
pdfFileName?: string;
|
||||||
pdfSiteLogo?: string | null;
|
pdfSiteLogo?: string | null;
|
||||||
@ -366,6 +362,43 @@ function normalizeMarkdownContent(value: string) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitMarkdownImages(content: string) {
|
||||||
|
const segments: Array<
|
||||||
|
| { type: "text"; value: string }
|
||||||
|
| { type: "image"; alt?: string; src: string }
|
||||||
|
> = [];
|
||||||
|
|
||||||
|
const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = imageRegex.exec(content)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
segments.push({
|
||||||
|
type: "text",
|
||||||
|
value: content.slice(lastIndex, match.index),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.push({
|
||||||
|
type: "image",
|
||||||
|
alt: match[1],
|
||||||
|
src: match[2],
|
||||||
|
});
|
||||||
|
|
||||||
|
lastIndex = imageRegex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < content.length) {
|
||||||
|
segments.push({
|
||||||
|
type: "text",
|
||||||
|
value: content.slice(lastIndex),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
function isLikelyImageUrl(value?: string | null) {
|
function isLikelyImageUrl(value?: string | null) {
|
||||||
return IMAGE_URL_REGEX.test(String(value || "").trim());
|
return IMAGE_URL_REGEX.test(String(value || "").trim());
|
||||||
}
|
}
|
||||||
@ -442,8 +475,8 @@ function MarkdownTableImagePreview({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(src)) {
|
if (event.currentTarget.src !== src) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(src);
|
event.currentTarget.src = src;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -473,8 +506,8 @@ function MarkdownTableImagePreview({
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(originalSrc)) {
|
if (event.currentTarget.src !== originalSrc) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(originalSrc);
|
event.currentTarget.src = originalSrc;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -764,7 +797,6 @@ function ResponseReportTableDownloadButton({
|
|||||||
export const Response = ({
|
export const Response = ({
|
||||||
className,
|
className,
|
||||||
collapsibleImageAnalysis = false,
|
collapsibleImageAnalysis = false,
|
||||||
isMarkdownStreaming = false,
|
|
||||||
enablePdfDownload = false,
|
enablePdfDownload = false,
|
||||||
pdfFileName,
|
pdfFileName,
|
||||||
pdfSiteLogo,
|
pdfSiteLogo,
|
||||||
@ -1029,10 +1061,7 @@ export const Response = ({
|
|||||||
|
|
||||||
const rawText =
|
const rawText =
|
||||||
typeof props.children === "string"
|
typeof props.children === "string"
|
||||||
? normalizeMarkdownContent(stableStreamingMarkdown(
|
? normalizeMarkdownContent(props.children)
|
||||||
getGatewayErrorPageMessage(props.children) || props.children,
|
|
||||||
isMarkdownStreaming
|
|
||||||
))
|
|
||||||
: null;
|
: null;
|
||||||
const shouldShowPdfDownload =
|
const shouldShowPdfDownload =
|
||||||
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
|
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
|
||||||
@ -1062,7 +1091,7 @@ export const Response = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (rawText !== null) {
|
if (rawText) {
|
||||||
const analysisSection = collapsibleImageAnalysis
|
const analysisSection = collapsibleImageAnalysis
|
||||||
? extractImageAnalysisSection(rawText)
|
? extractImageAnalysisSection(rawText)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import "@/public/vendor/drawnix.css";
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
@ -9,7 +7,6 @@ type FlowBoardClientProps = {
|
|||||||
siteName: string;
|
siteName: string;
|
||||||
siteLogo: string | null;
|
siteLogo: string | null;
|
||||||
siteLogoDark: string | null;
|
siteLogoDark: string | null;
|
||||||
previewMode?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DrawnixWorkspace = dynamic(
|
const DrawnixWorkspace = dynamic(
|
||||||
|
|||||||
@ -1,206 +0,0 @@
|
|||||||
import {
|
|
||||||
addSelectedElement,
|
|
||||||
BoardTransforms,
|
|
||||||
clearSelectedElement,
|
|
||||||
PlaitBoard,
|
|
||||||
type PlaitElement,
|
|
||||||
PlaitHistoryBoard,
|
|
||||||
PlaitPointerType,
|
|
||||||
type Point,
|
|
||||||
Transforms,
|
|
||||||
} from "@plait/core";
|
|
||||||
import {
|
|
||||||
ArrowLineMarkerType,
|
|
||||||
ArrowLineShape,
|
|
||||||
BasicShapes,
|
|
||||||
createArrowLineElement,
|
|
||||||
createGeometryElementWithoutText,
|
|
||||||
} from "@plait/draw";
|
|
||||||
import { getRectangleByNode, MindElement } from "@plait/mind";
|
|
||||||
import {
|
|
||||||
type AnnotatedElement,
|
|
||||||
getBoundaryPoints,
|
|
||||||
getRelationshipPoints,
|
|
||||||
isMindAnnotation,
|
|
||||||
type MindAnnotation,
|
|
||||||
type TopicRectangle,
|
|
||||||
} from "@/lib/flow-board/mind-annotations";
|
|
||||||
|
|
||||||
function getTopics(board: PlaitBoard) {
|
|
||||||
const topics = new Map<
|
|
||||||
string,
|
|
||||||
{ element: MindElement; visible: MindElement }
|
|
||||||
>();
|
|
||||||
const visit = (elements: PlaitElement[], collapsedAncestor?: MindElement) => {
|
|
||||||
for (const element of elements) {
|
|
||||||
if (!MindElement.isMindElement(board, element)) continue;
|
|
||||||
topics.set(element.id, {
|
|
||||||
element,
|
|
||||||
visible: collapsedAncestor || element,
|
|
||||||
});
|
|
||||||
visit(
|
|
||||||
element.children || [],
|
|
||||||
collapsedAncestor || (element.isCollapsed ? element : undefined)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
visit(board.children);
|
|
||||||
return topics;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rectangle(element: MindElement): TopicRectangle | null {
|
|
||||||
const node = MindElement.getNode(element);
|
|
||||||
return node ? getRectangleByNode(node) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function annotationPoints(
|
|
||||||
board: PlaitBoard,
|
|
||||||
annotation: MindAnnotation
|
|
||||||
): Point[] | null {
|
|
||||||
const topics = getTopics(board);
|
|
||||||
if (annotation.kind === "relationship") {
|
|
||||||
const source = topics.get(annotation.sourceId);
|
|
||||||
const target = topics.get(annotation.targetId);
|
|
||||||
if (!source || !target) return null;
|
|
||||||
const a = rectangle(source.visible);
|
|
||||||
const b = rectangle(target.visible);
|
|
||||||
return a && b ? getRelationshipPoints(a, b) : null;
|
|
||||||
}
|
|
||||||
const rectangles: TopicRectangle[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const collect = (element: MindElement) => {
|
|
||||||
if (seen.has(element.id)) return;
|
|
||||||
seen.add(element.id);
|
|
||||||
const rect = rectangle(element);
|
|
||||||
if (rect) rectangles.push(rect);
|
|
||||||
if (!element.isCollapsed) element.children?.forEach(collect);
|
|
||||||
};
|
|
||||||
for (const id of annotation.topicIds) {
|
|
||||||
const topic = topics.get(id);
|
|
||||||
if (topic) collect(topic.visible);
|
|
||||||
}
|
|
||||||
return getBoundaryPoints(rectangles, annotation.padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function insertMindAnnotation(
|
|
||||||
board: PlaitBoard,
|
|
||||||
annotation: MindAnnotation
|
|
||||||
) {
|
|
||||||
const points = annotationPoints(board, annotation);
|
|
||||||
if (!points) return null;
|
|
||||||
const endpoints: [Point, Point] = [points[0], points[points.length - 1]];
|
|
||||||
const label = { type: "paragraph", children: [{ text: "联系" }] };
|
|
||||||
const shape =
|
|
||||||
annotation.kind === "relationship"
|
|
||||||
? createArrowLineElement(
|
|
||||||
ArrowLineShape.curve,
|
|
||||||
endpoints,
|
|
||||||
{ marker: ArrowLineMarkerType.none },
|
|
||||||
{ marker: ArrowLineMarkerType.arrow },
|
|
||||||
[{ position: 0.5, text: label }],
|
|
||||||
{ strokeColor: "#64748b", strokeWidth: 2 }
|
|
||||||
)
|
|
||||||
: createGeometryElementWithoutText(
|
|
||||||
BasicShapes.roundRectangle,
|
|
||||||
endpoints,
|
|
||||||
{
|
|
||||||
fill: "transparent",
|
|
||||||
strokeColor: "#8b9bb8",
|
|
||||||
strokeWidth: 2,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const element: AnnotatedElement = {
|
|
||||||
...shape,
|
|
||||||
points,
|
|
||||||
mindAnnotation: annotation,
|
|
||||||
};
|
|
||||||
PlaitHistoryBoard.withNewBatch(board, () => {
|
|
||||||
// Boundaries sit behind topics, so clicking within them still selects topics.
|
|
||||||
Transforms.insertNode(board, element, [
|
|
||||||
annotation.kind === "boundary" ? 0 : board.children.length,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
BoardTransforms.updatePointerType(board, PlaitPointerType.selection);
|
|
||||||
clearSelectedElement(board);
|
|
||||||
addSelectedElement(board, element);
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
const installed = new WeakSet<PlaitBoard>();
|
|
||||||
|
|
||||||
/** Keep native drawable shapes linked to topic IDs, using the existing undo stack. */
|
|
||||||
export function withMindAnnotations(board: PlaitBoard) {
|
|
||||||
if (installed.has(board)) return;
|
|
||||||
installed.add(board);
|
|
||||||
const { afterChange, undo, redo, apply } = board;
|
|
||||||
board.apply = (operation) => {
|
|
||||||
// Plait keeps a hover reference after deleting or collapsing a topic. Clear it
|
|
||||||
// while the node's DOM still exists, otherwise the next pointerLeave throws.
|
|
||||||
if (
|
|
||||||
operation.type === "remove_node" ||
|
|
||||||
(operation.type === "set_node" &&
|
|
||||||
("isCollapsed" in operation.newProperties ||
|
|
||||||
"children" in operation.newProperties))
|
|
||||||
) {
|
|
||||||
board.pointerLeave(new PointerEvent("pointerleave"));
|
|
||||||
}
|
|
||||||
apply(operation);
|
|
||||||
};
|
|
||||||
let pending = false;
|
|
||||||
let replaying = true;
|
|
||||||
const synchronize = () => {
|
|
||||||
pending = false;
|
|
||||||
const replay = replaying;
|
|
||||||
replaying = false;
|
|
||||||
const topics = getTopics(board);
|
|
||||||
const changes = board.children
|
|
||||||
.filter(isMindAnnotation)
|
|
||||||
.flatMap((element) => {
|
|
||||||
const annotation = element.mindAnnotation;
|
|
||||||
const missing =
|
|
||||||
annotation.kind === "relationship"
|
|
||||||
? !topics.has(annotation.sourceId) ||
|
|
||||||
!topics.has(annotation.targetId)
|
|
||||||
: !annotation.topicIds.some((id) => topics.has(id));
|
|
||||||
const points = missing ? null : annotationPoints(board, annotation);
|
|
||||||
if (
|
|
||||||
!missing &&
|
|
||||||
(!points || JSON.stringify(points) === JSON.stringify(element.points))
|
|
||||||
)
|
|
||||||
return [];
|
|
||||||
return [{ element, points, missing }];
|
|
||||||
});
|
|
||||||
if (!changes.length) return;
|
|
||||||
const update = () => {
|
|
||||||
for (const { element, points, missing } of changes) {
|
|
||||||
const index = board.children.findIndex(
|
|
||||||
(child) => child.id === element.id
|
|
||||||
);
|
|
||||||
if (index < 0) continue;
|
|
||||||
if (missing) Transforms.removeNode(board, [index]);
|
|
||||||
else if (points) Transforms.setNode(board, { points }, [index]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (replay) PlaitHistoryBoard.withoutSaving(board, update);
|
|
||||||
else PlaitHistoryBoard.withMerging(board, update);
|
|
||||||
};
|
|
||||||
board.undo = () => {
|
|
||||||
replaying = board.history.undos.length > 0;
|
|
||||||
undo();
|
|
||||||
};
|
|
||||||
board.redo = () => {
|
|
||||||
replaying = board.history.redos.length > 0;
|
|
||||||
redo();
|
|
||||||
};
|
|
||||||
board.afterChange = () => {
|
|
||||||
afterChange();
|
|
||||||
if (
|
|
||||||
!pending &&
|
|
||||||
board.operations.some((op) => op.type !== "set_selection")
|
|
||||||
) {
|
|
||||||
pending = true;
|
|
||||||
queueMicrotask(synchronize);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
queueMicrotask(synchronize);
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import type { PlaitBoard } from "@plait/core";
|
|
||||||
import type { withEmoji } from "@plait/mind";
|
|
||||||
import { getXmindMarker } from "@/lib/flow-board/xmind-markers";
|
|
||||||
|
|
||||||
/** Use the native marker artwork in Plait's existing emoji geometry and hit targets. */
|
|
||||||
export function withXmindMarkers(board: PlaitBoard) {
|
|
||||||
const markerBoard = board as ReturnType<typeof withEmoji>;
|
|
||||||
const renderEmoji = markerBoard.renderEmoji;
|
|
||||||
markerBoard.renderEmoji = (container, props) => {
|
|
||||||
const marker = getXmindMarker(props.emojiItem.name);
|
|
||||||
if (!marker) return renderEmoji(container, props);
|
|
||||||
const image = document.createElement("img");
|
|
||||||
image.className = "mind-node-emoji xmind-topic-marker";
|
|
||||||
image.draggable = false;
|
|
||||||
let currentProps = props;
|
|
||||||
const update = (next: Partial<typeof props>) => {
|
|
||||||
currentProps = { ...currentProps, ...next };
|
|
||||||
const current = getXmindMarker(currentProps.emojiItem.name);
|
|
||||||
image.src = current?.asset || marker.asset;
|
|
||||||
image.alt = current?.name || marker.name;
|
|
||||||
image.dataset.markerId = current?.id || marker.id;
|
|
||||||
image.style.width = `${currentProps.fontSize}px`;
|
|
||||||
image.style.height = `${currentProps.fontSize}px`;
|
|
||||||
};
|
|
||||||
update(props);
|
|
||||||
container.append(image);
|
|
||||||
return { update, destroy: () => image.remove() };
|
|
||||||
};
|
|
||||||
return board;
|
|
||||||
}
|
|
||||||
@ -1,506 +0,0 @@
|
|||||||
import type { PlaitBoard } from "@plait/core";
|
|
||||||
import {
|
|
||||||
MindElement,
|
|
||||||
MindNodeComponent,
|
|
||||||
PlaitMind,
|
|
||||||
PlaitMindComponent,
|
|
||||||
getBranchColorByMindElement,
|
|
||||||
getBranchWidthByMindElement,
|
|
||||||
type MindNode,
|
|
||||||
} from "@plait/mind";
|
|
||||||
import {
|
|
||||||
arrangeSpecialMind,
|
|
||||||
isSpecialMindLayout,
|
|
||||||
isSummaryNode,
|
|
||||||
specialNodeRect,
|
|
||||||
specialSubtreeBounds,
|
|
||||||
} from "@/lib/flow-board/mind-special-layout";
|
|
||||||
|
|
||||||
import { measureXmindTopics } from "@/lib/flow-board/xmind-typography";
|
|
||||||
|
|
||||||
const ns = "http://www.w3.org/2000/svg";
|
|
||||||
type TableCell = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
depth: number;
|
|
||||||
branch: number;
|
|
||||||
};
|
|
||||||
type TableGrid = {
|
|
||||||
kind: "spreadsheet";
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
columns: string[];
|
|
||||||
headerHeight: number;
|
|
||||||
columnHeaderHeight: number;
|
|
||||||
rowHeaderWidth: number;
|
|
||||||
columnWidth: number;
|
|
||||||
rows: { y: number; height: number; branch: number }[];
|
|
||||||
};
|
|
||||||
const tableLayouts = new Set(["treeTable", "treeTableSide", "spreadsheet"]);
|
|
||||||
const tableTopicState = new WeakMap<
|
|
||||||
SVGGElement,
|
|
||||||
{
|
|
||||||
foreignObject: SVGForeignObjectElement;
|
|
||||||
x: string | null;
|
|
||||||
y: string | null;
|
|
||||||
width: string | null;
|
|
||||||
height: string | null;
|
|
||||||
textStyle: string | null;
|
|
||||||
contentStyle: string | null;
|
|
||||||
shape: SVGElement | null;
|
|
||||||
shapeVisibility: string;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
function ensureTreeTableStyles() {
|
|
||||||
const id = "xmind-tree-table-topic-styles";
|
|
||||||
if (document.getElementById(id)) return;
|
|
||||||
const style = document.createElement("style");
|
|
||||||
style.id = id;
|
|
||||||
style.textContent = `
|
|
||||||
[data-special-table-topic] .plait-text-container {
|
|
||||||
align-items: center !important;
|
|
||||||
display: flex !important;
|
|
||||||
height: 100% !important;
|
|
||||||
justify-content: center !important;
|
|
||||||
overflow-wrap: normal !important;
|
|
||||||
text-align: center !important;
|
|
||||||
white-space: nowrap !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
[data-special-table-topic] .plait-text-container > [data-slate-node="element"] {
|
|
||||||
text-align: center !important;
|
|
||||||
white-space: nowrap !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
[data-special-table-topic="0"] .plait-text-container {
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.append(style);
|
|
||||||
}
|
|
||||||
function frameTreeTableTopic(
|
|
||||||
component: MindNodeComponent,
|
|
||||||
node: MindNode,
|
|
||||||
enabled: boolean
|
|
||||||
) {
|
|
||||||
const host = component.getElementG();
|
|
||||||
const saved = tableTopicState.get(host);
|
|
||||||
if (!enabled) {
|
|
||||||
delete host.dataset.specialTableTopic;
|
|
||||||
if (!saved) return;
|
|
||||||
for (const [name, value] of [
|
|
||||||
["x", saved.x],
|
|
||||||
["y", saved.y],
|
|
||||||
["width", saved.width],
|
|
||||||
["height", saved.height],
|
|
||||||
] as const) {
|
|
||||||
if (value === null) saved.foreignObject.removeAttribute(name);
|
|
||||||
else saved.foreignObject.setAttribute(name, value);
|
|
||||||
}
|
|
||||||
const text = saved.foreignObject.firstElementChild as HTMLElement | null;
|
|
||||||
if (text) {
|
|
||||||
if (saved.textStyle === null) text.removeAttribute("style");
|
|
||||||
else text.setAttribute("style", saved.textStyle);
|
|
||||||
const content = text.firstElementChild as HTMLElement | null;
|
|
||||||
if (content) {
|
|
||||||
if (saved.contentStyle === null) content.removeAttribute("style");
|
|
||||||
else content.setAttribute("style", saved.contentStyle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (saved.shape) saved.shape.style.visibility = saved.shapeVisibility;
|
|
||||||
tableTopicState.delete(host);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cell = (node as MindNode & { tableCell?: TableCell }).tableCell;
|
|
||||||
const foreignObject = host.querySelector<SVGForeignObjectElement>(
|
|
||||||
":scope > .text foreignObject"
|
|
||||||
);
|
|
||||||
if (!cell || !foreignObject) return;
|
|
||||||
ensureTreeTableStyles();
|
|
||||||
host.dataset.specialTableTopic = String(cell.depth);
|
|
||||||
const currentShape = (
|
|
||||||
component.nodeShapeGenerator as unknown as { g?: SVGGElement }
|
|
||||||
).g;
|
|
||||||
if (!saved) {
|
|
||||||
tableTopicState.set(host, {
|
|
||||||
foreignObject,
|
|
||||||
x: foreignObject.getAttribute("x"),
|
|
||||||
y: foreignObject.getAttribute("y"),
|
|
||||||
width: foreignObject.getAttribute("width"),
|
|
||||||
height: foreignObject.getAttribute("height"),
|
|
||||||
textStyle: foreignObject.firstElementChild?.getAttribute("style") ?? null,
|
|
||||||
contentStyle:
|
|
||||||
foreignObject.firstElementChild?.firstElementChild?.getAttribute(
|
|
||||||
"style"
|
|
||||||
) ?? null,
|
|
||||||
shape: currentShape || null,
|
|
||||||
shapeVisibility: currentShape?.style.visibility || "",
|
|
||||||
});
|
|
||||||
} else if (currentShape && currentShape !== saved.shape) {
|
|
||||||
saved.shape = currentShape;
|
|
||||||
saved.shapeVisibility = currentShape.style.visibility || "";
|
|
||||||
}
|
|
||||||
if (currentShape) currentShape.style.visibility = "hidden";
|
|
||||||
foreignObject.setAttribute("x", String(cell.x + 14));
|
|
||||||
foreignObject.setAttribute("y", String(cell.y + 8));
|
|
||||||
foreignObject.setAttribute("width", String(Math.max(1, cell.width - 28)));
|
|
||||||
foreignObject.setAttribute("height", String(Math.max(1, cell.height - 16)));
|
|
||||||
const text = foreignObject.firstElementChild as HTMLElement | null;
|
|
||||||
if (text) {
|
|
||||||
text.style.alignItems = "center";
|
|
||||||
text.style.color = cell.depth === 0 ? "#ffffff" : "#17252a";
|
|
||||||
text.style.display = "flex";
|
|
||||||
text.style.height = "100%";
|
|
||||||
text.style.justifyContent = "center";
|
|
||||||
text.style.overflowWrap = "normal";
|
|
||||||
text.style.textAlign = "center";
|
|
||||||
text.style.whiteSpace = "nowrap";
|
|
||||||
text.style.width = "100%";
|
|
||||||
const content = text.firstElementChild as HTMLElement | null;
|
|
||||||
if (content) {
|
|
||||||
content.style.alignItems = "center";
|
|
||||||
content.style.color = cell.depth === 0 ? "#ffffff" : "#17252a";
|
|
||||||
content.style.display = "flex";
|
|
||||||
content.style.height = "100%";
|
|
||||||
content.style.justifyContent = "center";
|
|
||||||
content.style.textAlign = "center";
|
|
||||||
content.style.whiteSpace = "nowrap";
|
|
||||||
content.style.width = "100%";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function specialLink(component: MindNodeComponent) {
|
|
||||||
const node = component.node;
|
|
||||||
let root: MindNode = node;
|
|
||||||
while (root.parent) root = root.parent;
|
|
||||||
const kind = root.origin.specialLayout;
|
|
||||||
const tableLayout = tableLayouts.has(String(kind));
|
|
||||||
frameTreeTableTopic(component, node, tableLayout);
|
|
||||||
if (tableLayout)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (component.getElementG().isConnected)
|
|
||||||
frameTreeTableTopic(component, node, true);
|
|
||||||
});
|
|
||||||
if (!isSpecialMindLayout(kind) || isSummaryNode(node)) return false;
|
|
||||||
if (
|
|
||||||
kind !== "brace" &&
|
|
||||||
kind !== "verticalTimeline" &&
|
|
||||||
!tableLayout &&
|
|
||||||
node !== root &&
|
|
||||||
node.parent !== root
|
|
||||||
)
|
|
||||||
return false;
|
|
||||||
component.linkLineG?.remove();
|
|
||||||
const group = document.createElementNS(ns, "g");
|
|
||||||
group.dataset.specialStructure = kind;
|
|
||||||
const color =
|
|
||||||
getBranchColorByMindElement(component.board, component.element) ||
|
|
||||||
"#64748b";
|
|
||||||
const width =
|
|
||||||
getBranchWidthByMindElement(component.board, component.element) || 2;
|
|
||||||
const path = (d: string) => {
|
|
||||||
const shape = document.createElementNS(ns, "path");
|
|
||||||
shape.setAttribute("d", d);
|
|
||||||
shape.setAttribute("fill", "none");
|
|
||||||
shape.setAttribute("stroke", color);
|
|
||||||
shape.setAttribute("stroke-width", String(width));
|
|
||||||
group.append(shape);
|
|
||||||
};
|
|
||||||
const blendWithWhite = (value: string, amount: number) => {
|
|
||||||
const match = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
|
|
||||||
if (!match) return value;
|
|
||||||
const channels = match.slice(1).map((channel) => Number.parseInt(channel, 16));
|
|
||||||
return `#${channels
|
|
||||||
.map((channel) =>
|
|
||||||
Math.round(channel + (255 - channel) * amount)
|
|
||||||
.toString(16)
|
|
||||||
.padStart(2, "0")
|
|
||||||
)
|
|
||||||
.join("")}`;
|
|
||||||
};
|
|
||||||
const solidColor = (value: unknown, fallback: string) => {
|
|
||||||
if (typeof value !== "string") return fallback;
|
|
||||||
const short = value.match(/^#([\da-f])([\da-f])([\da-f])$/i);
|
|
||||||
if (short) return `#${short.slice(1).map((channel) => channel + channel).join("")}`;
|
|
||||||
if (/^#[\da-f]{6}$/i.test(value)) return value;
|
|
||||||
const rgba = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
|
|
||||||
if (!rgba) return fallback;
|
|
||||||
const alpha = Number.parseInt(rgba[4], 16) / 255;
|
|
||||||
return `#${rgba
|
|
||||||
.slice(1, 4)
|
|
||||||
.map((channel) =>
|
|
||||||
Math.round(Number.parseInt(channel, 16) * alpha + 255 * (1 - alpha))
|
|
||||||
.toString(16)
|
|
||||||
.padStart(2, "0")
|
|
||||||
)
|
|
||||||
.join("")}`;
|
|
||||||
};
|
|
||||||
const rootRect = specialNodeRect(root);
|
|
||||||
const axisY = rootRect.y + rootRect.height / 2;
|
|
||||||
const branches = root.children.filter((child) => !isSummaryNode(child));
|
|
||||||
if (kind === "treeTable" || kind === "treeTableSide") {
|
|
||||||
if (node === root) {
|
|
||||||
group.setAttribute("pointer-events", "none");
|
|
||||||
const palette = ["#ffbc9f", "#d8ac8f", "#83c5be", "#f3c78e", "#8ab7d9"];
|
|
||||||
const tableNodes: MindNode[] = [];
|
|
||||||
const collect = (item: MindNode) => {
|
|
||||||
if (!isSummaryNode(item)) tableNodes.push(item);
|
|
||||||
item.children.forEach(collect);
|
|
||||||
};
|
|
||||||
collect(root);
|
|
||||||
const rootFill = solidColor(root.origin.fill, "#006d77");
|
|
||||||
tableNodes.forEach((item) => {
|
|
||||||
const cell = (item as typeof item & { tableCell?: TableCell }).tableCell;
|
|
||||||
if (!cell) return;
|
|
||||||
const rectangle = document.createElementNS(ns, "rect");
|
|
||||||
rectangle.dataset.specialTableCell = item.origin.id || "topic";
|
|
||||||
rectangle.dataset.depth = String(cell.depth);
|
|
||||||
rectangle.setAttribute("x", String(cell.x));
|
|
||||||
rectangle.setAttribute("y", String(cell.y));
|
|
||||||
rectangle.setAttribute("width", String(cell.width));
|
|
||||||
rectangle.setAttribute("height", String(cell.height));
|
|
||||||
const branchColor = palette[cell.branch % palette.length] || palette[0];
|
|
||||||
const explicit = solidColor(item.origin.fill, "");
|
|
||||||
rectangle.setAttribute(
|
|
||||||
"fill",
|
|
||||||
cell.depth === 0
|
|
||||||
? rootFill
|
|
||||||
: explicit ||
|
|
||||||
blendWithWhite(branchColor, cell.depth === 1 ? 0.08 : 0.72)
|
|
||||||
);
|
|
||||||
rectangle.setAttribute("stroke", rootFill);
|
|
||||||
rectangle.setAttribute("stroke-width", "2");
|
|
||||||
rectangle.setAttribute("vector-effect", "non-scaling-stroke");
|
|
||||||
group.append(rectangle);
|
|
||||||
});
|
|
||||||
group.dataset.specialTable = kind;
|
|
||||||
} else {
|
|
||||||
group.dataset.specialConnector = kind;
|
|
||||||
}
|
|
||||||
} else if (kind === "spreadsheet") {
|
|
||||||
if (node === root) {
|
|
||||||
group.setAttribute("pointer-events", "none");
|
|
||||||
const grid = (root as MindNode & { tableGrid?: TableGrid }).tableGrid;
|
|
||||||
if (grid) {
|
|
||||||
const palette = ["#ff9d8e", "#f6c75d", "#70c8af", "#68a7dd", "#9c8bd8"];
|
|
||||||
const rootFill = solidColor(root.origin.fill, "#1f2766");
|
|
||||||
const rectangle = (
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
fill: string,
|
|
||||||
dataset?: Record<string, string>
|
|
||||||
) => {
|
|
||||||
const rect = document.createElementNS(ns, "rect");
|
|
||||||
rect.setAttribute("x", String(x));
|
|
||||||
rect.setAttribute("y", String(y));
|
|
||||||
rect.setAttribute("width", String(width));
|
|
||||||
rect.setAttribute("height", String(height));
|
|
||||||
rect.setAttribute("fill", fill);
|
|
||||||
rect.setAttribute("stroke", rootFill);
|
|
||||||
rect.setAttribute("stroke-width", "2");
|
|
||||||
rect.setAttribute("vector-effect", "non-scaling-stroke");
|
|
||||||
Object.assign(rect.dataset, dataset);
|
|
||||||
group.append(rect);
|
|
||||||
};
|
|
||||||
rectangle(grid.x, grid.y, grid.width, grid.headerHeight, rootFill, {
|
|
||||||
specialTableCell: root.origin.id || "topic",
|
|
||||||
depth: "0",
|
|
||||||
});
|
|
||||||
rectangle(
|
|
||||||
grid.x,
|
|
||||||
grid.y + grid.headerHeight,
|
|
||||||
grid.rowHeaderWidth,
|
|
||||||
grid.columnHeaderHeight,
|
|
||||||
rootFill
|
|
||||||
);
|
|
||||||
grid.columns.forEach((column, index) => {
|
|
||||||
const x = grid.x + grid.rowHeaderWidth + index * grid.columnWidth;
|
|
||||||
const y = grid.y + grid.headerHeight;
|
|
||||||
rectangle(x, y, grid.columnWidth, grid.columnHeaderHeight, rootFill, {
|
|
||||||
specialMatrixColumn: column,
|
|
||||||
});
|
|
||||||
const label = document.createElementNS(ns, "text");
|
|
||||||
label.dataset.specialMatrixColumnLabel = column;
|
|
||||||
label.setAttribute("x", String(x + grid.columnWidth / 2));
|
|
||||||
label.setAttribute("y", String(y + grid.columnHeaderHeight / 2 + 5));
|
|
||||||
label.setAttribute("fill", "#ffffff");
|
|
||||||
label.setAttribute("font-size", "14");
|
|
||||||
label.setAttribute("font-weight", "600");
|
|
||||||
label.setAttribute("text-anchor", "middle");
|
|
||||||
label.textContent = column;
|
|
||||||
group.append(label);
|
|
||||||
});
|
|
||||||
grid.rows.forEach((row, rowIndex) => {
|
|
||||||
const rowNode = branches[rowIndex];
|
|
||||||
const rowColor = solidColor(
|
|
||||||
rowNode?.origin.fill,
|
|
||||||
palette[rowIndex % palette.length]
|
|
||||||
);
|
|
||||||
rectangle(grid.x, row.y, grid.rowHeaderWidth, row.height, rowColor, {
|
|
||||||
specialTableCell: rowNode?.origin.id || `row-${rowIndex}`,
|
|
||||||
depth: "1",
|
|
||||||
});
|
|
||||||
grid.columns.forEach((column, columnIndex) =>
|
|
||||||
rectangle(
|
|
||||||
grid.x + grid.rowHeaderWidth + columnIndex * grid.columnWidth,
|
|
||||||
row.y,
|
|
||||||
grid.columnWidth,
|
|
||||||
row.height,
|
|
||||||
"#ffffff",
|
|
||||||
{ specialMatrixCell: `${rowIndex}:${columnIndex}` }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
rowNode?.children
|
|
||||||
.filter((child) => !isSummaryNode(child))
|
|
||||||
.forEach((entry) => {
|
|
||||||
const cell = (entry as typeof entry & { tableCell?: TableCell }).tableCell;
|
|
||||||
if (!cell) return;
|
|
||||||
rectangle(cell.x, cell.y, cell.width, cell.height, blendWithWhite(rowColor, 0.2), {
|
|
||||||
specialMatrixEntry: entry.origin.id || "topic",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
group.dataset.specialTable = kind;
|
|
||||||
}
|
|
||||||
} else group.dataset.specialConnector = kind;
|
|
||||||
} else if (kind === "verticalTimeline") {
|
|
||||||
const axisX = rootRect.x + rootRect.width / 2;
|
|
||||||
if (node === root) {
|
|
||||||
if (branches.length) {
|
|
||||||
const end = Math.max(
|
|
||||||
...branches.map((child) => {
|
|
||||||
const rect = specialNodeRect(child);
|
|
||||||
return rect.y + rect.height / 2;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
path(`M${axisX} ${rootRect.y + rootRect.height}V${end}`);
|
|
||||||
group.dataset.specialAxis = kind;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const rect = specialNodeRect(node);
|
|
||||||
const right = rect.x > axisX;
|
|
||||||
const endX = right ? rect.x : rect.x + rect.width;
|
|
||||||
const endY = rect.y + rect.height / 2;
|
|
||||||
if (node.parent === root) {
|
|
||||||
path(`M${axisX} ${endY}H${endX}`);
|
|
||||||
} else if (node.parent) {
|
|
||||||
const parent = specialNodeRect(node.parent);
|
|
||||||
const startX = right ? parent.x + parent.width : parent.x;
|
|
||||||
const startY = parent.y + parent.height / 2;
|
|
||||||
const midX = (startX + endX) / 2;
|
|
||||||
path(
|
|
||||||
`M${startX} ${startY}C${midX} ${startY} ${midX} ${endY} ${endX} ${endY}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
group.dataset.specialConnector = kind;
|
|
||||||
}
|
|
||||||
} else if (kind === "brace") {
|
|
||||||
const parent = node.parent;
|
|
||||||
const siblings = parent?.children.filter((child) => !isSummaryNode(child));
|
|
||||||
if (parent && siblings?.[0] === node) {
|
|
||||||
const owner = specialNodeRect(parent);
|
|
||||||
const rects = siblings.map(specialNodeRect);
|
|
||||||
const top = Math.min(...rects.map((rect) => rect.y + rect.height / 2));
|
|
||||||
const bottom = Math.max(...rects.map((rect) => rect.y + rect.height / 2));
|
|
||||||
const middle = (top + bottom) / 2;
|
|
||||||
const right = Math.min(...rects.map((rect) => rect.x)) - 10;
|
|
||||||
const x = right - 18;
|
|
||||||
const left = Math.max(owner.x + owner.width + 6, x - 18);
|
|
||||||
const radius = Math.min(18, (bottom - top) / 4);
|
|
||||||
path(
|
|
||||||
top === bottom
|
|
||||||
? `M${left} ${top}H${right}`
|
|
||||||
: `M${right} ${top}Q${x} ${top} ${x} ${top + radius}V${middle - radius}Q${x} ${middle} ${left} ${middle}Q${x} ${middle} ${x} ${middle + radius}V${bottom - radius}Q${x} ${bottom} ${right} ${bottom}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (node === root) {
|
|
||||||
if (branches.length) {
|
|
||||||
const end =
|
|
||||||
Math.max(
|
|
||||||
...branches.map((child) => {
|
|
||||||
const rect = specialSubtreeBounds(child);
|
|
||||||
return rect.x + rect.width;
|
|
||||||
})
|
|
||||||
) + 32;
|
|
||||||
path(`M${rootRect.x + rootRect.width} ${axisY}H${end}`);
|
|
||||||
group.dataset.specialAxis = kind;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const rect = specialNodeRect(node);
|
|
||||||
const above = rect.y + rect.height / 2 < axisY;
|
|
||||||
const endX = rect.x + rect.width / 2;
|
|
||||||
const startX = endX - (kind === "fishbone" ? 48 : 0);
|
|
||||||
path(
|
|
||||||
`M${startX} ${axisY}L${endX} ${above ? rect.y + rect.height : rect.y}`
|
|
||||||
);
|
|
||||||
group.dataset.specialConnector = kind;
|
|
||||||
if (kind === "timeline") {
|
|
||||||
const circle = document.createElementNS(ns, "circle");
|
|
||||||
circle.setAttribute("cx", String(startX));
|
|
||||||
circle.setAttribute("cy", String(axisY));
|
|
||||||
circle.setAttribute("r", "4");
|
|
||||||
circle.setAttribute("fill", color);
|
|
||||||
group.append(circle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
component.linkLineG = group;
|
|
||||||
const host = component.getElementG();
|
|
||||||
if (tableLayout && node === root && host.parentElement)
|
|
||||||
host.parentElement.insertBefore(group, host.parentElement.firstChild);
|
|
||||||
else host.append(group);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
class SpecialMindRoot extends PlaitMindComponent {
|
|
||||||
override updateMindLayout(element = this.element) {
|
|
||||||
measureXmindTopics(this.board, element);
|
|
||||||
super.updateMindLayout(element);
|
|
||||||
if (isSpecialMindLayout(element.specialLayout))
|
|
||||||
arrangeSpecialMind(this.root, element.specialLayout);
|
|
||||||
}
|
|
||||||
override drawLink() {
|
|
||||||
if (!specialLink(this)) {
|
|
||||||
this.linkLineG?.remove();
|
|
||||||
this.linkLineG = undefined;
|
|
||||||
super.drawLink();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class SpecialMindNode extends MindNodeComponent {
|
|
||||||
private lastSpecialLayout: unknown;
|
|
||||||
private rootLayout() {
|
|
||||||
let root = this.node;
|
|
||||||
while (root.parent) root = root.parent;
|
|
||||||
return root.origin.specialLayout;
|
|
||||||
}
|
|
||||||
override onContextChanged(
|
|
||||||
...args: Parameters<MindNodeComponent["onContextChanged"]>
|
|
||||||
) {
|
|
||||||
super.onContextChanged(...args);
|
|
||||||
// A structure switch can leave descendant positions and parent identity unchanged.
|
|
||||||
if (this.rootLayout() !== this.lastSpecialLayout) this.drawLink();
|
|
||||||
}
|
|
||||||
override drawLink() {
|
|
||||||
if (!specialLink(this)) super.drawLink();
|
|
||||||
this.lastSpecialLayout = this.rootLayout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const installed = new WeakSet<PlaitBoard>();
|
|
||||||
export function withSpecialMindLayouts(board: PlaitBoard) {
|
|
||||||
if (installed.has(board)) return board;
|
|
||||||
installed.add(board);
|
|
||||||
const drawElement = board.drawElement;
|
|
||||||
board.drawElement = (context) => {
|
|
||||||
if (PlaitMind.isMind(context.element)) return SpecialMindRoot;
|
|
||||||
if (MindElement.isMindElement(board, context.element))
|
|
||||||
return SpecialMindNode;
|
|
||||||
return drawElement(context);
|
|
||||||
};
|
|
||||||
return board;
|
|
||||||
}
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Trash2, X } from "lucide-react";
|
|
||||||
import type { AnnotatedElement } from "@/lib/flow-board/mind-annotations";
|
|
||||||
|
|
||||||
export function XmindAnnotationPanel({
|
|
||||||
element,
|
|
||||||
onChange,
|
|
||||||
onDelete,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
element: AnnotatedElement;
|
|
||||||
onChange: (patch: Record<string, unknown>) => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const relationship = element.mindAnnotation.kind === "relationship";
|
|
||||||
const label = (element.texts?.[0]?.text?.children || [])
|
|
||||||
.map((leaf: { text?: string }) => leaf.text || "")
|
|
||||||
.join("");
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
className="xmind-aux-panel absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
|
|
||||||
data-flow-board-shell-control="true"
|
|
||||||
>
|
|
||||||
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">
|
|
||||||
<h2 className="text-sm font-semibold">
|
|
||||||
{relationship ? "联系格式" : "外框格式"}
|
|
||||||
</h2>
|
|
||||||
<button aria-label="关闭格式面板" onClick={onClose} type="button">
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-5 p-4 text-xs">
|
|
||||||
{relationship ? (
|
|
||||||
<label className="block space-y-2">
|
|
||||||
<span>联系文字</span>
|
|
||||||
<input
|
|
||||||
className="h-9 w-full rounded border border-[#dfe2e7] px-2 text-sm"
|
|
||||||
value={label}
|
|
||||||
onChange={(event) =>
|
|
||||||
onChange({
|
|
||||||
texts: event.target.value
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
position: 0.5,
|
|
||||||
text: {
|
|
||||||
type: "paragraph",
|
|
||||||
children: [{ text: event.target.value }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
) : (
|
|
||||||
<label className="flex items-center justify-between">
|
|
||||||
<span>外框留白</span>
|
|
||||||
<select
|
|
||||||
aria-label="外框留白"
|
|
||||||
className="h-8 rounded border border-[#dfe2e7] px-2"
|
|
||||||
value={
|
|
||||||
element.mindAnnotation.kind === "boundary"
|
|
||||||
? element.mindAnnotation.padding
|
|
||||||
: 18
|
|
||||||
}
|
|
||||||
onChange={(event) =>
|
|
||||||
onChange({
|
|
||||||
mindAnnotation: {
|
|
||||||
...element.mindAnnotation,
|
|
||||||
padding: Number(event.target.value),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{[12, 18, 24, 32].map((value) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{value} px
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label className="flex items-center justify-between">
|
|
||||||
<span>线条颜色</span>
|
|
||||||
<input
|
|
||||||
aria-label="联系或外框颜色"
|
|
||||||
className="h-7 w-12 cursor-pointer"
|
|
||||||
type="color"
|
|
||||||
value={element.strokeColor || "#64748b"}
|
|
||||||
onChange={(event) => onChange({ strokeColor: event.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center justify-between">
|
|
||||||
<span>线条粗细</span>
|
|
||||||
<select
|
|
||||||
aria-label="联系或外框粗细"
|
|
||||||
className="h-8 rounded border border-[#dfe2e7] px-2"
|
|
||||||
value={element.strokeWidth || 2}
|
|
||||||
onChange={(event) =>
|
|
||||||
onChange({ strokeWidth: Number(event.target.value) })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{[1, 2, 3, 4].map((value) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{value} pt
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<p className="leading-5 text-[#8b919c]">
|
|
||||||
{relationship
|
|
||||||
? "联系线会跟随主题移动。双击线上的文字也可以编辑。"
|
|
||||||
: "外框包含所选主题及其展开的子主题,随分支布局自动调整。"}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
className="flex h-9 w-full items-center justify-center gap-2 rounded border border-[#e5e7eb] text-[#b44343] hover:bg-red-50"
|
|
||||||
onClick={onDelete}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
删除{relationship ? "联系" : "外框"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
||||||
import { ChevronDown, type LucideIcon } from "lucide-react";
|
|
||||||
|
|
||||||
export type XmindCommand = {
|
|
||||||
label: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
shortcut?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
checked?: boolean;
|
|
||||||
separator?: boolean;
|
|
||||||
run: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function XmindCommandMenu({ label, children, commands, className = "", align = "left" }: {
|
|
||||||
label: string;
|
|
||||||
children: ReactNode;
|
|
||||||
commands: XmindCommand[];
|
|
||||||
className?: string;
|
|
||||||
align?: "left" | "right";
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const host = useRef<HTMLDivElement>(null);
|
|
||||||
const trigger = useRef<HTMLButtonElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const dismiss = (event: PointerEvent) => {
|
|
||||||
if (!host.current?.contains(event.target as Node)) setOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener("pointerdown", dismiss);
|
|
||||||
return () => document.removeEventListener("pointerdown", dismiss);
|
|
||||||
}, [open]);
|
|
||||||
return (
|
|
||||||
<div className={`xmind-menu-host ${className}`} ref={host} data-flow-board-shell-control="true">
|
|
||||||
<button type="button" ref={trigger} aria-label={label} aria-haspopup="menu" aria-expanded={open}
|
|
||||||
className="xmind-menu-trigger" onClick={() => setOpen(value => !value)}
|
|
||||||
onKeyDown={event => {
|
|
||||||
if (event.key === "ArrowDown") {
|
|
||||||
event.preventDefault();
|
|
||||||
setOpen(true);
|
|
||||||
requestAnimationFrame(() => host.current?.querySelector<HTMLButtonElement>("[role=menuitem]:not(:disabled)")?.focus());
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
{children}<ChevronDown size={12} />
|
|
||||||
</button>
|
|
||||||
{open ? <div role="menu" aria-label={label} className={`xmind-command-menu ${align === "right" ? "xmind-menu-right" : ""}`}
|
|
||||||
onKeyDown={event => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus();
|
|
||||||
}
|
|
||||||
const items = Array.from(host.current?.querySelectorAll<HTMLButtonElement>("[role=menuitem]:not(:disabled)") || []);
|
|
||||||
const index = items.indexOf(document.activeElement as HTMLButtonElement);
|
|
||||||
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
|
||||||
event.preventDefault();
|
|
||||||
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
|
|
||||||
items[next]?.focus();
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
{commands.map(({ label: title, icon: Icon, shortcut, disabled, checked, separator, run }) => <div key={title}>
|
|
||||||
{separator ? <hr className="xmind-menu-separator" /> : null}
|
|
||||||
<button type="button" role="menuitem" disabled={disabled} className="xmind-command" onClick={() => { setOpen(false); run(); }}>
|
|
||||||
{Icon ? <Icon size={16} /> : <span className="xmind-command-check">{checked ? "✓" : ""}</span>}
|
|
||||||
<span>{title}</span>{shortcut ? <kbd>{shortcut}</kbd> : null}
|
|
||||||
</button>
|
|
||||||
</div>)}
|
|
||||||
</div> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,470 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
CalendarDays,
|
|
||||||
ChartGantt,
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowUpRight,
|
|
||||||
Bold,
|
|
||||||
ChevronDown,
|
|
||||||
Copy,
|
|
||||||
FileText,
|
|
||||||
FolderOpen,
|
|
||||||
GitBranchPlus,
|
|
||||||
GitMerge,
|
|
||||||
Hand,
|
|
||||||
Image as ImageIcon,
|
|
||||||
Italic,
|
|
||||||
ListPlus,
|
|
||||||
Link2,
|
|
||||||
StickyNote,
|
|
||||||
Maximize,
|
|
||||||
Minus,
|
|
||||||
MousePointer2,
|
|
||||||
Network,
|
|
||||||
PanelRight,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Redo2,
|
|
||||||
Save,
|
|
||||||
Shapes,
|
|
||||||
Tag,
|
|
||||||
Trash2,
|
|
||||||
Type,
|
|
||||||
Underline,
|
|
||||||
Undo2,
|
|
||||||
Upload,
|
|
||||||
Download,
|
|
||||||
X,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { type ReactNode, useState } from "react";
|
|
||||||
import { Home, Smile, Scan, Search, MoreHorizontal, AlignLeft, AlignCenter, AlignRight, Presentation, SlidersHorizontal } from "lucide-react";
|
|
||||||
import { XmindCommandMenu } from "./xmind-command-menu";
|
|
||||||
|
|
||||||
export type FlowBoardToolId =
|
|
||||||
| "selection"
|
|
||||||
| "hand"
|
|
||||||
| "mind"
|
|
||||||
| "text"
|
|
||||||
| "arrow"
|
|
||||||
| "shape"
|
|
||||||
| "image";
|
|
||||||
|
|
||||||
export type FlowBoardSheetTab = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FlowBoardLayoutOption = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
preview:
|
|
||||||
| "both"
|
|
||||||
| "right"
|
|
||||||
| "left"
|
|
||||||
| "down"
|
|
||||||
| "up"
|
|
||||||
| "indent"
|
|
||||||
| "fishbone"
|
|
||||||
| "timeline"
|
|
||||||
| "brace"
|
|
||||||
| "verticalTimeline"
|
|
||||||
| "treeTable"
|
|
||||||
| "treeTableSide"
|
|
||||||
| "spreadsheet";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FlowBoardThemeOption = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
colors: [string, string, string];
|
|
||||||
};
|
|
||||||
|
|
||||||
function LayoutPreview({
|
|
||||||
direction,
|
|
||||||
}: {
|
|
||||||
direction: FlowBoardLayoutOption["preview"];
|
|
||||||
}) {
|
|
||||||
const horizontal = direction === "right" || direction === "left";
|
|
||||||
const vertical = direction === "down" || direction === "up";
|
|
||||||
const leaf = (x: number, y: number) => (
|
|
||||||
<rect key={`${x}-${y}`} x={x - 3} y={y - 3} width="6" height="6" fill="white" />
|
|
||||||
);
|
|
||||||
const root = (x: number, y: number) => (
|
|
||||||
<rect x={x - 4.5} y={y - 4.5} width="9" height="9" fill="currentColor" stroke="none" />
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<svg aria-hidden="true" className="block h-9 w-12 shrink-0" data-layout-preview={direction}
|
|
||||||
viewBox="0 0 48 36" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round">
|
|
||||||
{direction === "both" ? (
|
|
||||||
<>
|
|
||||||
<path d="M19.5 18H14V8H8M14 18V28H8M28.5 18H34V8H40M34 18V28H40" />
|
|
||||||
{root(24, 18)}
|
|
||||||
{[leaf(5, 8), leaf(5, 28), leaf(43, 8), leaf(43, 28)]}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{horizontal ? (
|
|
||||||
<g transform={direction === "left" ? "translate(48 0) scale(-1 1)" : undefined}>
|
|
||||||
<path d="M13.5 18H24V8H35M24 18V28H35" />
|
|
||||||
{root(9, 18)}
|
|
||||||
{[leaf(38, 8), leaf(38, 28)]}
|
|
||||||
</g>
|
|
||||||
) : null}
|
|
||||||
{vertical ? (
|
|
||||||
<g transform={direction === "up" ? "translate(0 36) scale(1 -1)" : undefined}>
|
|
||||||
<path d="M24 10.5V19M7 25V19H41V25M24 19V25" />
|
|
||||||
{root(24, 6)}
|
|
||||||
{[leaf(7, 28), leaf(24, 28), leaf(41, 28)]}
|
|
||||||
</g>
|
|
||||||
) : null}
|
|
||||||
{direction === "indent" ? (
|
|
||||||
<>
|
|
||||||
<path d="M9 10.5V30H27M9 18H27" />
|
|
||||||
{root(9, 6)}
|
|
||||||
{[leaf(30, 18), leaf(30, 30)]}
|
|
||||||
<path d="M34 18H44M34 30H44" />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{direction === "fishbone" || direction === "timeline" ? <>
|
|
||||||
{root(6, 18)}<path d="M10.5 18H46" />
|
|
||||||
<path d={direction === "fishbone" ? "M17 18L23 7M29 18L35 29M34 18L40 7" : "M20 18V7M29 18V29M40 18V7"} />
|
|
||||||
{[leaf(direction === "fishbone" ? 23 : 20, 5), leaf(direction === "fishbone" ? 35 : 29, 31), leaf(40, 5)]}
|
|
||||||
</> : null}
|
|
||||||
{direction === "verticalTimeline" ? <>
|
|
||||||
{root(24, 5)}<path d="M24 9.5V32M24 15H35M24 23H13M24 31H35" />
|
|
||||||
{[leaf(38, 15), leaf(10, 23), leaf(38, 31)]}
|
|
||||||
</> : null}
|
|
||||||
{direction === "treeTable" ? <>
|
|
||||||
<rect x="7" y="4" width="34" height="8" fill="currentColor" stroke="none" />
|
|
||||||
<path d="M7 12H41V32H7ZM7 22H41M25 12V32" />
|
|
||||||
</> : null}
|
|
||||||
{direction === "treeTableSide" ? <>
|
|
||||||
<rect x="5" y="5" width="10" height="26" fill="currentColor" stroke="none" />
|
|
||||||
<path d="M15 5H43V31H15ZM15 14H43M15 23H43M29 5V31" />
|
|
||||||
</> : null}
|
|
||||||
{direction === "spreadsheet" ? <>
|
|
||||||
<rect x="5" y="4" width="38" height="8" fill="currentColor" stroke="none" />
|
|
||||||
<path d="M5 12H43V32H5ZM5 19H43M5 25.5H43M16 12V32M25 12V32M34 12V32" />
|
|
||||||
<rect x="6" y="20" width="9" height="5" fill="currentColor" stroke="none" opacity=".55" />
|
|
||||||
</> : null}
|
|
||||||
{direction === "brace" ? <>{root(8,18)}<path d="M30 5Q23 5 23 11V13Q23 18 17 18Q23 18 23 23V25Q23 31 30 31" />{[leaf(37,5),leaf(37,18),leaf(37,31)]}</> : null}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XmindEditorTopBar({
|
|
||||||
activeTool, canEditMind, currentTitle, formatPanelOpen, markerPanelOpen, isSaving, onBack,
|
|
||||||
onImport, onExport, onExportImage, isExporting, onInsertChild, onInsertSibling,
|
|
||||||
onRelationship, onBoundary, relationshipPending, onOpenFiles, onRedo,
|
|
||||||
onSave, onSummary, onTopicDetails, onTitleChange, onTitleCommit,
|
|
||||||
onToggleFormat, onToggleMarkers, onToolChange, onUndo, siteName,
|
|
||||||
onSearch, onToggleZen, onToggleOutline, onPresentation, onTask, onGantt,
|
|
||||||
}: {
|
|
||||||
activeTool: FlowBoardToolId;
|
|
||||||
canEditMind: boolean;
|
|
||||||
currentTitle: string;
|
|
||||||
formatPanelOpen: boolean;
|
|
||||||
markerPanelOpen: boolean;
|
|
||||||
isSaving: boolean;
|
|
||||||
onBack: () => void;
|
|
||||||
onImport: () => void;
|
|
||||||
onExport: () => void;
|
|
||||||
onExportImage: () => void;
|
|
||||||
isExporting: boolean;
|
|
||||||
onInsertChild: () => void;
|
|
||||||
onInsertSibling: () => void;
|
|
||||||
onRelationship: () => void;
|
|
||||||
onBoundary: () => void;
|
|
||||||
relationshipPending: boolean;
|
|
||||||
onOpenFiles: () => void;
|
|
||||||
onRedo: () => void;
|
|
||||||
onSave: () => void;
|
|
||||||
onSummary: () => void;
|
|
||||||
onTopicDetails: (section: "notes" | "links" | "labels") => void;
|
|
||||||
onTitleChange: (value: string) => void;
|
|
||||||
onTitleCommit: () => void;
|
|
||||||
onToggleFormat: () => void;
|
|
||||||
onToggleMarkers: () => void;
|
|
||||||
onToolChange: (tool: FlowBoardToolId) => void;
|
|
||||||
onUndo: () => void;
|
|
||||||
siteLogo: string | null;
|
|
||||||
siteName: string;
|
|
||||||
onTask: () => void;
|
|
||||||
onGantt: () => void;
|
|
||||||
onSearch: () => void;
|
|
||||||
onToggleZen: () => void;
|
|
||||||
onToggleOutline: () => void;
|
|
||||||
onPresentation: () => void;
|
|
||||||
}) {
|
|
||||||
const nativeIcons: Record<string,string> = {"同级主题":"topic","子主题":"subtopic","联系":"relationship","概要":"summary","外框":"boundary","ZEN 模式":"zen","演示":"pitch","格式":"sidebar","标记":"marker","导出 XMind":"export"};
|
|
||||||
const action = (label: string, Icon: LucideIcon, run: () => void, disabled = false, active = false) => (
|
|
||||||
<button aria-label={label} title={label} type="button" disabled={disabled} aria-pressed={active}
|
|
||||||
className={`xmind-icon-button ${active ? "is-active" : ""}`} onClick={run}>{nativeIcons[label] ? <span aria-hidden="true" className="xmind-native-icon" style={{maskImage:`url(/icons/xmind/${nativeIcons[label]}.svg)`}} /> : <Icon size={20} strokeWidth={1.8} />}</button>
|
|
||||||
);
|
|
||||||
return <header className="xmind-topbar" data-flow-board-shell-control="true">
|
|
||||||
<div className="xmind-document-heading">
|
|
||||||
<button type="button" aria-label="返回首页" title={`返回 ${siteName}`} className="xmind-home-button" onClick={onBack}><Home size={21} strokeWidth={1.8} /></button>
|
|
||||||
<div className="xmind-document-name">
|
|
||||||
<input aria-label="白板标题" value={currentTitle} maxLength={256} onChange={e => onTitleChange(e.target.value)}
|
|
||||||
onBlur={onTitleCommit} onKeyDown={e => { if (e.key === "Enter") e.currentTarget.blur(); }} />
|
|
||||||
<span role="status">{isSaving ? "正在保存…" : "已编辑"}</span>
|
|
||||||
</div>
|
|
||||||
<XmindCommandMenu label="文件与编辑" className="xmind-document-menu" commands={[
|
|
||||||
{label:"保存",icon:Save,shortcut:"⌘S",run:onSave},
|
|
||||||
{label:"云文件",icon:FolderOpen,run:onOpenFiles},
|
|
||||||
{label:"导入",icon:Upload,run:onImport},
|
|
||||||
{label:"导出 XMind",icon:Download,disabled:isExporting,run:onExport},
|
|
||||||
{label:"导出图片",icon:ImageIcon,disabled:isExporting,run:onExportImage},
|
|
||||||
{label:"撤销",icon:Undo2,shortcut:"⌘Z",separator:true,run:onUndo},
|
|
||||||
{label:"重做",icon:Redo2,shortcut:"⇧⌘Z",run:onRedo},
|
|
||||||
{label:"甘特图",icon:ChartGantt,run:onGantt},
|
|
||||||
{label:"查找与替换",icon:Search,shortcut:"⌘F",separator:true,run:onSearch},
|
|
||||||
{label:"选择",icon:MousePointer2,checked:activeTool === "selection",separator:true,run:() => onToolChange("selection")},
|
|
||||||
{label:"画布",icon:Hand,checked:activeTool === "hand",run:() => onToolChange("hand")},
|
|
||||||
]}><MoreHorizontal size={17} /></XmindCommandMenu>
|
|
||||||
</div>
|
|
||||||
<nav className="xmind-pointer-toolbar" aria-label="画布工具">
|
|
||||||
<button type="button" aria-label="选择工具" title="选择 · V(空白处拖动框选)"
|
|
||||||
aria-pressed={activeTool === "selection"} className={`xmind-icon-button ${activeTool === "selection" ? "is-active" : ""}`} onClick={() => onToolChange("selection")}><MousePointer2 size={17} strokeWidth={1.8} /></button>
|
|
||||||
<button type="button" aria-label="拖动画布" title="拖动画布 · H(也可按住空格拖动)"
|
|
||||||
aria-pressed={activeTool === "hand"} className={`xmind-icon-button ${activeTool === "hand" ? "is-active" : ""}`} onClick={() => onToolChange("hand")}><Hand size={17} strokeWidth={1.8} /></button>
|
|
||||||
</nav>
|
|
||||||
<nav className="xmind-primary-tools" aria-label="主题工具">
|
|
||||||
<div className="xmind-toolbar-pill">
|
|
||||||
{action("同级主题", ListPlus, onInsertSibling, !canEditMind)}
|
|
||||||
{action("子主题", GitBranchPlus, onInsertChild, !canEditMind)}
|
|
||||||
</div>
|
|
||||||
<div className="xmind-toolbar-pill">
|
|
||||||
{action("联系", ArrowUpRight, onRelationship, !canEditMind && !relationshipPending, relationshipPending)}
|
|
||||||
{action("概要", GitMerge, onSummary, !canEditMind)}
|
|
||||||
{action("外框", Shapes, onBoundary, !canEditMind)}
|
|
||||||
<XmindCommandMenu label="插入" commands={[
|
|
||||||
{label:"主题",icon:Network,run:() => onToolChange("mind")},
|
|
||||||
{label:"笔记",icon:StickyNote,shortcut:"⇧⌘N",disabled:!canEditMind,separator:true,run:() => onTopicDetails("notes")},
|
|
||||||
{label:"任务",icon:CalendarDays,disabled:!canEditMind,run:onTask},
|
|
||||||
{label:"链接",icon:Link2,disabled:!canEditMind,run:() => onTopicDetails("links")},
|
|
||||||
{label:"标签",icon:Tag,disabled:!canEditMind,run:() => onTopicDetails("labels")},
|
|
||||||
{label:"图片",icon:ImageIcon,separator:true,run:() => onToolChange("image")},
|
|
||||||
{label:"文本",icon:Type,run:() => onToolChange("text")},
|
|
||||||
{label:"形状",icon:Shapes,run:() => onToolChange("shape")},
|
|
||||||
{label:"箭头",icon:ArrowUpRight,run:() => onToolChange("arrow")},
|
|
||||||
]}><Plus size={23} strokeWidth={1.7} /></XmindCommandMenu>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<div className="xmind-utility-tools">
|
|
||||||
<div className="xmind-toolbar-pill">{action("导出 XMind", Download, onExport, isExporting)}<XmindCommandMenu label="更多导出选项" align="right" commands={[
|
|
||||||
{label:"导出图片",icon:ImageIcon,disabled:isExporting,run:onExportImage},
|
|
||||||
{label:"导出 XMind",icon:Download,disabled:isExporting,run:onExport},
|
|
||||||
]}><span className="sr-only">导出</span></XmindCommandMenu></div>
|
|
||||||
<div className="xmind-toolbar-pill">
|
|
||||||
{action("ZEN 模式", Scan, onToggleZen)}
|
|
||||||
{action("大纲视图", FileText, onToggleOutline)}
|
|
||||||
{action("演示", Presentation, onPresentation)}
|
|
||||||
</div>
|
|
||||||
<div className="xmind-toolbar-pill">
|
|
||||||
{action("标记", Smile, onToggleMarkers, false, markerPanelOpen)}
|
|
||||||
{action("格式", PanelRight, onToggleFormat, false, formatPanelOpen)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XmindFormatPanel({
|
|
||||||
activeLayoutId,
|
|
||||||
activeThemeId,
|
|
||||||
branchColor,
|
|
||||||
branchShape,
|
|
||||||
branchWidth,
|
|
||||||
canvasBackgroundColor,
|
|
||||||
canvasGridColor,
|
|
||||||
canvasGridEnabled,
|
|
||||||
canvasGridSize,
|
|
||||||
fontSize,
|
|
||||||
isBold,
|
|
||||||
isItalic,
|
|
||||||
isUnderlined,
|
|
||||||
layouts,
|
|
||||||
nodeFill,
|
|
||||||
nodeShape,
|
|
||||||
onBranchColorChange,
|
|
||||||
onBranchShapeChange,
|
|
||||||
onBranchWidthChange,
|
|
||||||
onCanvasBackgroundColorChange,
|
|
||||||
onCanvasGridColorChange,
|
|
||||||
onCanvasGridEnabledChange,
|
|
||||||
onCanvasGridSizeChange,
|
|
||||||
onClose,
|
|
||||||
onFontSizeChange,
|
|
||||||
onLayoutChange,
|
|
||||||
onNodeFillChange,
|
|
||||||
onNodeShapeChange,
|
|
||||||
onTextColorChange,
|
|
||||||
onThemeChange,
|
|
||||||
onToggleTextMark,
|
|
||||||
selectedCount,
|
|
||||||
textColor,
|
|
||||||
themes,
|
|
||||||
}: {
|
|
||||||
activeLayoutId: string;
|
|
||||||
activeThemeId: string;
|
|
||||||
branchColor: string;
|
|
||||||
branchShape: "bight" | "polyline";
|
|
||||||
branchWidth: number;
|
|
||||||
canvasBackgroundColor: string;
|
|
||||||
canvasGridColor: string;
|
|
||||||
canvasGridEnabled: boolean;
|
|
||||||
canvasGridSize: number;
|
|
||||||
fontSize: string;
|
|
||||||
isBold: boolean;
|
|
||||||
isItalic: boolean;
|
|
||||||
isUnderlined: boolean;
|
|
||||||
layouts: FlowBoardLayoutOption[];
|
|
||||||
nodeFill: string;
|
|
||||||
nodeShape: "round-rectangle" | "underline";
|
|
||||||
onBranchColorChange: (value: string) => void;
|
|
||||||
onBranchShapeChange: (value: "bight" | "polyline") => void;
|
|
||||||
onBranchWidthChange: (value: number) => void;
|
|
||||||
onCanvasBackgroundColorChange: (value: string) => void;
|
|
||||||
onCanvasGridColorChange: (value: string) => void;
|
|
||||||
onCanvasGridEnabledChange: (value: boolean) => void;
|
|
||||||
onCanvasGridSizeChange: (value: number) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onFontSizeChange: (value: string) => void;
|
|
||||||
onLayoutChange: (value: string) => void;
|
|
||||||
onNodeFillChange: (value: string) => void;
|
|
||||||
onNodeShapeChange: (value: "round-rectangle" | "underline") => void;
|
|
||||||
onTextColorChange: (value: string) => void;
|
|
||||||
onThemeChange: (value: string) => void;
|
|
||||||
onToggleTextMark: (mark: "bold" | "italic" | "underlined") => void;
|
|
||||||
selectedCount: number;
|
|
||||||
textColor: string;
|
|
||||||
themes: FlowBoardThemeOption[];
|
|
||||||
}) {
|
|
||||||
const [activePanel, setActivePanel] = useState<"format" | "canvas">("format");
|
|
||||||
const [structuresOpen, setStructuresOpen] = useState(false);
|
|
||||||
const activeLayout = layouts.find(layout => layout.id === activeLayoutId) || layouts[0];
|
|
||||||
const colorControl = (label: string, value: string, change: (value: string) => void, disabled = false) => (
|
|
||||||
<input type="color" aria-label={label} value={/^#[0-9a-f]{6}$/i.test(value) ? value : "#ffffff"}
|
|
||||||
disabled={disabled} onChange={e => change(e.target.value)} className="xmind-color-control" />
|
|
||||||
);
|
|
||||||
return <aside className="xmind-format-panel" aria-label="格式面板" data-flow-board-shell-control="true">
|
|
||||||
<div className="xmind-panel-heading"><span>格式</span><button type="button" aria-label="关闭格式面板" onClick={onClose}><X size={16} /></button></div>
|
|
||||||
<div className="xmind-panel-tabs" role="tablist" aria-label="格式分类">
|
|
||||||
<button type="button" role="tab" aria-selected={activePanel === "format"} onClick={() => setActivePanel("format")}>样式</button>
|
|
||||||
<button type="button" role="tab" aria-selected={activePanel === "canvas"} onClick={() => setActivePanel("canvas")}>画布</button>
|
|
||||||
</div>
|
|
||||||
<div className="xmind-panel-content">
|
|
||||||
{activePanel === "format" ? <>
|
|
||||||
<div className="xmind-topic-preview"><span style={{ backgroundColor: nodeFill, color: textColor,
|
|
||||||
fontWeight: isBold ? 700 : 500, fontStyle: isItalic ? "italic" : "normal", borderRadius: nodeShape === "underline" ? 0 : 8 }}>{selectedCount ? "分支主题" : "中心主题"}</span><ChevronDown size={14} /></div>
|
|
||||||
<section className="xmind-format-section">
|
|
||||||
<h3><ChevronDown size={13} />形状</h3>
|
|
||||||
<div className="xmind-segmented">
|
|
||||||
<button type="button" aria-label="圆角矩形节点" aria-pressed={nodeShape === "round-rectangle"} disabled={!selectedCount} onClick={() => onNodeShapeChange("round-rectangle")}><span className="xmind-shape-rectangle" /></button>
|
|
||||||
<button type="button" aria-label="下划线节点" aria-pressed={nodeShape === "underline"} disabled={!selectedCount} onClick={() => onNodeShapeChange("underline")}><span className="xmind-shape-line" /></button>
|
|
||||||
</div>
|
|
||||||
<label className="xmind-setting-row"><span>填充</span>{colorControl("节点填充颜色", nodeFill, onNodeFillChange, !selectedCount)}</label>
|
|
||||||
</section>
|
|
||||||
<section className="xmind-format-section">
|
|
||||||
<h3><ChevronDown size={13} />文本</h3>
|
|
||||||
<label className="xmind-setting-row"><span>字号</span><select aria-label="节点字号" value={fontSize} disabled={!selectedCount} onChange={e => onFontSizeChange(e.target.value)}>
|
|
||||||
{Array.from(new Set([12,13,14,15,16,18,20,24,28,30,32,40,48,Number(fontSize)].filter(size => size > 0))).sort((a,b) => a-b).map(size => <option value={size} key={size}>{size}</option>)}
|
|
||||||
</select></label>
|
|
||||||
<label className="xmind-setting-row"><span>颜色</span>{colorControl("文字颜色", textColor, onTextColorChange, !selectedCount)}</label>
|
|
||||||
<div className="xmind-segmented xmind-text-marks">
|
|
||||||
{[{icon:Bold,id:"bold" as const,label:"粗体",active:isBold},{icon:Italic,id:"italic" as const,label:"斜体",active:isItalic},{icon:Underline,id:"underlined" as const,label:"下划线",active:isUnderlined}].map(({icon:Icon,id,label,active}) =>
|
|
||||||
<button type="button" key={id} aria-label={label} aria-pressed={active} disabled={!selectedCount} onClick={() => onToggleTextMark(id)}><Icon size={18} /></button>)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="xmind-format-section">
|
|
||||||
<div className="xmind-setting-row"><h3><ChevronDown size={13} />结构</h3><button type="button" aria-label="选择结构" aria-expanded={structuresOpen} className="xmind-structure-trigger" onClick={() => setStructuresOpen(value => !value)}><LayoutPreview direction={activeLayout.preview} /><ChevronDown size={12} /></button></div>
|
|
||||||
<div className="xmind-current-structure">{activeLayout.label}</div>
|
|
||||||
{structuresOpen ? <div className="xmind-structure-grid">
|
|
||||||
{layouts.map(layout => <button type="button" key={layout.id} aria-label={layout.label} aria-pressed={activeLayoutId === layout.id}
|
|
||||||
onClick={() => { onLayoutChange(layout.id); setStructuresOpen(false); }}><LayoutPreview direction={layout.preview} /><span>{layout.label}</span></button>)}
|
|
||||||
</div> : null}
|
|
||||||
</section>
|
|
||||||
<section className="xmind-format-section">
|
|
||||||
<h3><ChevronDown size={13} />分支</h3>
|
|
||||||
<label className="xmind-setting-row"><span>线条</span><select aria-label="分支形状" value={branchShape} disabled={!selectedCount} onChange={e => onBranchShapeChange(e.target.value as "bight" | "polyline")}><option value="bight">曲线</option><option value="polyline">折线</option></select></label>
|
|
||||||
<label className="xmind-setting-row"><span>粗细</span><select aria-label="分支粗细" value={branchWidth} disabled={!selectedCount} onChange={e => onBranchWidthChange(Number(e.target.value))}>{[1,2,3,4,5,6,8].map(width => <option key={width} value={width}>{width} px</option>)}</select></label>
|
|
||||||
<label className="xmind-setting-row"><span>颜色</span>{colorControl("分支颜色", branchColor, onBranchColorChange, !selectedCount)}</label>
|
|
||||||
</section>
|
|
||||||
</> : <>
|
|
||||||
<section className="xmind-format-section">
|
|
||||||
<h3><ChevronDown size={13} />背景</h3>
|
|
||||||
<div className="xmind-background-presets">{["#ffffff","#f7f8fb","#f1f5f9","#fff8e7","#eef8f1","#f5f0ff"].map(color => <button type="button" key={color} aria-label={`画布背景 ${color}`} aria-pressed={color === canvasBackgroundColor} style={{backgroundColor:color}} onClick={() => onCanvasBackgroundColorChange(color)} />)}</div>
|
|
||||||
<label className="xmind-setting-row"><span>自定义颜色</span>{colorControl("画布背景颜色",canvasBackgroundColor,onCanvasBackgroundColorChange)}</label>
|
|
||||||
</section>
|
|
||||||
<section className="xmind-format-section"><h3><ChevronDown size={13} />彩虹色</h3><div className="xmind-theme-grid">{themes.map(theme => <button type="button" key={theme.id} aria-label={theme.label} aria-pressed={theme.id === activeThemeId} onClick={() => onThemeChange(theme.id)}><span>{theme.colors.map(color => <i key={color} style={{backgroundColor:color}} />)}</span>{theme.label}</button>)}</div></section>
|
|
||||||
<section className="xmind-format-section">
|
|
||||||
<div className="xmind-setting-row"><h3><ChevronDown size={13} />网格</h3><button type="button" role="switch" aria-label="显示画布网格" aria-checked={canvasGridEnabled} className="xmind-switch" onClick={() => onCanvasGridEnabledChange(!canvasGridEnabled)}><i /></button></div>
|
|
||||||
<label className="xmind-setting-row"><span>网格颜色</span>{colorControl("网格颜色",canvasGridColor,onCanvasGridColorChange,!canvasGridEnabled)}</label>
|
|
||||||
<label className="xmind-setting-row"><span>网格间距</span><select aria-label="网格间距" value={canvasGridSize} disabled={!canvasGridEnabled} onChange={e => onCanvasGridSizeChange(Number(e.target.value))}>{[8,16,24,32,40,48,64].map(size => <option key={size} value={size}>{size} px</option>)}</select></label>
|
|
||||||
<div aria-label="画布样式预览" className="xmind-canvas-preview" style={{backgroundColor:canvasBackgroundColor,backgroundImage:canvasGridEnabled ? `radial-gradient(${canvasGridColor} 1px, transparent 1px)` : "none",backgroundSize:`${canvasGridSize}px ${canvasGridSize}px`}} />
|
|
||||||
</section>
|
|
||||||
</>}
|
|
||||||
</div>
|
|
||||||
</aside>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XmindSheetBar({
|
|
||||||
outlineOpen, onToggleOutline, activeSheetId, formatPanelOpen, onAddSheet,
|
|
||||||
onDeleteSheet, onDuplicateSheet, onFit, onRenameSheet, onSwitchSheet,
|
|
||||||
onZoomIn, onZoomOut, onZoomTo, sheets, zoomPercent, topicCount, selectedCount,
|
|
||||||
}: {
|
|
||||||
outlineOpen: boolean;
|
|
||||||
onToggleOutline: () => void;
|
|
||||||
activeSheetId: string;
|
|
||||||
formatPanelOpen: boolean;
|
|
||||||
onAddSheet: () => void;
|
|
||||||
onDeleteSheet: (id: string) => void;
|
|
||||||
onDuplicateSheet: (id: string) => void;
|
|
||||||
onFit: () => void;
|
|
||||||
onRenameSheet: (id: string, title: string) => void;
|
|
||||||
onSwitchSheet: (id: string) => void;
|
|
||||||
onZoomIn: () => void;
|
|
||||||
onZoomOut: () => void;
|
|
||||||
onZoomTo: (value: number) => void;
|
|
||||||
sheets: FlowBoardSheetTab[];
|
|
||||||
zoomPercent: number;
|
|
||||||
topicCount: number;
|
|
||||||
selectedCount: number;
|
|
||||||
}) {
|
|
||||||
const [renamingSheetId, setRenamingSheetId] = useState<string | null>(null);
|
|
||||||
const [renameDraft, setRenameDraft] = useState("");
|
|
||||||
const [contextId, setContextId] = useState<string | null>(null);
|
|
||||||
const activeSheet = sheets.find(sheet => sheet.id === activeSheetId);
|
|
||||||
const rename = (id: string) => { const sheet = sheets.find(item => item.id === id); if (sheet) { setRenamingSheetId(id); setRenameDraft(sheet.title); } };
|
|
||||||
const commitRename = () => { if (renamingSheetId && renameDraft.trim()) onRenameSheet(renamingSheetId,renameDraft.trim()); setRenamingSheetId(null); };
|
|
||||||
return <footer className="xmind-sheetbar" data-flow-board-shell-control="true">
|
|
||||||
<div className="xmind-sheet-tabs" role="tablist" aria-label="工作表">
|
|
||||||
{sheets.map(sheet => <div className="xmind-sheet-tab-host" key={sheet.id}>
|
|
||||||
{renamingSheetId === sheet.id ? <input aria-label="重命名画布" autoFocus value={renameDraft} maxLength={80} onChange={e => setRenameDraft(e.target.value)} onBlur={commitRename} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); commitRename(); } if (e.key === "Escape") { e.preventDefault(); setRenamingSheetId(null); } }} /> :
|
|
||||||
<button type="button" aria-label={sheet.title} aria-pressed={sheet.id === activeSheetId} onClick={() => onSwitchSheet(sheet.id)} onDoubleClick={() => rename(sheet.id)} onContextMenu={e => { e.preventDefault(); setContextId(contextId === sheet.id ? null : sheet.id); }}>{sheet.title}</button>}
|
|
||||||
{contextId === sheet.id ? <div className="xmind-sheet-context" data-flow-board-shell-control="true">
|
|
||||||
<button type="button" onClick={() => { rename(sheet.id); setContextId(null); }}>重命名</button>
|
|
||||||
<button type="button" onClick={() => { onDuplicateSheet(sheet.id); setContextId(null); }}>复制</button>
|
|
||||||
<button type="button" disabled={sheets.length <= 1} onClick={() => { onDeleteSheet(sheet.id); setContextId(null); }}>删除</button>
|
|
||||||
<button type="button" onClick={() => setContextId(null)}>关闭菜单</button>
|
|
||||||
</div> : null}
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
<span className="xmind-footer-divider" />
|
|
||||||
<button type="button" aria-label="新增画布" title="新增画布" className="xmind-footer-icon" onClick={onAddSheet}><Plus size={21} strokeWidth={1.5} /></button>
|
|
||||||
<XmindCommandMenu label="画布操作" className="xmind-sheet-actions" commands={[
|
|
||||||
{label:"重命名当前画布",icon:Pencil,disabled:!activeSheet,run:() => activeSheet && rename(activeSheet.id)},
|
|
||||||
{label:"复制当前画布",icon:Copy,disabled:!activeSheet,run:() => activeSheet && onDuplicateSheet(activeSheet.id)},
|
|
||||||
{label:"删除当前画布",icon:Trash2,disabled:sheets.length <= 1,run:() => activeSheet && onDeleteSheet(activeSheet.id)},
|
|
||||||
]}><MoreHorizontal size={17} /></XmindCommandMenu>
|
|
||||||
<div className="xmind-sheet-status"><span className="xmind-topic-count">主题: {selectedCount} / {topicCount}</span><span className="xmind-footer-divider" />
|
|
||||||
<XmindCommandMenu label="缩放" align="right" className="xmind-zoom-menu" commands={[
|
|
||||||
{label:"放大",icon:Plus,run:onZoomIn},{label:"缩小",icon:Minus,run:onZoomOut},
|
|
||||||
...[50,75,100,125,150,200].map(value => ({label:`${value}%`,checked:value === zoomPercent,separator:value === 50,run:() => onZoomTo(value)})),
|
|
||||||
{label:"适应画布",icon:Maximize,separator:true,run:onFit},
|
|
||||||
]}><span>{zoomPercent}%</span></XmindCommandMenu>
|
|
||||||
<span className="xmind-footer-divider" /><button type="button" aria-label={outlineOpen ? "切换到思维导图" : "切换到大纲"} aria-pressed={outlineOpen} className="xmind-outline-toggle" onClick={onToggleOutline}>{outlineOpen ? "思维导图" : "大纲"}</button>
|
|
||||||
</div>
|
|
||||||
</footer>;
|
|
||||||
}
|
|
||||||
@ -1,778 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import {
|
|
||||||
CalendarDays,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Download,
|
|
||||||
Link2,
|
|
||||||
Minus,
|
|
||||||
Plus,
|
|
||||||
Printer,
|
|
||||||
Search,
|
|
||||||
UserRound,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { PlaitElement } from "@plait/core";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
addDays,
|
|
||||||
dateIndex,
|
|
||||||
getTaskRows,
|
|
||||||
indexDate,
|
|
||||||
todayDate,
|
|
||||||
type TopicTask,
|
|
||||||
} from "@/lib/flow-board/topic-tasks";
|
|
||||||
import { getTopicEntries } from "@/lib/flow-board/topic-details";
|
|
||||||
import {
|
|
||||||
activeWorkingCalendar,
|
|
||||||
getProjectCalendar,
|
|
||||||
isWorkingDate,
|
|
||||||
preserveNonWorkingDue,
|
|
||||||
taskDueDate,
|
|
||||||
workingDaysBetween,
|
|
||||||
} from "@/lib/flow-board/project-calendar";
|
|
||||||
const ROW = 38,
|
|
||||||
HEADER = 64;
|
|
||||||
export function XmindGantt({
|
|
||||||
elements,
|
|
||||||
title,
|
|
||||||
onClose,
|
|
||||||
onEdit,
|
|
||||||
onCalendar,
|
|
||||||
onChange,
|
|
||||||
onUndo,
|
|
||||||
onRedo,
|
|
||||||
}: {
|
|
||||||
elements: PlaitElement[];
|
|
||||||
title: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onEdit: (id: string) => void;
|
|
||||||
onCalendar: () => void;
|
|
||||||
onChange: (id: string, task: TopicTask) => void;
|
|
||||||
onUndo: () => void;
|
|
||||||
onRedo: () => void;
|
|
||||||
}) {
|
|
||||||
const panelRef = useRef<HTMLElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const previous = document.activeElement as HTMLElement | null;
|
|
||||||
panelRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
|
|
||||||
return () => {
|
|
||||||
if (previous?.isConnected) previous.focus();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
const [scale, setScale] = useState(34),
|
|
||||||
[query, setQuery] = useState(""),
|
|
||||||
[assignee, setAssignee] = useState(""),
|
|
||||||
[status, setStatus] = useState("all");
|
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
|
||||||
const [drag, setDrag] = useState<{
|
|
||||||
id: string;
|
|
||||||
x: number;
|
|
||||||
kind: "move" | "start" | "due";
|
|
||||||
task: TopicTask;
|
|
||||||
days: number;
|
|
||||||
} | null>(null);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const rowScrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const projectCalendar = getProjectCalendar(elements);
|
|
||||||
const calendar = activeWorkingCalendar(projectCalendar);
|
|
||||||
const rows = getTaskRows(elements);
|
|
||||||
const visible = rows.filter(
|
|
||||||
(row) =>
|
|
||||||
row.title.toLocaleLowerCase().includes(query.toLocaleLowerCase()) &&
|
|
||||||
(!assignee || row.task.assignees.includes(assignee)) &&
|
|
||||||
(status === "all" ||
|
|
||||||
(status === "done" && row.task.progress === 100) ||
|
|
||||||
(status === "open" && row.task.progress < 100)) &&
|
|
||||||
!rows.some(
|
|
||||||
(parent) =>
|
|
||||||
collapsed.has(parent.element.id) &&
|
|
||||||
parent.path.length < row.path.length &&
|
|
||||||
parent.path.every((n, i) => row.path[i] === n)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const candidates = getTopicEntries(elements).filter(
|
|
||||||
(e) => !rows.some((r) => r.element.id === e.element.id)
|
|
||||||
);
|
|
||||||
const [candidate, setCandidate] = useState("");
|
|
||||||
const [notice, setNotice] = useState("");
|
|
||||||
const today = dateIndex(todayDate());
|
|
||||||
const dated = rows.filter((r) => r.task.start && r.task.due);
|
|
||||||
const from =
|
|
||||||
Math.min(today, ...dated.map((r) => dateIndex(r.task.start))) - 3;
|
|
||||||
const last =
|
|
||||||
Math.max(today + 20, ...dated.map((r) => dateIndex(r.task.due))) + 4;
|
|
||||||
// Very long plans use a virtual day grid; no thousands of day DOM nodes.
|
|
||||||
const width = Math.max(800, (last - from + 1) * scale);
|
|
||||||
const [scrollX, setScrollX] = useState(0),
|
|
||||||
[viewportWidth, setViewportWidth] = useState(1000);
|
|
||||||
const tickStep = scale >= 24 ? 1 : scale >= 10 ? 7 : 30;
|
|
||||||
const monthBands = useMemo(() => {
|
|
||||||
const start = from + Math.max(0, Math.floor(scrollX / scale) - 31),
|
|
||||||
end = Math.min(
|
|
||||||
last,
|
|
||||||
from + Math.ceil((scrollX + viewportWidth) / scale) + 31
|
|
||||||
);
|
|
||||||
const bands: { date: string; offset: number; days: number }[] = [];
|
|
||||||
for (let day = start; day <= end; ) {
|
|
||||||
const date = indexDate(day),
|
|
||||||
next = dateIndex(`${date.slice(0, 7)}-01`);
|
|
||||||
const d = new Date(next * 86_400_000);
|
|
||||||
d.setUTCMonth(d.getUTCMonth() + 1);
|
|
||||||
const monthEnd = d.getTime() / 86_400_000;
|
|
||||||
const bandStart = Math.max(from, next),
|
|
||||||
bandEnd = Math.min(last + 1, monthEnd);
|
|
||||||
bands.push({
|
|
||||||
date: date.slice(0, 7),
|
|
||||||
offset: bandStart - from,
|
|
||||||
days: bandEnd - bandStart,
|
|
||||||
});
|
|
||||||
day = monthEnd;
|
|
||||||
}
|
|
||||||
return bands;
|
|
||||||
}, [from, last, scrollX, scale, viewportWidth]);
|
|
||||||
const ticks = useMemo(() => {
|
|
||||||
const result: number[] = [];
|
|
||||||
const begin = Math.max(
|
|
||||||
0,
|
|
||||||
Math.floor(scrollX / scale / tickStep) * tickStep - tickStep
|
|
||||||
);
|
|
||||||
const end = Math.min(
|
|
||||||
last - from,
|
|
||||||
Math.ceil((scrollX + viewportWidth) / scale) + tickStep
|
|
||||||
);
|
|
||||||
for (let i = begin; i <= end; i += tickStep) result.push(i);
|
|
||||||
return result;
|
|
||||||
}, [scrollX, scale, tickStep, viewportWidth, last, from]);
|
|
||||||
const moveTask = (task: TopicTask, days: number) => {
|
|
||||||
const start = addDays(task.start, days);
|
|
||||||
return {
|
|
||||||
...task,
|
|
||||||
start,
|
|
||||||
due: preserveNonWorkingDue(
|
|
||||||
addDays(task.due, days),
|
|
||||||
taskDueDate(start, task.duration, calendar),
|
|
||||||
calendar
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const effective = (row: (typeof rows)[number]) => {
|
|
||||||
if (drag?.id !== row.element.id || !drag.days) return row.task;
|
|
||||||
const task = row.task;
|
|
||||||
if (drag.kind === "move") return moveTask(task, drag.days);
|
|
||||||
const start =
|
|
||||||
drag.kind === "start" ? addDays(task.start, drag.days) : task.start;
|
|
||||||
const due = drag.kind === "due" ? addDays(task.due, drag.days) : task.due;
|
|
||||||
return {
|
|
||||||
...task,
|
|
||||||
start,
|
|
||||||
due,
|
|
||||||
duration: Math.max(1, workingDaysBetween(start, due, calendar)),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const goToday = () =>
|
|
||||||
scrollRef.current?.scrollTo({
|
|
||||||
left: Math.max(0, (today - from) * scale - 200),
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
const downloadCsv = () => {
|
|
||||||
const quote = (v: unknown) => `"${String(v ?? "").replaceAll('"', '""')}"`;
|
|
||||||
const content =
|
|
||||||
"\ufeff" +
|
|
||||||
[
|
|
||||||
[
|
|
||||||
"任务名称",
|
|
||||||
"开始日期",
|
|
||||||
"截止日期",
|
|
||||||
"时长(天)",
|
|
||||||
"进度(%)",
|
|
||||||
"优先级",
|
|
||||||
"负责人",
|
|
||||||
"前置任务",
|
|
||||||
],
|
|
||||||
...visible.map((r) => [
|
|
||||||
r.title,
|
|
||||||
r.task.start,
|
|
||||||
r.task.due,
|
|
||||||
r.task.duration,
|
|
||||||
r.task.progress,
|
|
||||||
r.task.priority == null ? "" : `P${r.task.priority}`,
|
|
||||||
r.task.assignees.join(", "),
|
|
||||||
r.task.dependencies
|
|
||||||
.map(
|
|
||||||
(d) =>
|
|
||||||
`${rows.find((t) => t.element.id === d.id)?.title || d.id} ${d.type}`
|
|
||||||
)
|
|
||||||
.join(", "),
|
|
||||||
]),
|
|
||||||
]
|
|
||||||
.map((row) => row.map(quote).join(","))
|
|
||||||
.join("\r\n");
|
|
||||||
const url = URL.createObjectURL(
|
|
||||||
new Blob([content], { type: "text/csv;charset=utf-8" })
|
|
||||||
);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${title}-甘特图.csv`;
|
|
||||||
a.click();
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
||||||
};
|
|
||||||
const linkTasks = () => {
|
|
||||||
if (selected.length !== 2) return;
|
|
||||||
const target = rows.find((r) => r.element.id === selected[1]);
|
|
||||||
if (!target) return;
|
|
||||||
onChange(target.element.id, {
|
|
||||||
...target.task,
|
|
||||||
dependencies: [
|
|
||||||
...target.task.dependencies.filter((d) => d.id !== selected[0]),
|
|
||||||
{ id: selected[0], type: "FS", lag: 0 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="甘特图"
|
|
||||||
className="xmind-gantt"
|
|
||||||
ref={panelRef}
|
|
||||||
data-flow-board-shell-control="true"
|
|
||||||
onKeyUp={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
if (e.key === "Tab") {
|
|
||||||
const controls = Array.from(
|
|
||||||
panelRef.current?.querySelectorAll<HTMLElement>(
|
|
||||||
'button:not(:disabled),input,select,[tabindex="0"]'
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
const first = controls[0],
|
|
||||||
last = controls.at(-1);
|
|
||||||
if (e.shiftKey && document.activeElement === first) {
|
|
||||||
e.preventDefault();
|
|
||||||
last?.focus();
|
|
||||||
}
|
|
||||||
if (!e.shiftKey && document.activeElement === last) {
|
|
||||||
e.preventDefault();
|
|
||||||
first?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(e.metaKey || e.ctrlKey) &&
|
|
||||||
e.key.toLowerCase() === "z" &&
|
|
||||||
!(e.target instanceof HTMLInputElement) &&
|
|
||||||
!(e.target instanceof HTMLSelectElement)
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.shiftKey) onRedo();
|
|
||||||
else onUndo();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<header className="xmind-gantt-toolbar">
|
|
||||||
<div className="xmind-gantt-title">
|
|
||||||
<button type="button" aria-label="关闭甘特图" onClick={onClose}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
<h2>甘特图</h2>
|
|
||||||
<span>{title}</span>
|
|
||||||
</div>
|
|
||||||
<div className="xmind-gantt-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="连接选中任务"
|
|
||||||
title="按选择顺序建立完成 → 开始依赖"
|
|
||||||
disabled={selected.length !== 2}
|
|
||||||
onClick={linkTasks}
|
|
||||||
>
|
|
||||||
<Link2 size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="项目工作日"
|
|
||||||
title="项目工作日"
|
|
||||||
onClick={onCalendar}
|
|
||||||
>
|
|
||||||
<CalendarDays size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="导出甘特图 CSV"
|
|
||||||
onClick={downloadCsv}
|
|
||||||
>
|
|
||||||
<Download size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="打印甘特图"
|
|
||||||
onClick={() => window.print()}
|
|
||||||
>
|
|
||||||
<Printer size={18} />
|
|
||||||
</button>
|
|
||||||
<button type="button" aria-label="定位今天" onClick={goToday}>
|
|
||||||
<CalendarDays size={18} />
|
|
||||||
</button>
|
|
||||||
<div className="xmind-gantt-zoom">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="缩小甘特图"
|
|
||||||
onClick={() => setScale((s) => Math.max(2, s - 4))}
|
|
||||||
>
|
|
||||||
<Minus size={16} />
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
aria-label="甘特图缩放"
|
|
||||||
min={2}
|
|
||||||
max={64}
|
|
||||||
value={scale}
|
|
||||||
onChange={(e) => setScale(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="放大甘特图"
|
|
||||||
onClick={() => setScale((s) => Math.min(64, s + 4))}
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div className="xmind-gantt-filters">
|
|
||||||
<label>
|
|
||||||
<Search size={14} />
|
|
||||||
<input
|
|
||||||
aria-label="搜索甘特图任务"
|
|
||||||
placeholder="搜索任务"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
aria-label="按负责人筛选"
|
|
||||||
value={assignee}
|
|
||||||
onChange={(e) => setAssignee(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">全部负责人</option>
|
|
||||||
{[...new Set(rows.flatMap((r) => r.task.assignees))].map((name) => (
|
|
||||||
<option key={name} value={name}>
|
|
||||||
{name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="按任务进度筛选"
|
|
||||||
value={status}
|
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="all">全部任务</option>
|
|
||||||
<option value="open">未完成</option>
|
|
||||||
<option value="done">已完成</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="选择新增任务主题"
|
|
||||||
value={candidate}
|
|
||||||
onChange={(e) => setCandidate(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">选择主题添加任务</option>
|
|
||||||
{candidates.map((e) => (
|
|
||||||
<option key={e.element.id} value={e.element.id}>
|
|
||||||
{e.title}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!candidate}
|
|
||||||
onClick={() => {
|
|
||||||
onEdit(candidate);
|
|
||||||
setCandidate("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
添加任务
|
|
||||||
</button>
|
|
||||||
<span>
|
|
||||||
{calendar ? `${calendar.name} · 跳过非工作日 · ` : ""}
|
|
||||||
{visible.length} 个任务
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="xmind-gantt-body">
|
|
||||||
<div className="xmind-gantt-table">
|
|
||||||
<div className="xmind-gantt-table-head">
|
|
||||||
<span>任务名称</span>
|
|
||||||
<span>开始日期</span>
|
|
||||||
<span>时长</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="xmind-gantt-row-scroll"
|
|
||||||
ref={rowScrollRef}
|
|
||||||
onScroll={(e) => {
|
|
||||||
const node = scrollRef.current;
|
|
||||||
if (node && node.scrollTop !== e.currentTarget.scrollTop)
|
|
||||||
node.scrollTop = e.currentTarget.scrollTop;
|
|
||||||
}}
|
|
||||||
data-gantt-row-scroll="true"
|
|
||||||
>
|
|
||||||
{visible.map((row) => (
|
|
||||||
<div
|
|
||||||
className={`xmind-gantt-row ${selected.includes(row.element.id) ? "is-selected" : ""}`}
|
|
||||||
key={row.element.id}
|
|
||||||
style={{ height: ROW }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="xmind-gantt-row-name"
|
|
||||||
style={{ paddingLeft: 8 + Math.min(row.depth, 5) * 12 }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={`选择任务 ${row.title}`}
|
|
||||||
checked={selected.includes(row.element.id)}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelected((s) =>
|
|
||||||
e.target.checked
|
|
||||||
? [...s, row.element.id]
|
|
||||||
: s.filter((id) => id !== row.element.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{row.summary ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`折叠任务 ${row.title}`}
|
|
||||||
aria-expanded={!collapsed.has(row.element.id)}
|
|
||||||
onClick={() =>
|
|
||||||
setCollapsed((old) => {
|
|
||||||
const next = new Set(old);
|
|
||||||
if (next.has(row.element.id))
|
|
||||||
next.delete(row.element.id);
|
|
||||||
else next.add(row.element.id);
|
|
||||||
return next;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{collapsed.has(row.element.id) ? (
|
|
||||||
<ChevronRight size={12} />
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={12} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="xmind-gantt-task-title"
|
|
||||||
title={row.title}
|
|
||||||
onClick={() => onEdit(row.element.id)}
|
|
||||||
>
|
|
||||||
{row.title}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="xmind-gantt-date"
|
|
||||||
onClick={() => onEdit(row.element.id)}
|
|
||||||
>
|
|
||||||
{effective(row).start || "未排期"}
|
|
||||||
</button>
|
|
||||||
<span>{effective(row).duration} 天</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!visible.length ? (
|
|
||||||
<div className="xmind-gantt-empty">
|
|
||||||
<CalendarDays size={52} />
|
|
||||||
<strong>{rows.length ? "没有匹配的任务" : "没有任务"}</strong>
|
|
||||||
<p>
|
|
||||||
{rows.length
|
|
||||||
? "调整搜索或筛选条件。"
|
|
||||||
: "请选择一个主题以添加任务。"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="xmind-gantt-timeline"
|
|
||||||
ref={scrollRef}
|
|
||||||
onScroll={(e) => {
|
|
||||||
setScrollX(e.currentTarget.scrollLeft);
|
|
||||||
setViewportWidth(e.currentTarget.clientWidth);
|
|
||||||
const rowScroll = rowScrollRef.current;
|
|
||||||
if (rowScroll && rowScroll.scrollTop !== e.currentTarget.scrollTop)
|
|
||||||
rowScroll.scrollTop = e.currentTarget.scrollTop;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="xmind-gantt-grid"
|
|
||||||
style={{
|
|
||||||
width,
|
|
||||||
height: Math.max(600, HEADER + visible.length * ROW),
|
|
||||||
backgroundSize: `${scale}px ${ROW}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{calendar
|
|
||||||
? Array.from(
|
|
||||||
{ length: Math.ceil(viewportWidth / scale) + 3 },
|
|
||||||
(_, index) =>
|
|
||||||
Math.max(0, Math.floor(scrollX / scale) - 1) + index
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
(offset) =>
|
|
||||||
offset <= last - from &&
|
|
||||||
!isWorkingDate(indexDate(from + offset), calendar)
|
|
||||||
)
|
|
||||||
.map((offset) => (
|
|
||||||
<div
|
|
||||||
key={`off-${offset}`}
|
|
||||||
className="xmind-gantt-day-off"
|
|
||||||
data-gantt-day-off={indexDate(from + offset)}
|
|
||||||
style={{
|
|
||||||
left: offset * scale,
|
|
||||||
width: scale,
|
|
||||||
top: HEADER,
|
|
||||||
bottom: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
: null}
|
|
||||||
<div
|
|
||||||
className="xmind-gantt-calendar"
|
|
||||||
style={{ width, height: HEADER }}
|
|
||||||
>
|
|
||||||
{ticks.map((offset) => {
|
|
||||||
const date = indexDate(from + offset);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`xmind-gantt-day ${calendar && !isWorkingDate(date, calendar) ? "is-day-off" : ""}`}
|
|
||||||
key={offset}
|
|
||||||
style={{ left: offset * scale, width: tickStep * scale }}
|
|
||||||
>
|
|
||||||
<b>
|
|
||||||
{tickStep >= 30
|
|
||||||
? date.slice(5, 7) + "月"
|
|
||||||
: tickStep >= 7
|
|
||||||
? date.slice(5)
|
|
||||||
: String(Number(date.slice(8)))}
|
|
||||||
</b>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{monthBands.map((band) => (
|
|
||||||
<div
|
|
||||||
key={band.date}
|
|
||||||
className="xmind-gantt-month"
|
|
||||||
style={{
|
|
||||||
left: band.offset * scale,
|
|
||||||
width: band.days * scale,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{band.date.replace("-", "年")}月
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="xmind-gantt-today"
|
|
||||||
aria-label="今天"
|
|
||||||
style={{ left: (today - from) * scale, top: HEADER }}
|
|
||||||
/>
|
|
||||||
<svg
|
|
||||||
className="xmind-gantt-connections"
|
|
||||||
aria-label="任务依赖关系"
|
|
||||||
role="img"
|
|
||||||
width={width}
|
|
||||||
height={Math.max(600, HEADER + visible.length * ROW)}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<marker
|
|
||||||
id="gantt-arrow"
|
|
||||||
viewBox="0 0 10 10"
|
|
||||||
refX="8"
|
|
||||||
refY="5"
|
|
||||||
markerWidth="5"
|
|
||||||
markerHeight="5"
|
|
||||||
orient="auto-start-reverse"
|
|
||||||
>
|
|
||||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#8d94a1" />
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
{visible.flatMap((row, i) =>
|
|
||||||
row.task.dependencies.map((d) => {
|
|
||||||
const fromIndex = visible.findIndex(
|
|
||||||
(r) => r.element.id === d.id
|
|
||||||
);
|
|
||||||
if (fromIndex < 0) return null;
|
|
||||||
const before = effective(visible[fromIndex]),
|
|
||||||
after = effective(row);
|
|
||||||
if (!before.start || !after.start) return null;
|
|
||||||
const x1 =
|
|
||||||
(dateIndex(
|
|
||||||
d.type[0] === "F" ? before.due : before.start
|
|
||||||
) -
|
|
||||||
from +
|
|
||||||
(d.type[0] === "F" ? 1 : 0)) *
|
|
||||||
scale,
|
|
||||||
x2 =
|
|
||||||
(dateIndex(d.type[1] === "F" ? after.due : after.start) -
|
|
||||||
from +
|
|
||||||
(d.type[1] === "F" ? 1 : 0)) *
|
|
||||||
scale,
|
|
||||||
y1 = HEADER + fromIndex * ROW + ROW / 2,
|
|
||||||
y2 = HEADER + i * ROW + ROW / 2;
|
|
||||||
return (
|
|
||||||
<path
|
|
||||||
key={`${row.element.id}-${d.id}`}
|
|
||||||
data-gantt-dependency={`${d.id}:${row.element.id}`}
|
|
||||||
d={`M${x1},${y1} H${x1 + 9} V${y2} H${x2}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="#8d94a1"
|
|
||||||
strokeWidth={1.3}
|
|
||||||
markerEnd="url(#gantt-arrow)"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
{visible.map((row, i) =>
|
|
||||||
selected.includes(row.element.id) ? (
|
|
||||||
<div
|
|
||||||
key={`selection-${row.element.id}`}
|
|
||||||
className="xmind-gantt-selection"
|
|
||||||
style={{ top: HEADER + i * ROW, height: ROW, width }}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
)}
|
|
||||||
{visible.map((row, i) => {
|
|
||||||
const t = effective(row);
|
|
||||||
if (!t.start || !t.due) return null;
|
|
||||||
const left = (dateIndex(t.start) - from) * scale,
|
|
||||||
barWidth = Math.max(
|
|
||||||
6,
|
|
||||||
(dateIndex(t.due) - dateIndex(t.start) + 1) * scale
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={row.element.id}
|
|
||||||
className={`xmind-gantt-bar ${row.summary ? "is-summary" : ""} ${t.progress === 100 ? "is-complete" : ""}`}
|
|
||||||
data-gantt-task-id={row.element.id}
|
|
||||||
style={{ left, top: HEADER + i * ROW + 6, width: barWidth }}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`任务时间条 ${row.title}`}
|
|
||||||
aria-pressed={selected.includes(row.element.id)}
|
|
||||||
title={`${row.title}\n${t.start} — ${t.due}\n进度 ${t.progress}%`}
|
|
||||||
onDoubleClick={() => onEdit(row.element.id)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") onEdit(row.element.id);
|
|
||||||
if (
|
|
||||||
!row.summary &&
|
|
||||||
["ArrowLeft", "ArrowRight"].includes(e.key)
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
const days = e.key === "ArrowLeft" ? -1 : 1;
|
|
||||||
onChange(row.element.id, moveTask(t, days));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => {
|
|
||||||
if (row.summary || e.button !== 0) return;
|
|
||||||
e.preventDefault();
|
|
||||||
setSelected((ids) =>
|
|
||||||
e.metaKey || e.ctrlKey
|
|
||||||
? ids.includes(row.element.id)
|
|
||||||
? ids.filter((id) => id !== row.element.id)
|
|
||||||
: [...ids, row.element.id]
|
|
||||||
: [row.element.id]
|
|
||||||
);
|
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
|
||||||
const kind =
|
|
||||||
(e.target as HTMLElement).dataset.resize === "start"
|
|
||||||
? "start"
|
|
||||||
: (e.target as HTMLElement).dataset.resize === "due"
|
|
||||||
? "due"
|
|
||||||
: "move";
|
|
||||||
setDrag({
|
|
||||||
id: row.element.id,
|
|
||||||
x: e.clientX,
|
|
||||||
kind,
|
|
||||||
task: row.task,
|
|
||||||
days: 0,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onPointerMove={(e) => {
|
|
||||||
if (drag?.id !== row.element.id) return;
|
|
||||||
let days = Math.round((e.clientX - drag.x) / scale);
|
|
||||||
if (drag.kind === "start")
|
|
||||||
days = Math.min(
|
|
||||||
dateIndex(drag.task.due) - dateIndex(drag.task.start),
|
|
||||||
days
|
|
||||||
);
|
|
||||||
if (drag.kind === "due")
|
|
||||||
days = Math.max(
|
|
||||||
dateIndex(drag.task.start) - dateIndex(drag.task.due),
|
|
||||||
days
|
|
||||||
);
|
|
||||||
setDrag({ ...drag, days });
|
|
||||||
}}
|
|
||||||
onPointerUp={() => {
|
|
||||||
if (drag?.id !== row.element.id) return;
|
|
||||||
if (drag.days) onChange(row.element.id, t);
|
|
||||||
setDrag(null);
|
|
||||||
}}
|
|
||||||
onPointerCancel={() => setDrag(null)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="xmind-gantt-progress"
|
|
||||||
style={{ width: `${t.progress}%` }}
|
|
||||||
/>
|
|
||||||
{!row.summary ? (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
data-resize="start"
|
|
||||||
className="xmind-gantt-resize is-start"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
data-resize="due"
|
|
||||||
className="xmind-gantt-resize is-due"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<span className="xmind-gantt-bar-label">
|
|
||||||
<span
|
|
||||||
className="xmind-gantt-avatar"
|
|
||||||
title={t.assignees.join("、") || "未分配负责人"}
|
|
||||||
>
|
|
||||||
<UserRound size={12} />
|
|
||||||
</span>
|
|
||||||
<span>{row.title}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer className="xmind-gantt-footer">
|
|
||||||
<span>
|
|
||||||
{drag
|
|
||||||
? `调整 ${drag.days > 0 ? "+" : ""}${drag.days} 天`
|
|
||||||
: "拖动调整日期 · 拉伸修改工期 · 双击编辑任务"}
|
|
||||||
</span>
|
|
||||||
<span>{notice}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setQuery("");
|
|
||||||
setAssignee("");
|
|
||||||
setStatus("all");
|
|
||||||
setNotice("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
清除筛选
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Download, Loader2, X } from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import type { ImageExportOptions } from "@/lib/flow-board/image-export";
|
|
||||||
|
|
||||||
export function XmindImageExport({
|
|
||||||
busy,
|
|
||||||
onExport,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
busy: boolean;
|
|
||||||
onExport: (
|
|
||||||
options: Pick<ImageExportOptions, "scale" | "transparent">
|
|
||||||
) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const dialog = useRef<HTMLDialogElement>(null);
|
|
||||||
const [scale, setScale] = useState<1 | 2 | 3>(2);
|
|
||||||
const [transparent, setTransparent] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
const node = dialog.current;
|
|
||||||
node?.showModal();
|
|
||||||
return () => node?.close();
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<dialog
|
|
||||||
ref={dialog}
|
|
||||||
className="xmind-image-export"
|
|
||||||
aria-labelledby="image-export-title"
|
|
||||||
data-flow-board-shell-control="true"
|
|
||||||
onKeyDown={(event) => event.stopPropagation()}
|
|
||||||
onCancel={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!busy) onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<header>
|
|
||||||
<h2 id="image-export-title">导出图片</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="关闭图片导出"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<p>导出当前工作表的完整内容为 PNG 图片。</p>
|
|
||||||
<label className="xmind-image-export-field">
|
|
||||||
清晰度
|
|
||||||
<select
|
|
||||||
aria-label="图片清晰度"
|
|
||||||
value={scale}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(event) =>
|
|
||||||
setScale(Number(event.target.value) as 1 | 2 | 3)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value={1}>标准 · 1 倍</option>
|
|
||||||
<option value={2}>高清 · 2 倍</option>
|
|
||||||
<option value={3}>超清 · 3 倍</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="xmind-image-export-check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={transparent}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(event) => setTransparent(event.target.checked)}
|
|
||||||
/>
|
|
||||||
透明背景
|
|
||||||
</label>
|
|
||||||
<footer>
|
|
||||||
<button type="button" disabled={busy} onClick={onClose}>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="xmind-primary-button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => onExport({ scale, transparent })}
|
|
||||||
>
|
|
||||||
{busy ? (
|
|
||||||
<Loader2 size={16} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download size={16} />
|
|
||||||
)}
|
|
||||||
{busy ? "正在导出…" : "导出 PNG"}
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ChevronDown, Search, X } from "lucide-react";
|
|
||||||
import { XMIND_MARKER_GROUPS, type XmindMarker } from "@/lib/flow-board/xmind-markers";
|
|
||||||
|
|
||||||
export function XmindMarkerPanel({ selectedCount, activeIds, mixedIds, onApply, onClear, onClose }: {
|
|
||||||
selectedCount: number;
|
|
||||||
activeIds: string[];
|
|
||||||
mixedIds: string[];
|
|
||||||
onApply: (marker: XmindMarker) => void;
|
|
||||||
onClear: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
|
||||||
const [collapsed, setCollapsed] = useState<string[]>([]);
|
|
||||||
const needle = query.trim().toLowerCase();
|
|
||||||
const groups = XMIND_MARKER_GROUPS.filter(group => showAll || needle || !group.hidden).map(group => ({
|
|
||||||
...group, markers: group.markers.filter(marker => (showAll || needle || !marker.hidden) && (!needle || `${group.label} ${marker.name} ${marker.id}`.toLowerCase().includes(needle))),
|
|
||||||
})).filter(group => group.markers.length);
|
|
||||||
return <aside className="xmind-format-panel xmind-marker-panel" aria-label="标记面板" data-flow-board-shell-control="true"
|
|
||||||
onPointerDownCapture={event => event.stopPropagation()} onMouseDownCapture={event => event.stopPropagation()}>
|
|
||||||
<div className="xmind-panel-heading"><span>标记</span><button type="button" aria-label="关闭标记面板" onClick={onClose}><X size={16} /></button></div>
|
|
||||||
<div className="xmind-marker-search"><Search size={14} /><input aria-label="搜索标记" placeholder="搜索标记" value={query} onChange={event => setQuery(event.target.value)} /></div>
|
|
||||||
<p className="xmind-marker-selection" role="status">{selectedCount ? `已选中 ${selectedCount} 个主题` : "先选中主题,再添加标记"}</p>
|
|
||||||
<div className="xmind-panel-content">
|
|
||||||
{groups.map(group => <section className="xmind-marker-group" key={group.id}>
|
|
||||||
<button type="button" className="xmind-marker-group-heading" aria-expanded={!collapsed.includes(group.id) || Boolean(needle)} onClick={() => setCollapsed(ids => ids.includes(group.id) ? ids.filter(id => id !== group.id) : [...ids, group.id])}>
|
|
||||||
<ChevronDown size={13} /><span>{group.label}</span><span>{group.markers.length}</span>
|
|
||||||
</button>
|
|
||||||
{!collapsed.includes(group.id) || needle ? <div className="xmind-marker-grid">
|
|
||||||
{group.markers.map(marker => <button type="button" key={marker.id} data-marker-id={marker.id}
|
|
||||||
aria-label={marker.id.startsWith("priority-") ? marker.name : `${group.label} ${marker.name}`}
|
|
||||||
title={`${group.label} · ${marker.name}`} aria-pressed={activeIds.includes(marker.id) ? true : mixedIds.includes(marker.id) ? "mixed" : false}
|
|
||||||
disabled={!selectedCount} onPointerDown={event => event.preventDefault()} onMouseDown={event => event.preventDefault()} onClick={() => onApply(marker)}>
|
|
||||||
<img src={marker.asset} alt="" draggable={false} loading="lazy" />
|
|
||||||
</button>)}
|
|
||||||
</div> : null}
|
|
||||||
</section>)}
|
|
||||||
{!groups.length ? <p className="xmind-marker-empty">没有匹配的标记</p> : null}
|
|
||||||
{!needle ? <button type="button" className="xmind-marker-more" aria-expanded={showAll} onClick={() => setShowAll(value => !value)}>{showAll ? "收起更多标记" : "更多标记"}</button> : null}
|
|
||||||
</div>
|
|
||||||
<div className="xmind-marker-footer"><button type="button" disabled={!selectedCount} onPointerDown={event => event.preventDefault()} onMouseDown={event => event.preventDefault()} onClick={onClear}>清空所选节点标记</button></div>
|
|
||||||
</aside>;
|
|
||||||
}
|
|
||||||
@ -1,280 +0,0 @@
|
|||||||
/* Xmind editing controls use the project's home/canvas typography and density. */
|
|
||||||
.xmind-workspace {
|
|
||||||
--xmind-panel-width: 240px;
|
|
||||||
--xmind-ink: #24282b;
|
|
||||||
--xmind-muted: #888b8e;
|
|
||||||
--xmind-panel: #f3f4f5;
|
|
||||||
--xmind-border: #e3e5e8;
|
|
||||||
color: var(--xmind-ink);
|
|
||||||
font-family: Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
background: white;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.xmind-workspace button { cursor: pointer; }
|
|
||||||
.xmind-workspace :is(button,input,textarea,select) { font-family: inherit; }
|
|
||||||
.xmind-workspace button:disabled { cursor: default; opacity: .3; }
|
|
||||||
.xmind-workspace button:focus-visible { outline: 2px solid #35b5ff; outline-offset: 2px; }
|
|
||||||
.xmind-topic-marker { display:inline-block; object-fit:contain; flex-shrink:0; vertical-align:middle; }
|
|
||||||
.xmind-marker-search { display:flex; align-items:center; gap:6px; margin:4px 12px; padding:6px 8px; border:1px solid var(--xmind-border); border-radius:7px; background:white; }
|
|
||||||
.xmind-marker-search input { width:100%; min-width:0; outline:none; background:transparent; font-size:12px; }
|
|
||||||
.xmind-marker-selection { margin:6px 12px 10px; font-size:11px; color:var(--xmind-muted); }
|
|
||||||
.xmind-marker-panel .xmind-panel-content { flex:1; overflow-x:hidden; }
|
|
||||||
.xmind-marker-group { margin-bottom:12px; }
|
|
||||||
.xmind-marker-group-heading { display:flex; align-items:center; gap:5px; width:100%; height:26px; font-size:12px; font-weight:600; }
|
|
||||||
.xmind-marker-group-heading > span:last-child { margin-left:auto; font-size:10px; font-weight:400; color:var(--xmind-muted); }
|
|
||||||
.xmind-marker-group-heading[aria-expanded=false] svg { transform:rotate(-90deg); }
|
|
||||||
.xmind-marker-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:3px; }
|
|
||||||
.xmind-marker-grid button { display:grid; place-items:center; width:100%; height:28px; border:1px solid transparent; border-radius:5px; background:transparent; }
|
|
||||||
.xmind-marker-grid button:hover:not(:disabled) { background:#e3e6ea; }
|
|
||||||
.xmind-marker-grid button[aria-pressed=true] { border-color:#387cff; background:#e9f0ff; }
|
|
||||||
.xmind-marker-grid button[aria-pressed=mixed] { border:1px dashed #387cff; background:#e9f0ff80; }
|
|
||||||
.xmind-marker-grid img { width:20px; height:20px; object-fit:contain; }
|
|
||||||
.xmind-marker-more { width:100%; padding:7px; border-radius:6px; color:#3569e8; background:#e9edf4; font-size:12px; }
|
|
||||||
.xmind-marker-empty { padding:24px 0; text-align:center; color:var(--xmind-muted); }
|
|
||||||
.xmind-marker-footer { padding:10px 12px; border-top:1px solid var(--xmind-border); }
|
|
||||||
.xmind-marker-footer button { width:100%; padding:7px; border:1px solid var(--xmind-border); border-radius:6px; background:white; font-size:12px; }
|
|
||||||
.xmind-topbar { position:absolute; top:0; left:0; right:var(--xmind-panel-offset,0px); height:56px; z-index:50; display:flex; align-items:flex-start; padding:12px 12px 0; gap:8px; pointer-events:none; }
|
|
||||||
.xmind-topbar > * { pointer-events:auto; }
|
|
||||||
.xmind-document-heading { display:flex; align-items:center; gap:8px; min-width:200px; }
|
|
||||||
.xmind-home-button { display:grid; place-items:center; width:32px; height:32px; border:1px solid var(--xmind-border); border-radius:8px; background:rgba(255,255,255,.95); box-shadow:0 2px 8px #00000006; flex-shrink:0; }
|
|
||||||
.xmind-document-name { height:32px; display:flex; flex-direction:column; justify-content:center; gap:0; min-width:100px; }
|
|
||||||
.xmind-document-name input { background:transparent; border:1px solid transparent; border-radius:5px; font-size:13px; font-weight:600; width:140px; padding:0 3px; outline:none; }
|
|
||||||
.xmind-document-name input:focus { border-color:#35b5ff; }
|
|
||||||
.xmind-document-name span { font-size:11px; padding-left:4px; }
|
|
||||||
.xmind-document-menu { margin-left:-8px; opacity:0; transition:opacity .15s; }
|
|
||||||
.xmind-document-heading:hover .xmind-document-menu,.xmind-document-heading:focus-within .xmind-document-menu { opacity:1; }
|
|
||||||
.xmind-pointer-toolbar { position:absolute; top:64px; left:12px; display:flex; flex-direction:column; gap:2px; padding:3px; border:1px solid var(--xmind-border); border-radius:10px; background:#fffffff5; box-shadow:0 2px 8px #00000008; }
|
|
||||||
.xmind-pointer-toolbar .xmind-icon-button { width:28px; height:28px; }
|
|
||||||
.xmind-primary-tools { display:flex; align-items:center; gap:8px; margin:auto; margin-top:0; }
|
|
||||||
.xmind-toolbar-pill { display:flex; align-items:center; padding:2px; border:1px solid var(--xmind-border); border-radius:8px; background:rgba(255,255,255,.95); box-shadow:0 2px 8px #00000006; backdrop-filter:blur(24px); }
|
|
||||||
.xmind-icon-button { width:30px; height:28px; display:grid; place-items:center; border-radius:6px; color:var(--xmind-ink); background:transparent; flex-shrink:0; }
|
|
||||||
.xmind-icon-button > svg { width:18px; height:18px; }
|
|
||||||
.xmind-icon-button:hover:not(:disabled),.xmind-home-button:hover,.xmind-menu-trigger:hover { background:#00000009; }
|
|
||||||
.xmind-icon-button.is-active { background:#363b3e; color:white; }
|
|
||||||
.xmind-utility-tools { display:flex; align-items:center; gap:8px; }
|
|
||||||
.xmind-menu-host { position:relative; }
|
|
||||||
.xmind-menu-trigger { display:flex; align-items:center; justify-content:center; gap:4px; min-height:28px; padding:2px 5px; border-radius:24px; }
|
|
||||||
.xmind-document-menu .xmind-menu-trigger > svg:last-child,.xmind-sheet-actions .xmind-menu-trigger > svg:last-child { display:none; }
|
|
||||||
.xmind-command-menu { position:absolute; top:calc(100% + 10px); left:0; min-width:220px; max-height:min(70vh,540px); overflow-y:auto; padding:5px; border:1px solid #dedfe2; border-radius:10px; background:#fcfcfcf5; backdrop-filter:blur(24px); box-shadow:0 12px 32px #00000020,0 2px 6px #0000000a; z-index:70; }
|
|
||||||
.xmind-menu-right { left:auto; right:0; }
|
|
||||||
.xmind-command { width:100%; display:flex; align-items:center; gap:10px; border-radius:5px; padding:7px 9px; text-align:left; min-height:32px; color:#34393c; font-size:13px; }
|
|
||||||
.xmind-command:hover:not(:disabled),.xmind-command:focus-visible { background:#35a6fc; color:white; outline:none; }
|
|
||||||
.xmind-command kbd { font:inherit; opacity:.5; margin-left:auto; font-size:11px; }
|
|
||||||
.xmind-command-check { display:block; width:16px; }
|
|
||||||
.xmind-menu-separator { height:1px; background:#e1e2e4; margin:4px 6px; }
|
|
||||||
.xmind-format-panel { position:absolute; top:8px; right:8px; bottom:8px; width:var(--xmind-panel-width); border-radius:12px; background:var(--xmind-panel); border:1px solid #e5e7e9; box-shadow:-5px 0 20px #00000005; display:flex; flex-direction:column; z-index:35; overflow:hidden; }
|
|
||||||
.xmind-panel-heading { display:flex; justify-content:space-between; align-items:center; min-height:36px; padding:0 12px; font-weight:600; }
|
|
||||||
.xmind-panel-heading button { border-radius:6px; padding:4px; color:#7a7e83; }
|
|
||||||
.xmind-panel-heading button:hover { background:#e2e4e6; }
|
|
||||||
.xmind-panel-tabs { display:flex; margin:4px 10px 10px; padding:3px; height:28px; border-radius:8px; background:#e7e8eb; flex-shrink:0; }
|
|
||||||
.xmind-panel-tabs button { flex:1; border-radius:6px; font-size:12px; color:#6d7276; }
|
|
||||||
.xmind-panel-tabs button[aria-selected=true] { background:#ffffffd9; color:#23282b; font-weight:600; }
|
|
||||||
.xmind-panel-content { overflow-y:auto; min-height:0; padding:0 12px 12px; scrollbar-width:thin; scrollbar-color:#b5b5b5 transparent; }
|
|
||||||
.xmind-topic-preview { min-height:48px; border:1px solid var(--xmind-border); border-radius:8px; background:#fff; display:flex; align-items:center; gap:8px; padding:8px; margin-bottom:12px; }
|
|
||||||
.xmind-topic-preview > span { flex:1; text-align:center; font-size:14px; padding:5px; }
|
|
||||||
.xmind-topic-preview svg { color:#5c6267; flex-shrink:0; }
|
|
||||||
.xmind-format-section { padding:12px 0; border-top:1px solid var(--xmind-border); }
|
|
||||||
.xmind-format-section h3 { display:flex; align-items:center; gap:5px; margin:0 0 10px; font-size:12px; font-weight:600; }
|
|
||||||
.xmind-setting-row { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; font-size:12px; }
|
|
||||||
.xmind-setting-row h3 { margin:0; }
|
|
||||||
.xmind-setting-row > span { flex-shrink:0; }
|
|
||||||
.xmind-setting-row select { height:25px; background:#fff; border:1px solid var(--xmind-border); border-radius:8px; min-width:74px; max-width:170px; padding:0 8px; font-size:12px; }
|
|
||||||
.xmind-color-control { height:25px; width:74px; padding:0; border:1px solid var(--xmind-border); border-radius:8px; overflow:hidden; background:white; cursor:pointer; }
|
|
||||||
.xmind-color-control::-webkit-color-swatch-wrapper { padding:0; }
|
|
||||||
.xmind-color-control::-webkit-color-swatch { border:0; border-radius:7px; }
|
|
||||||
.xmind-segmented { display:flex; border:1px solid var(--xmind-border); background:white; border-radius:8px; overflow:hidden; height:27px; }
|
|
||||||
.xmind-segmented button { flex:1; display:grid; place-items:center; margin:2px; border-radius:6px; }
|
|
||||||
.xmind-segmented button[aria-pressed=true] { background:#e4e6e8; }
|
|
||||||
.xmind-segmented button:hover:not(:disabled) { background:#f1f2f3; }
|
|
||||||
.xmind-shape-rectangle { width:18px; height:12px; border:1.5px solid currentColor; border-radius:3px; }
|
|
||||||
.xmind-shape-line { width:24px; border-bottom:1.5px solid currentColor; }
|
|
||||||
.xmind-text-marks { height:28px; margin-top:12px; }
|
|
||||||
.xmind-structure-trigger { display:flex; height:26px; align-items:center; padding:0 8px; background:white; border:1px solid var(--xmind-border); border-radius:8px; }
|
|
||||||
.xmind-structure-trigger svg[data-layout-preview] { width:35px; height:24px; }
|
|
||||||
.xmind-current-structure { padding:4px 10px; margin-top:14px; border:1px solid var(--xmind-border); border-radius:8px; background:#fff; font-size:12px; color:#7e8388; }
|
|
||||||
.xmind-structure-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; margin-top:12px; }
|
|
||||||
.xmind-structure-grid button { display:flex; flex-direction:column; justify-content:center; align-items:center; gap:4px; min-height:64px; border:1px solid #e0e3e7; border-radius:7px; font-size:9px; padding:4px 2px; background:white; }
|
|
||||||
.xmind-structure-grid button[aria-pressed=true] { border-color:#36b4ff; background:#edf8ff; color:#179cdc; }
|
|
||||||
.xmind-background-presets { display:grid; grid-template-columns:repeat(6,1fr); gap:6px; }
|
|
||||||
.xmind-background-presets button { height:28px; border:1px solid #d6d8dc; border-radius:6px; }
|
|
||||||
.xmind-background-presets button[aria-pressed=true] { outline:2px solid #35b5ff; }
|
|
||||||
.xmind-theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
|
|
||||||
.xmind-theme-grid button { background:white; border:1px solid #e1e3e6; border-radius:8px; font-size:11px; padding:8px; }
|
|
||||||
.xmind-theme-grid button[aria-pressed=true] { border-color:#35b5ff; }
|
|
||||||
.xmind-theme-grid button > span { display:flex; margin-bottom:8px; height:12px; overflow:hidden; border-radius:3px; }
|
|
||||||
.xmind-theme-grid i { flex:1; }
|
|
||||||
.xmind-switch { width:30px; height:18px; border-radius:12px; background:#bfc3c8; position:relative; }
|
|
||||||
.xmind-switch i { position:absolute; top:2px; left:2px; height:14px; width:14px; background:white; border-radius:50%; transition:transform .15s; }
|
|
||||||
.xmind-switch[aria-checked=true] { background:#35b5ff; }
|
|
||||||
.xmind-switch[aria-checked=true] i { transform:translateX(12px); }
|
|
||||||
.xmind-canvas-preview { height:96px; border:1px solid var(--xmind-border); border-radius:8px; margin-top:12px; }
|
|
||||||
.xmind-sheetbar { position:absolute; bottom:10px; left:16px; right:calc(var(--xmind-panel-offset,0px) + 16px); height:30px; z-index:40; display:flex; align-items:center; gap:8px; pointer-events:none; }
|
|
||||||
.xmind-sheetbar > * { pointer-events:auto; }
|
|
||||||
.xmind-sheet-tabs { display:flex; gap:4px; max-width:60%; overflow-x:auto; scrollbar-width:none; }
|
|
||||||
.xmind-sheet-tab-host { flex-shrink:0; }
|
|
||||||
.xmind-sheet-tab-host > button { border-radius:16px; padding:4px 18px; font-size:12px; font-weight:500; background:#ffffffd9; max-width:180px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
||||||
.xmind-sheet-tab-host > button[aria-pressed=true] { background:#e4e5e6; font-weight:600; }
|
|
||||||
.xmind-sheet-tab-host input { width:130px; height:25px; border-radius:6px; background:white; border:1px solid #35b5ff; padding:0 8px; }
|
|
||||||
.xmind-sheet-context { position:absolute; bottom:36px; min-width:140px; padding:5px; border:1px solid #e2e4e7; border-radius:10px; background:white; box-shadow:0 10px 25px #00000015; }
|
|
||||||
.xmind-sheet-context button { display:block; width:100%; text-align:left; padding:7px 10px; border-radius:6px; }
|
|
||||||
.xmind-sheet-context button:hover { background:#f0f3f5; }
|
|
||||||
.xmind-footer-divider { width:1px; height:13px; background:#dfe2e5; flex-shrink:0; }
|
|
||||||
.xmind-footer-icon { display:grid; place-items:center; width:26px; height:26px; border-radius:6px; }
|
|
||||||
.xmind-footer-icon:hover { background:#f2f3f5; }
|
|
||||||
.xmind-sheet-actions { opacity:0; }
|
|
||||||
.xmind-sheetbar:hover .xmind-sheet-actions,.xmind-sheet-actions:focus-within { opacity:1; }
|
|
||||||
.xmind-sheet-status { margin-left:auto; display:flex; gap:12px; align-items:center; background:#ffffffd9; }
|
|
||||||
.xmind-topic-count { font-size:11px; white-space:nowrap; }
|
|
||||||
.xmind-zoom-menu .xmind-command-menu,.xmind-sheet-actions .xmind-command-menu { top:auto; bottom:calc(100% + 8px); }
|
|
||||||
.xmind-zoom-menu .xmind-menu-trigger { font-size:12px; padding:0; min-width:50px; }
|
|
||||||
.xmind-outline-toggle { font-weight:600; font-size:13px; white-space:nowrap; }
|
|
||||||
.xmind-workspace .flow-board-canvas-shell { top:0; bottom:0; right:var(--xmind-panel-offset,0px); }
|
|
||||||
.xmind-workspace .board-host-svg { background:transparent !important; }
|
|
||||||
.xmind-workspace .board-active-svg { z-index:2; pointer-events:none; }
|
|
||||||
.xmind-workspace .active-host-g path[stroke="#6698FF"] { stroke:#387cff; stroke-width:2px; vector-effect:non-scaling-stroke; }
|
|
||||||
.xmind-workspace .active-host-g path[fill="#6698FF25"] { fill:rgba(56,124,255,.12); }
|
|
||||||
.xmind-outline-shell { position:absolute; inset:56px var(--xmind-panel-offset,0px) 48px 0; z-index:20; }
|
|
||||||
.xmind-workspace .xmind-aux-panel { top:8px; bottom:8px; right:8px; width:var(--xmind-panel-width); max-width:var(--xmind-panel-width); border-radius:12px; border:1px solid var(--xmind-border); background:var(--xmind-panel); }
|
|
||||||
.xmind-zen-exit { position:absolute; top:16px; right:16px; z-index:45; display:flex; gap:8px; align-items:center; padding:9px 14px; border-radius:24px; background:#fffffff0; box-shadow:0 4px 20px #0000000c; font-size:12px; }
|
|
||||||
.xmind-empty-canvas { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); z-index:20; border:1px solid #333; background:white; border-radius:8px; padding:8px 16px; font-size:16px; font-weight:600; }
|
|
||||||
.xmind-search-panel { position:absolute; top:64px; left:8px; bottom:48px; width:var(--xmind-panel-width); border-radius:12px; background:var(--xmind-panel); border:1px solid var(--xmind-border); z-index:45; display:flex; flex-direction:column; box-shadow:5px 0 20px #00000008; }
|
|
||||||
.xmind-workspace:has(.xmind-search-panel) .xmind-pointer-toolbar { visibility:hidden; }
|
|
||||||
.xmind-search-panel .xmind-panel-content { padding:0 14px 14px; }
|
|
||||||
.xmind-search-box { display:flex; gap:8px; border-radius:8px; border:1px solid var(--xmind-border); background:white; padding:8px; align-items:center; margin:8px 0; }
|
|
||||||
.xmind-search-box input { outline:0; width:100%; background:transparent; font-size:13px; }
|
|
||||||
.xmind-search-options { display:flex; align-items:center; gap:12px; margin:10px 0; font-size:11px; }
|
|
||||||
.xmind-search-panel select { width:100%; height:30px; border-radius:8px; background:white; border:1px solid var(--xmind-border); padding:0 8px; font-size:12px; }
|
|
||||||
.xmind-search-navigation { display:flex; align-items:center; gap:8px; margin:12px 0; font-size:11px; color:#81868b; }
|
|
||||||
.xmind-search-navigation span { flex:1; }
|
|
||||||
.xmind-search-navigation button { padding:4px; border-radius:5px; }
|
|
||||||
.xmind-search-navigation button:hover { background:#e5e7e9; }
|
|
||||||
.xmind-search-result { display:block; width:100%; text-align:left; padding:10px; border-radius:8px; margin:4px 0; background:#ffffffa6; }
|
|
||||||
.xmind-search-result.is-active { outline:1px solid #35b5ff; background:#ecf8ff; }
|
|
||||||
.xmind-search-result > span { display:block; font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
||||||
.xmind-search-result small { font-size:10px; color:#8a8e91; }
|
|
||||||
.xmind-search-replace-buttons { display:flex; gap:8px; margin:10px 0 16px; }
|
|
||||||
.xmind-search-replace-buttons button { flex:1; border:1px solid var(--xmind-border); border-radius:6px; background:white; padding:5px; font-size:11px; }
|
|
||||||
.xmind-presentation { position:absolute; inset:0; z-index:60; background:#fff; display:flex; flex-direction:column; }
|
|
||||||
.xmind-presentation > header { display:flex; align-items:center; justify-content:space-between; padding:20px 28px; color:#8b9095; font-size:12px; }
|
|
||||||
.xmind-presentation > header button { display:flex; align-items:center; gap:6px; }
|
|
||||||
.xmind-presentation-slide { display:flex; flex:1; flex-direction:column; align-items:center; justify-content:center; padding:60px; overflow:auto; }
|
|
||||||
.xmind-presentation-slide h1 { font-size:clamp(28px,4vw,56px); font-weight:650; text-align:center; }
|
|
||||||
.xmind-presentation-slide ul { display:flex; flex-direction:column; gap:20px; margin-top:40px; font-size:clamp(18px,2vw,28px); }
|
|
||||||
.xmind-presentation-slide li { display:flex; align-items:center; gap:15px; }
|
|
||||||
.xmind-presentation-slide li::before { content:""; width:8px; height:8px; border-radius:50%; background:#ff7160; }
|
|
||||||
.xmind-presentation > footer { display:flex; justify-content:center; align-items:center; gap:20px; padding:20px; font-size:12px; color:#72787e; }
|
|
||||||
.xmind-presentation > footer button { padding:8px; border:1px solid #e6e7e9; border-radius:50%; }
|
|
||||||
@media (max-width:1100px) { .xmind-window-dots { display:none; } .xmind-document-heading { min-width:160px; gap:8px; } .xmind-document-name input { width:100px; } .xmind-utility-tools { gap:8px; } .xmind-primary-tools { gap:8px; } .xmind-topbar { gap:8px; padding-left:12px; } }
|
|
||||||
@media (max-width:850px) { .xmind-document-name { display:none; } .xmind-document-heading { min-width:auto; } .xmind-document-menu { opacity:1; } .xmind-utility-tools > .xmind-toolbar-pill:nth-child(2) { display:none; } .xmind-topbar { justify-content:space-between; } }
|
|
||||||
@media (max-width:640px) { .xmind-workspace { --xmind-panel-offset:0px !important; } .xmind-format-panel,.xmind-workspace .xmind-aux-panel { width:min(var(--xmind-panel-width),calc(100vw - 16px)); } .xmind-topbar { gap:4px; padding:12px 8px; } .xmind-home-button { width:32px; } .xmind-primary-tools { gap:4px; margin:0 auto; } .xmind-icon-button { width:28px; } .xmind-document-menu { display:none; } .xmind-topic-count { display:none; } .xmind-sheetbar { left:10px; right:10px; gap:5px; } .xmind-sheet-status { gap:6px; } .xmind-sheet-tabs { max-width:50%; } }
|
|
||||||
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Regular.ttf') format('truetype'); font-weight:400; font-style:normal; font-display:swap; }
|
|
||||||
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Medium.ttf') format('truetype'); font-weight:500; font-style:normal; font-display:swap; }
|
|
||||||
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Bold.ttf') format('truetype'); font-weight:700; font-style:normal; font-display:swap; }
|
|
||||||
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Extrabold.ttf') format('truetype'); font-weight:800; font-style:normal; font-display:swap; }
|
|
||||||
@layer base {
|
|
||||||
.xmind-workspace .drawnix,.xmind-workspace .drawnix * { --ui-font:Arial,"PingFang SC",sans-serif; font-family:Arial,"PingFang SC",sans-serif !important; }
|
|
||||||
.xmind-workspace .drawnix .plait-text-container,.xmind-workspace .drawnix .plait-text-container * { font-family:NeverMind,Arial,"PingFang SC",sans-serif !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.xmind-native-icon { display:block; width:18px; height:18px; background:currentColor; mask-size:contain; mask-position:center; mask-repeat:no-repeat; }
|
|
||||||
|
|
||||||
/* Topic notes follow the native compact formatting toolbar. */
|
|
||||||
.xmind-notes-editor { border:1px solid #e2e5eb; border-radius:12px; background:#fafbfc; overflow:hidden; }
|
|
||||||
.xmind-notes-toolbar { display:flex; justify-content:center; align-items:center; gap:2px; padding:7px; border-bottom:1px solid #eceef1; }
|
|
||||||
.xmind-notes-toolbar button { display:grid; place-items:center; width:28px; height:28px; border-radius:6px; }
|
|
||||||
.xmind-notes-toolbar button:hover,.xmind-notes-toolbar button[aria-pressed=true] { background:#e9edf5; color:#3569e8; }
|
|
||||||
.xmind-note-content { min-height:180px; max-height:380px; overflow:auto; padding:12px; outline:none; font-size:13px; line-height:1.7; color:#30343b; counter-reset:note-number; }
|
|
||||||
.xmind-note-content p { margin:0 0 4px; position:relative; }
|
|
||||||
.xmind-note-content p[data-note-list] { padding-left:20px; }
|
|
||||||
.xmind-note-content p[data-note-list=bullet]::before { content:"•"; position:absolute; left:3px; }
|
|
||||||
.xmind-note-content p[data-note-list=number] { counter-increment:note-number; }
|
|
||||||
.xmind-note-content p[data-note-list=number]::before { content:counter(note-number) "."; position:absolute; left:0; }
|
|
||||||
.xmind-note-content a { color:#3569e8; text-decoration:underline; }
|
|
||||||
.xmind-note-link-form { padding:8px; display:flex; flex-wrap:wrap; gap:6px; }
|
|
||||||
.xmind-note-link-form input { min-width:0; flex:1; border:1px solid #dfe2e7; padding:4px; border-radius:5px; }
|
|
||||||
.xmind-note-link-form p { width:100%; color:#c0392b; font-size:11px; }
|
|
||||||
.xmind-task-backdrop { position:absolute; inset:0; background:rgb(20 27 40 / .16); z-index:85; display:grid; place-items:center; padding:20px; }
|
|
||||||
.xmind-task-editor { width:min(460px,100%); max-height:calc(100vh - 40px); overflow:auto; border:1px solid #e0e3e8; border-radius:16px; background:#fafbfc; color:#343b48; padding:18px; box-shadow:0 16px 64px rgb(20 27 40 / .18); font-size:12px; }
|
|
||||||
.xmind-task-editor header,.xmind-task-editor header h2 { display:flex; align-items:center; gap:8px; }
|
|
||||||
.xmind-task-editor header { justify-content:space-between; }.xmind-task-editor h2 { font-size:14px; font-weight:600; }
|
|
||||||
.xmind-task-name { margin:14px 0; font-size:15px; font-weight:600; overflow-wrap:anywhere; }
|
|
||||||
.xmind-task-fields { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
|
||||||
.xmind-task-fields label { display:flex; flex-direction:column; gap:5px; }
|
|
||||||
.xmind-task-editor input,.xmind-task-editor select { width:100%; min-width:0; height:32px; padding:0 8px; border:1px solid #dfe2e7; background:white; border-radius:6px; outline:none; }
|
|
||||||
.xmind-task-editor input:focus,.xmind-task-editor select:focus { border-color:#799ce8; }
|
|
||||||
.xmind-task-editor h3 { font-weight:600; margin:16px 0 8px; }
|
|
||||||
.xmind-task-dependencies>div { display:flex; justify-content:space-between; padding:6px 0; }.xmind-task-add-dependency { display:grid; grid-template-columns:1fr 1fr 50px 34px; gap:5px; }
|
|
||||||
.xmind-task-editor footer { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-top:20px; }.xmind-task-editor footer button { border-radius:6px; padding:7px 11px; }.xmind-task-editor footer button:first-child { margin-right:auto; }
|
|
||||||
.xmind-primary-button { background:#3569e8; color:white; }.xmind-danger,.xmind-task-error { color:#c23c36; }.xmind-task-error { margin-top:10px; }
|
|
||||||
.xmind-gantt { position:absolute; inset:0; z-index:75; display:flex; flex-direction:column; background:#edf0f3; color:#30343b; font-size:12px; }
|
|
||||||
.xmind-gantt-toolbar { display:flex; align-items:center; justify-content:space-between; min-height:58px; padding:8px 16px; gap:10px; }.xmind-gantt-title,.xmind-gantt-actions { display:flex; align-items:center; gap:12px; }.xmind-gantt-title h2 { font-size:15px; font-weight:600; white-space:nowrap; }.xmind-gantt-title>span { max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#7d8592; }
|
|
||||||
.xmind-gantt-actions>button,.xmind-gantt-title>button { display:grid; place-items:center; width:32px; height:32px; background:#fafbfc; border-radius:12px; }.xmind-gantt button:disabled { opacity:.35; cursor:default; }.xmind-gantt-zoom { display:flex; align-items:center; gap:8px; padding:6px 10px; background:#fafbfc; border-radius:16px; }.xmind-gantt-zoom input { width:90px; }
|
|
||||||
.xmind-gantt-filters { display:flex; align-items:center; gap:8px; padding:0 16px 10px; flex-wrap:wrap; }.xmind-gantt-filters label { display:flex; align-items:center; gap:6px; background:#fafbfc; padding:0 8px; border:1px solid #e0e3e8; border-radius:6px; }.xmind-gantt-filters input { width:130px; height:28px; outline:none; background:transparent; }.xmind-gantt-filters select { max-width:210px; height:30px; border:1px solid #e0e3e8; background:#fafbfc; padding:0 8px; border-radius:6px; }.xmind-gantt-filters>span { margin-left:auto; color:#7d8592; }
|
|
||||||
.xmind-gantt-body { min-height:0; flex:1; display:grid; grid-template-columns:minmax(290px,34%) minmax(0,1fr); gap:12px; padding:0 12px; }.xmind-gantt-table,.xmind-gantt-timeline { border:1px solid #dfe3e9; border-radius:12px; background:#fafbfc; overflow:hidden; min-width:0; position:relative; }.xmind-gantt-table { display:flex; flex-direction:column; }.xmind-gantt-table-head,.xmind-gantt-row { display:grid; grid-template-columns:minmax(0,1fr) 104px 54px; align-items:center; }.xmind-gantt-table-head { height:64px; min-height:64px; font-weight:600; border-bottom:1px solid #e2e5eb; }.xmind-gantt-table-head>span { display:flex; align-items:center; height:100%; padding:0 12px; border-right:1px solid #e2e5eb; }.xmind-gantt-row-scroll { overflow:auto; scrollbar-width:none; flex:1; position:relative; }.xmind-gantt-row { border-bottom:1px solid #e2e5eb; }.xmind-gantt-row.is-selected { background:#edf3ff; }.xmind-gantt-row>span { padding:0 7px; }.xmind-gantt-row-name { display:flex; align-items:center; gap:5px; overflow:hidden; }.xmind-gantt-row-name input { flex-shrink:0; }.xmind-gantt-task-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; }.xmind-gantt-date { font-size:11px; text-align:left; padding:0 8px; }
|
|
||||||
.xmind-gantt-timeline { overflow:auto; }.xmind-gantt-grid { position:relative; background-image:linear-gradient(to right,#e7e9ee 1px,transparent 1px),linear-gradient(to bottom,#e7e9ee 1px,transparent 1px); background-position:0 64px; }.xmind-gantt-calendar { position:sticky; top:0; z-index:5; background:#fafbfc; border-bottom:1px solid #e2e5eb; }.xmind-gantt-day { position:absolute; top:0; height:64px; display:flex; flex-direction:column; justify-content:space-around; align-items:center; border-right:1px solid #e2e5eb; white-space:nowrap; overflow:hidden; }.xmind-gantt-day span { font-size:10px; color:#8b929d; }.xmind-gantt-day b { font-weight:500; }.xmind-gantt-today { position:absolute; bottom:0; width:2px; background:#fa603b; pointer-events:none; z-index:2; }.xmind-gantt-connections { position:absolute; inset:0; pointer-events:none; z-index:2; }.xmind-gantt-bar { position:absolute; height:26px; border-radius:16px; background:#fac28a; cursor:grab; touch-action:none; z-index:3; overflow:hidden; min-width:6px; border:1px solid #f5b978; }.xmind-gantt-bar:focus { outline:2px solid #3569e8; outline-offset:2px; }.xmind-gantt-progress { position:absolute; inset:0 auto 0 0; background:#f3a660; }.xmind-gantt-bar.is-complete { background:#b8dcca; border-color:#68a986; }.xmind-gantt-bar.is-complete .xmind-gantt-progress { background:#86b99b; }.xmind-gantt-bar.is-summary { height:10px; margin-top:4px; background:#667387; border-color:#667387; cursor:default; }.xmind-gantt-bar-label { position:relative; display:flex; align-items:center; gap:8px; padding:0 6px; height:24px; color:#72491f; font-size:11px; pointer-events:none; white-space:nowrap; }
|
|
||||||
.xmind-gantt-bar-label>span:last-child { flex:1; overflow:hidden; text-overflow:ellipsis; text-align:center; }
|
|
||||||
.xmind-gantt-avatar { width:18px; height:18px; border-radius:50%; background:#eceff1; color:#939ba2; display:grid; place-items:center; flex-shrink:0; }
|
|
||||||
.xmind-gantt-selection { position:absolute; left:0; background:rgb(125 136 148 / .15); pointer-events:none; z-index:1; }
|
|
||||||
.xmind-gantt-bar[aria-pressed=true] { outline:2px solid #539efa; outline-offset:1px; }.xmind-gantt-bar.is-summary .xmind-gantt-bar-label { display:none; }.xmind-gantt-resize { position:absolute; top:0; bottom:0; width:6px; cursor:ew-resize; z-index:4; }.xmind-gantt-resize.is-start { left:0; }.xmind-gantt-resize.is-due { right:0; }
|
|
||||||
.xmind-gantt-empty { min-height:300px; height:70%; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#a0a6af; gap:10px; }.xmind-gantt-empty strong { font-size:15px; color:#424852; }.xmind-gantt-footer { display:flex; align-items:center; justify-content:space-between; gap:8px; min-height:32px; padding:0 16px; color:#7d8592; font-size:11px; }
|
|
||||||
@media(max-width:800px) { .xmind-gantt-title>span { display:none; }.xmind-gantt-body { grid-template-columns:260px minmax(0,1fr); gap:6px; padding:0 6px; }.xmind-gantt-table-head,.xmind-gantt-row { grid-template-columns:minmax(0,1fr) 84px 42px; }.xmind-gantt-actions { gap:5px; }.xmind-gantt-toolbar { padding:8px; }.xmind-gantt-zoom input { width:50px; } }
|
|
||||||
@media print { .xmind-workspace:has(.xmind-gantt)>:not(.xmind-gantt) { display:none!important; }.xmind-gantt { position:fixed; background:white; }.xmind-gantt-toolbar button,.xmind-gantt-actions,.xmind-gantt-filters,.xmind-gantt-footer { display:none; }.xmind-gantt-timeline,.xmind-gantt-row-scroll { overflow:visible; }.xmind-gantt-body { overflow:visible; }.xmind-gantt-table { overflow:visible; }.xmind-gantt-row-name input { display:none; } }
|
|
||||||
|
|
||||||
.xmind-gantt-grid { min-height:100%; }
|
|
||||||
.xmind-gantt-day { top:28px; height:36px; }
|
|
||||||
.xmind-gantt-month { position:absolute; top:0; height:28px; padding:6px 12px; text-align:center; color:#7d8592; border-right:1px solid #e2e5eb; border-bottom:1px solid #e2e5eb; white-space:nowrap; overflow:hidden; }
|
|
||||||
|
|
||||||
.xmind-topic-badges { position:absolute; inset:0; pointer-events:none; z-index:12; overflow:hidden; }
|
|
||||||
.xmind-topic-badge { position:absolute; display:flex; align-items:center; gap:2px; pointer-events:auto; }
|
|
||||||
.xmind-topic-badge button { display:grid; place-items:center; width:19px; height:19px; background:rgb(255 255 255 / .9); border-radius:4px; color:#8b929d; }
|
|
||||||
.xmind-topic-badge button:hover { background:#edf3ff; color:#3569e8; }
|
|
||||||
|
|
||||||
.xmind-note-content p:not([data-note-list=number]) { counter-reset:note-number; }
|
|
||||||
|
|
||||||
.xmind-gantt-day-off { position:absolute; background:rgb(111 124 139 / .09); pointer-events:none; z-index:0; }
|
|
||||||
.xmind-gantt-day.is-day-off { color:#929aa6; background:#eef0f3; }
|
|
||||||
.xmind-task-calendar-info { display:flex; justify-content:space-between; align-items:center; gap:10px; margin:12px 0; color:#7d8592; font-size:11px; }
|
|
||||||
.xmind-task-calendar-info button { padding:6px 10px; border:1px solid #dce1e8; border-radius:6px; background:#f7f8fa; color:#3b4452; }
|
|
||||||
.xmind-project-calendar-backdrop,.xmind-calendar-exception-backdrop { position:absolute; inset:0; z-index:100; display:flex; align-items:center; justify-content:center; background:rgb(29 36 46 / .22); padding:16px; }
|
|
||||||
.xmind-calendar-exception-backdrop { position:fixed; z-index:110; }
|
|
||||||
.xmind-project-calendar,.xmind-calendar-exception { display:flex; flex-direction:column; width:740px; max-width:100%; max-height:calc(100svh - 32px); color:#343b48; font-size:12px; background:#f7f8fa; border:1px solid #dce1e8; border-radius:14px; box-shadow:0 18px 60px rgb(0 0 0 / .17); overflow:hidden; }
|
|
||||||
.xmind-project-calendar>header,.xmind-calendar-exception>header { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; padding:18px 20px; }
|
|
||||||
.xmind-project-calendar h2,.xmind-calendar-exception h2 { display:flex; align-items:center; gap:9px; font-size:16px; font-weight:600; }
|
|
||||||
.xmind-project-calendar>header p { margin-top:6px; font-size:11px; color:#8b929d; }
|
|
||||||
.xmind-project-calendar>header button,.xmind-calendar-exception>header button { padding:4px; border-radius:5px; }
|
|
||||||
.xmind-project-calendar-content { display:grid; grid-template-columns:290px minmax(0,1fr); gap:12px; padding:0 20px 18px; min-height:0; overflow:auto; }
|
|
||||||
.xmind-calendar-preview,.xmind-calendar-settings { padding:15px; border:1px solid #e0e4ea; border-radius:10px; background:#fff; }
|
|
||||||
.xmind-project-calendar label,.xmind-calendar-exception label { display:flex; flex-direction:column; gap:6px; font-size:11px; }
|
|
||||||
.xmind-project-calendar input:not([type=checkbox]),.xmind-project-calendar select,.xmind-calendar-exception input,.xmind-calendar-exception select { height:31px; padding:0 9px; border:1px solid #dce1e8; background:#fff; border-radius:6px; font-size:12px; outline:none; min-width:0; width:100%; }
|
|
||||||
.xmind-project-calendar input:focus,.xmind-project-calendar select:focus,.xmind-calendar-exception input:focus,.xmind-calendar-exception select:focus { border-color:#6592ef; box-shadow:0 0 0 2px rgb(53 105 232 / .1); }
|
|
||||||
.xmind-project-calendar h3 { font-size:12px; font-weight:600; }
|
|
||||||
.xmind-calendar-import { display:flex; align-items:flex-end; gap:8px; margin-bottom:18px; }.xmind-calendar-import label { flex:1; min-width:0; }.xmind-calendar-import>button { color:#3569e8; font-size:11px; margin-bottom:8px; }
|
|
||||||
.xmind-calendar-month-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:9px; }.xmind-calendar-month-header button { padding:5px; }.xmind-calendar-month-header strong { font-weight:500; }
|
|
||||||
.xmind-calendar-month-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:3px; }.xmind-calendar-month-grid>button,.xmind-calendar-weekday { height:29px; display:grid; place-items:center; border-radius:5px; font-size:11px; }
|
|
||||||
.xmind-calendar-weekday { color:#8b929d; }.xmind-calendar-month-grid .is-day-off { background:repeating-linear-gradient(135deg,#f1f3f6,#f1f3f6 3px,#e9ecf0 3px,#e9ecf0 4px); color:#89929e; }.xmind-calendar-month-grid .is-other-month { opacity:.4; }.xmind-calendar-month-grid>button[aria-pressed=true] { background:#edf3ff; box-shadow:inset 0 0 0 1px #6592ef; color:#3569e8; }
|
|
||||||
.xmind-calendar-hint { color:#929aa6; font-size:10px; line-height:1.6; margin-top:9px; }
|
|
||||||
.xmind-project-calendar .xmind-calendar-skip { flex-direction:row; align-items:center; gap:7px; margin:18px 0; font-size:12px; }.xmind-calendar-working-week { display:flex; justify-content:space-between; gap:5px; padding:12px 0 18px; border-bottom:1px solid #e9edf2; }.xmind-calendar-working-week label { align-items:center; }
|
|
||||||
.xmind-calendar-rule-heading { display:flex; align-items:center; justify-content:space-between; margin:16px 0 9px; }.xmind-calendar-rule-heading button { padding:5px; background:#f2f4f7; border-radius:5px; }.xmind-calendar-rules { max-height:225px; overflow:auto; }.xmind-calendar-rules>div { display:flex; justify-content:space-between; align-items:center; gap:7px; padding:9px 0; border-bottom:1px solid #eef0f4; }.xmind-calendar-rules>div>button:first-child { flex:1; text-align:left; min-width:0; }.xmind-calendar-rules strong,.xmind-calendar-rules span { display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.xmind-calendar-rules strong { font-weight:500; }.xmind-calendar-rules span { font-size:10px; color:#8b929d; margin-top:5px; }.xmind-calendar-rules>div>button:last-child { padding:6px; color:#8b929d; }.xmind-calendar-rules>p { color:#929aa6; font-size:11px; padding:10px 0; }
|
|
||||||
.xmind-project-calendar>footer,.xmind-calendar-exception>footer { padding:13px 20px; display:flex; justify-content:space-between; gap:10px; border-top:1px solid #e2e6ec; background:#fff; }.xmind-project-calendar>footer>div { display:flex; gap:8px; }.xmind-project-calendar>footer button,.xmind-calendar-exception>footer button,.xmind-calendar-personal>button { padding:8px 12px; border:1px solid #dce1e8; border-radius:6px; font-size:11px; }.xmind-project-calendar>footer .xmind-primary-button,.xmind-calendar-exception>footer .xmind-primary-button { border-color:#3569e8; }
|
|
||||||
.xmind-calendar-personal { border-top:1px solid #e6e9ee; margin-top:14px; padding-top:12px; }.xmind-calendar-personal p { color:#929aa6; font-size:10px; margin:7px 0; }.xmind-calendar-personal>div { display:flex; justify-content:space-between; align-items:center; margin:9px 0; }.xmind-calendar-personal>label { margin:12px 0 8px; }
|
|
||||||
.xmind-calendar-exception { width:420px; }.xmind-calendar-exception-fields { display:flex; flex-direction:column; gap:12px; padding:0 20px 20px; overflow:auto; }.xmind-calendar-range { display:grid; grid-template-columns:1fr 1fr; gap:12px; }.xmind-calendar-exception>footer { justify-content:flex-end; }
|
|
||||||
@media(max-width:700px) { .xmind-project-calendar-content { grid-template-columns:1fr; }.xmind-calendar-preview { max-width:360px; width:100%; justify-self:center; }.xmind-project-calendar>footer { flex-wrap:wrap; }.xmind-project-calendar>footer>div:last-child { margin-left:auto; } }
|
|
||||||
|
|
||||||
.xmind-notes-popover { position:absolute; z-index:40; width:min(360px,calc(100vw - 32px)); padding:8px; background:#fafbfc; border:1px solid #e5e8ed; border-radius:18px; box-shadow:0 14px 44px rgb(0 0 0 / .14); color:#30343b; }
|
|
||||||
.xmind-notes-popover>header { display:flex; align-items:center; justify-content:space-between; padding:0 6px; height:24px; font-size:11px; color:#9198a3; }.xmind-notes-popover>header>span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.xmind-notes-popover>header>div { display:flex; align-items:center; gap:8px; }
|
|
||||||
.xmind-notes-popover .xmind-notes-editor { border:0; border-radius:12px; }.xmind-notes-popover .xmind-notes-toolbar { border-bottom:0; padding:4px; }.xmind-notes-popover .xmind-note-content { min-height:220px; max-height:280px; padding:8px; }.xmind-note-empty { min-height:220px; padding:16px; font-size:12px; color:#8b929d; }
|
|
||||||
|
|
||||||
.xmind-image-export { width:400px; max-width:calc(100vw - 32px); border:1px solid #e5e5e8; border-radius:14px; padding:24px; color:#303034; background:white; box-shadow:0 18px 60px #0002; font:14px NeverMind,Arial,"PingFang SC",sans-serif; }
|
|
||||||
.xmind-image-export::backdrop { background:#0004; }
|
|
||||||
.xmind-image-export header,.xmind-image-export footer { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
|
||||||
.xmind-image-export h2 { margin:0; font-size:18px; font-weight:600; }
|
|
||||||
.xmind-image-export p { color:#737378; margin:18px 0 24px; line-height:1.6; }
|
|
||||||
.xmind-image-export button { display:inline-flex; gap:7px; align-items:center; justify-content:center; min-height:34px; border:0; border-radius:7px; padding:7px 12px; background:#f2f2f4; color:inherit; cursor:pointer; }
|
|
||||||
.xmind-image-export button:disabled { opacity:.55; cursor:wait; }
|
|
||||||
.xmind-image-export button.xmind-primary-button { background:#2463eb; color:white; }
|
|
||||||
.xmind-image-export-field { display:flex; align-items:center; justify-content:space-between; gap:20px; }
|
|
||||||
.xmind-image-export select { width:220px; height:36px; background:white; border:1px solid #dcdce2; border-radius:7px; padding:0 10px; color:inherit; }
|
|
||||||
.xmind-image-export-check { display:flex; align-items:center; gap:8px; margin:20px 0 28px; }
|
|
||||||
.xmind-image-export-check input { accent-color:#2463eb; }
|
|
||||||
.xmind-image-export footer { justify-content:flex-end; }
|
|
||||||
@ -1,318 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import {
|
|
||||||
Bold,
|
|
||||||
Italic,
|
|
||||||
Underline,
|
|
||||||
Strikethrough,
|
|
||||||
List,
|
|
||||||
ListOrdered,
|
|
||||||
Link2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
createEditor,
|
|
||||||
Editor,
|
|
||||||
Element,
|
|
||||||
Transforms,
|
|
||||||
type Descendant,
|
|
||||||
} from "slate";
|
|
||||||
import { withHistory } from "slate-history";
|
|
||||||
import {
|
|
||||||
Editable,
|
|
||||||
Slate,
|
|
||||||
withReact,
|
|
||||||
ReactEditor,
|
|
||||||
type RenderElementProps,
|
|
||||||
type RenderLeafProps,
|
|
||||||
} from "slate-react";
|
|
||||||
import {
|
|
||||||
type NoteDocument,
|
|
||||||
type NoteParagraph,
|
|
||||||
safeNoteLink,
|
|
||||||
serializeNoteDocument,
|
|
||||||
} from "@/lib/flow-board/topic-notes";
|
|
||||||
|
|
||||||
export function XmindNotesEditor({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: NoteDocument;
|
|
||||||
onChange: (value: NoteDocument) => void;
|
|
||||||
}) {
|
|
||||||
const [editor] = useState(() => withHistory(withReact(createEditor())));
|
|
||||||
const sourceRef = useRef(value);
|
|
||||||
sourceRef.current = value;
|
|
||||||
const onChangeRef = useRef(onChange);
|
|
||||||
onChangeRef.current = onChange;
|
|
||||||
const [revision, setRevision] = useState(0);
|
|
||||||
const [linkOpen, setLinkOpen] = useState(false),
|
|
||||||
[link, setLink] = useState("");
|
|
||||||
const [linkError, setLinkError] = useState("");
|
|
||||||
const savedSelection = useRef(editor.selection);
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
JSON.stringify(serializeNoteDocument(editor.children as NoteDocument)) ===
|
|
||||||
JSON.stringify(serializeNoteDocument(value))
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
editor.children = value as Descendant[];
|
|
||||||
editor.selection = null;
|
|
||||||
editor.history = { undos: [], redos: [] };
|
|
||||||
editor.onChange();
|
|
||||||
}, [editor, value]);
|
|
||||||
const marks = Editor.marks(editor) as Record<string, unknown> | null;
|
|
||||||
// Toolbar presses can arrive before Slate's throttled selection listener.
|
|
||||||
const syncDOMSelection = () => {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (
|
|
||||||
!selection?.anchorNode ||
|
|
||||||
!ReactEditor.hasDOMNode(editor, selection.anchorNode)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const range = ReactEditor.toSlateRange(editor, selection, {
|
|
||||||
exactMatch: false,
|
|
||||||
suppressThrow: true,
|
|
||||||
});
|
|
||||||
if (range) Transforms.select(editor, range);
|
|
||||||
};
|
|
||||||
const toggleMark = (
|
|
||||||
mark: "bold" | "italic" | "underline" | "strikethrough"
|
|
||||||
) => {
|
|
||||||
syncDOMSelection();
|
|
||||||
const currentMarks = Editor.marks(editor) as Record<string, unknown> | null;
|
|
||||||
if (currentMarks?.[mark]) Editor.removeMark(editor, mark);
|
|
||||||
else Editor.addMark(editor, mark, true);
|
|
||||||
setRevision((v) => v + 1);
|
|
||||||
};
|
|
||||||
const toggleList = (list: "bullet" | "number") => {
|
|
||||||
syncDOMSelection();
|
|
||||||
const match = Array.from(
|
|
||||||
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
|
|
||||||
)[0];
|
|
||||||
Transforms.setNodes(
|
|
||||||
editor,
|
|
||||||
{
|
|
||||||
list: (match?.[0] as NoteParagraph)?.list === list ? undefined : list,
|
|
||||||
} as Partial<Descendant>,
|
|
||||||
{ match: (n) => Element.isElement(n) }
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const applyLink = () => {
|
|
||||||
const href = link.trim() ? safeNoteLink(link.trim()) : "";
|
|
||||||
if (href === null) {
|
|
||||||
setLinkError("请输入完整的网页或邮件链接。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (savedSelection.current)
|
|
||||||
Transforms.select(editor, savedSelection.current);
|
|
||||||
if (href) Editor.addMark(editor, "href", href);
|
|
||||||
else Editor.removeMark(editor, "href");
|
|
||||||
setLinkOpen(false);
|
|
||||||
editor.onChange();
|
|
||||||
};
|
|
||||||
const leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
|
|
||||||
const t = leaf as {
|
|
||||||
bold?: boolean;
|
|
||||||
italic?: boolean;
|
|
||||||
underline?: boolean;
|
|
||||||
strikethrough?: boolean;
|
|
||||||
href?: string;
|
|
||||||
};
|
|
||||||
const href = safeNoteLink(t.href || "");
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
{...attributes}
|
|
||||||
style={{
|
|
||||||
fontWeight: t.bold ? 700 : undefined,
|
|
||||||
fontStyle: t.italic ? "italic" : undefined,
|
|
||||||
textDecoration:
|
|
||||||
[
|
|
||||||
t.underline ? "underline" : "",
|
|
||||||
t.strikethrough ? "line-through" : "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ") || undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{href ? (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
title={href}
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (!e.metaKey && !e.ctrlKey) e.preventDefault();
|
|
||||||
}}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const element = ({ attributes, children, element }: RenderElementProps) => {
|
|
||||||
const p = element as NoteParagraph;
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
{...attributes}
|
|
||||||
data-note-list={p.list}
|
|
||||||
style={{ marginLeft: Math.max(0, Math.min(120, p.indent || 0)) }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="xmind-notes-editor" data-editor-revision={revision}>
|
|
||||||
<Slate
|
|
||||||
editor={editor}
|
|
||||||
initialValue={value as Descendant[]}
|
|
||||||
onChange={(next) => {
|
|
||||||
setRevision((v) => v + 1);
|
|
||||||
if (editor.operations.every((o) => o.type === "set_selection"))
|
|
||||||
return;
|
|
||||||
const doc = next as NoteDocument;
|
|
||||||
if (
|
|
||||||
JSON.stringify(serializeNoteDocument(doc)) !==
|
|
||||||
JSON.stringify(serializeNoteDocument(sourceRef.current))
|
|
||||||
)
|
|
||||||
onChangeRef.current(doc);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="xmind-notes-toolbar"
|
|
||||||
role="toolbar"
|
|
||||||
aria-label="笔记格式"
|
|
||||||
>
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
["bold", "加粗", Bold],
|
|
||||||
["italic", "斜体", Italic],
|
|
||||||
["underline", "下划线", Underline],
|
|
||||||
["strikethrough", "删除线", Strikethrough],
|
|
||||||
] as const
|
|
||||||
).map(([mark, label, Icon]) => (
|
|
||||||
<button
|
|
||||||
key={mark}
|
|
||||||
type="button"
|
|
||||||
aria-label={`笔记${label}`}
|
|
||||||
aria-pressed={Boolean(marks?.[mark])}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleMark(mark);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon size={17} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="笔记无序列表"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleList("bullet");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<List size={17} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="笔记有序列表"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleList("number");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListOrdered size={17} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="笔记链接"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
syncDOMSelection();
|
|
||||||
savedSelection.current = editor.selection;
|
|
||||||
setLink(String(marks?.href || ""));
|
|
||||||
setLinkError("");
|
|
||||||
setLinkOpen(!linkOpen);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link2 size={17} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{linkOpen ? (
|
|
||||||
<div className="xmind-note-link-form">
|
|
||||||
<input
|
|
||||||
aria-label="笔记链接地址"
|
|
||||||
placeholder="https://…"
|
|
||||||
value={link}
|
|
||||||
onChange={(e) => setLink(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
applyLink();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button type="button" onClick={applyLink}>
|
|
||||||
确定
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="取消笔记链接"
|
|
||||||
onClick={() => setLinkOpen(false)}
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
{linkError ? <p role="alert">{linkError}</p> : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Editable
|
|
||||||
aria-label="主题备注"
|
|
||||||
className="xmind-note-content"
|
|
||||||
placeholder="为这个主题补充说明…"
|
|
||||||
renderLeaf={leaf}
|
|
||||||
renderElement={element}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (
|
|
||||||
(e.metaKey || e.ctrlKey) &&
|
|
||||||
["b", "i", "u"].includes(e.key.toLowerCase())
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleMark(
|
|
||||||
(
|
|
||||||
{ b: "bold", i: "italic", u: "underline" } as Record<
|
|
||||||
string,
|
|
||||||
"bold" | "italic" | "underline"
|
|
||||||
>
|
|
||||||
)[e.key.toLowerCase()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z")
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.key === "Tab") {
|
|
||||||
e.preventDefault();
|
|
||||||
const match = Array.from(
|
|
||||||
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
|
|
||||||
)[0];
|
|
||||||
const indent = (match?.[0] as NoteParagraph)?.indent || 0;
|
|
||||||
Transforms.setNodes(
|
|
||||||
editor,
|
|
||||||
{
|
|
||||||
indent: Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(120, indent + (e.shiftKey ? -20 : 20))
|
|
||||||
),
|
|
||||||
} as Partial<Descendant>,
|
|
||||||
{ match: (n) => Element.isElement(n) }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Slate>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import {
|
|
||||||
type PlaitBoard,
|
|
||||||
type PlaitElement,
|
|
||||||
toHostPointFromViewBoxPoint,
|
|
||||||
toScreenPointFromHostPoint,
|
|
||||||
} from "@plait/core";
|
|
||||||
import { getRectangleByNode, MindElement } from "@plait/mind";
|
|
||||||
import { MoreHorizontal, X } from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
getNoteDocument,
|
|
||||||
type NoteDocument,
|
|
||||||
} from "@/lib/flow-board/topic-notes";
|
|
||||||
import { getTopicTitle } from "@/lib/flow-board/topic-details";
|
|
||||||
import { XmindNotesEditor } from "./xmind-notes-editor";
|
|
||||||
export function XmindNotesPopover({
|
|
||||||
board,
|
|
||||||
topic,
|
|
||||||
viewport,
|
|
||||||
onChange,
|
|
||||||
onDetails,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
board: PlaitBoard | null;
|
|
||||||
topic: PlaitElement | null;
|
|
||||||
viewport: unknown;
|
|
||||||
onChange: (doc: NoteDocument) => void;
|
|
||||||
onDetails: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const ref = useRef<HTMLElement>(null);
|
|
||||||
const [position, setPosition] = useState({ left: 400, top: 300 });
|
|
||||||
useEffect(() => {
|
|
||||||
if (!board || !topic) return;
|
|
||||||
const update = () => {
|
|
||||||
const node = MindElement.getNode(topic as MindElement);
|
|
||||||
if (!node) return;
|
|
||||||
const rect = getRectangleByNode(node),
|
|
||||||
point = toScreenPointFromHostPoint(
|
|
||||||
board,
|
|
||||||
toHostPointFromViewBoxPoint(board, [
|
|
||||||
rect.x + rect.width / 2,
|
|
||||||
rect.y + rect.height,
|
|
||||||
])
|
|
||||||
);
|
|
||||||
const host = ref.current?.parentElement?.getBoundingClientRect();
|
|
||||||
const width = Math.min(360, window.innerWidth - 32);
|
|
||||||
const right = window.innerWidth > 800 ? 240 : 0;
|
|
||||||
const left = Math.max(
|
|
||||||
12,
|
|
||||||
Math.min(window.innerWidth - right - width - 16, point[0] - width / 2)
|
|
||||||
);
|
|
||||||
const top = Math.max(
|
|
||||||
78,
|
|
||||||
Math.min(window.innerHeight - 340, point[1] + 12)
|
|
||||||
);
|
|
||||||
setPosition({ left: left - (host?.x || 0), top: top - (host?.y || 0) });
|
|
||||||
};
|
|
||||||
const frame = requestAnimationFrame(update);
|
|
||||||
window.addEventListener("resize", update);
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(frame);
|
|
||||||
window.removeEventListener("resize", update);
|
|
||||||
};
|
|
||||||
}, [board, topic?.id, topic?.data, viewport]);
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
ref={ref}
|
|
||||||
className="xmind-notes-popover"
|
|
||||||
aria-label="主题笔记"
|
|
||||||
style={position}
|
|
||||||
data-flow-board-shell-control="true"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<header>
|
|
||||||
<span title={topic ? getTopicTitle(topic) : "笔记"}>
|
|
||||||
{topic ? getTopicTitle(topic) : "笔记"}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="主题详情"
|
|
||||||
title="主题详情"
|
|
||||||
onClick={onDetails}
|
|
||||||
>
|
|
||||||
<MoreHorizontal size={15} />
|
|
||||||
</button>
|
|
||||||
<button type="button" aria-label="关闭笔记" onClick={onClose}>
|
|
||||||
<X size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{topic ? (
|
|
||||||
<XmindNotesEditor
|
|
||||||
key={topic.id}
|
|
||||||
value={getNoteDocument(topic)}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="xmind-note-empty">选择一个主题以编辑笔记。</p>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,310 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
ArrowDown,
|
|
||||||
ArrowUp,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
GripVertical,
|
|
||||||
IndentDecrease,
|
|
||||||
IndentIncrease,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { PlaitElement } from "@plait/core";
|
|
||||||
import {
|
|
||||||
getTopicDetails,
|
|
||||||
getTopicEntries,
|
|
||||||
type TopicEntry,
|
|
||||||
} from "@/lib/flow-board/topic-details";
|
|
||||||
import type {
|
|
||||||
OutlineCommand,
|
|
||||||
OutlineDrop,
|
|
||||||
} from "@/lib/flow-board/mind-outline";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
zoomPercent: number;
|
|
||||||
elements: PlaitElement[];
|
|
||||||
selectedId: string | null;
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
onRename: (id: string, title: string) => void;
|
|
||||||
onToggle: (id: string) => void;
|
|
||||||
onCommand: (
|
|
||||||
id: string | null,
|
|
||||||
command: OutlineCommand | OutlineDrop
|
|
||||||
) => string | null;
|
|
||||||
onUndo: () => void;
|
|
||||||
onRedo: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function OutlineRow({
|
|
||||||
entry,
|
|
||||||
selected,
|
|
||||||
onSelect,
|
|
||||||
onRename,
|
|
||||||
onToggle,
|
|
||||||
onCommand,
|
|
||||||
onFocusTopic,
|
|
||||||
}: {
|
|
||||||
entry: TopicEntry;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: Props["onSelect"];
|
|
||||||
onRename: Props["onRename"];
|
|
||||||
onToggle: Props["onToggle"];
|
|
||||||
onCommand: Props["onCommand"];
|
|
||||||
onFocusTopic: (id: string | null, direction?: number) => void;
|
|
||||||
}) {
|
|
||||||
const { element, title, depth } = entry;
|
|
||||||
const [draft, setDraft] = useState(title);
|
|
||||||
const [drop, setDrop] = useState<OutlineDrop["placement"] | null>(null);
|
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
|
||||||
const details = getTopicDetails(element);
|
|
||||||
useEffect(() => setDraft(title), [title]);
|
|
||||||
const commit = () => {
|
|
||||||
if (draft !== title) onRename(element.id, draft);
|
|
||||||
};
|
|
||||||
const act = (command: OutlineCommand) => {
|
|
||||||
commit();
|
|
||||||
onFocusTopic(onCommand(element.id, command));
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-outline-id={element.id}
|
|
||||||
data-depth={depth}
|
|
||||||
className={`group flex min-h-11 items-center gap-1 border-b border-[#f1f2f5] px-3 ${selected ? "bg-[#f0f5ff]" : "hover:bg-[#fafbfc]"} ${drop === "child" ? "ring-1 ring-inset ring-blue-400" : drop === "before" ? "border-t-2 border-t-blue-400" : drop === "after" ? "border-b-2 border-b-blue-400" : ""}`}
|
|
||||||
style={{ paddingLeft: 12 + Math.min(depth, 20) * 24 }}
|
|
||||||
onDragOver={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const box = event.currentTarget.getBoundingClientRect();
|
|
||||||
const y = (event.clientY - box.top) / box.height;
|
|
||||||
setDrop(y < 0.25 ? "before" : y > 0.75 ? "after" : "child");
|
|
||||||
}}
|
|
||||||
onDragLeave={() => setDrop(null)}
|
|
||||||
onDrop={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const source = event.dataTransfer.getData("application/x-mind-topic");
|
|
||||||
if (source && drop)
|
|
||||||
onFocusTopic(
|
|
||||||
onCommand(source, { targetId: element.id, placement: drop })
|
|
||||||
);
|
|
||||||
setDrop(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
draggable
|
|
||||||
aria-label={`拖动 ${title}`}
|
|
||||||
className="cursor-grab text-[#9ca3af] opacity-30 group-hover:opacity-100"
|
|
||||||
onDragStart={(event) => {
|
|
||||||
event.dataTransfer.setData("application/x-mind-topic", element.id);
|
|
||||||
event.dataTransfer.effectAllowed = "move";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GripVertical size={13} />
|
|
||||||
</span>
|
|
||||||
{element.children?.length ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="p-1 text-[#64748b]"
|
|
||||||
aria-label={`${element.isCollapsed ? "展开" : "折叠"} ${title}`}
|
|
||||||
onClick={() => onToggle(element.id)}
|
|
||||||
>
|
|
||||||
{element.isCollapsed ? (
|
|
||||||
<ChevronRight size={14} />
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={14} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="w-[22px] text-center text-[#a2a9b3]">·</span>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
aria-label={`大纲主题 ${title}`}
|
|
||||||
data-outline-input={element.id}
|
|
||||||
className={`min-w-0 flex-1 bg-transparent py-2 text-sm outline-none ${depth === 0 ? "font-semibold text-[#253046]" : "text-[#404958]"}`}
|
|
||||||
value={draft}
|
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
|
||||||
onFocus={() => onSelect(element.id)}
|
|
||||||
onBlur={commit}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.nativeEvent.isComposing) return;
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
act(event.shiftKey || depth === 0 ? "child" : "sibling");
|
|
||||||
} else if (event.key === "Tab") {
|
|
||||||
event.preventDefault();
|
|
||||||
act(event.shiftKey ? "outdent" : "indent");
|
|
||||||
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
||||||
event.preventDefault();
|
|
||||||
commit();
|
|
||||||
if (event.altKey) act(event.key === "ArrowUp" ? "up" : "down");
|
|
||||||
else onFocusTopic(element.id, event.key === "ArrowUp" ? -1 : 1);
|
|
||||||
} else if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
setDraft(title);
|
|
||||||
} else if (
|
|
||||||
(event.metaKey || event.ctrlKey) &&
|
|
||||||
event.key.toLowerCase() === "z" &&
|
|
||||||
draft !== title
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
setDraft(title);
|
|
||||||
} else if (event.key === "Backspace" && !draft) {
|
|
||||||
event.preventDefault();
|
|
||||||
onFocusTopic(onCommand(element.id, "delete"));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{Number.isInteger(element.start) ? (
|
|
||||||
<span className="text-[10px] text-[#8d79b2]">概要</span>
|
|
||||||
) : null}
|
|
||||||
{details.notes ? (
|
|
||||||
<span title={details.notes} className="text-[10px] text-[#8a94a5]">
|
|
||||||
备注
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{details.labels.map((label) => (
|
|
||||||
<span
|
|
||||||
key={label}
|
|
||||||
className="hidden rounded bg-[#e8effc] px-1.5 py-0.5 text-[10px] text-[#5879b4] lg:inline"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`为 ${title} 添加子主题`}
|
|
||||||
className="p-1 text-[#8a94a5] opacity-0 focus:opacity-100 group-hover:opacity-100"
|
|
||||||
onClick={() => act("child")}
|
|
||||||
>
|
|
||||||
<Plus size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XmindOutline(props: Props) {
|
|
||||||
const { elements, selectedId, onCommand } = props;
|
|
||||||
const container = useRef<HTMLElement>(null);
|
|
||||||
const [focusId, setFocusId] = useState<string | null>(null);
|
|
||||||
const entries = getTopicEntries(elements);
|
|
||||||
const collapsedPaths = entries
|
|
||||||
.filter(({ element }) => element.isCollapsed)
|
|
||||||
.map((entry) => entry.path);
|
|
||||||
const visible = entries.filter(
|
|
||||||
(entry) =>
|
|
||||||
!collapsedPaths.some(
|
|
||||||
(path) =>
|
|
||||||
path.length < entry.path.length &&
|
|
||||||
path.every((index, i) => index === entry.path[i])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!focusId) return;
|
|
||||||
const input = Array.from(
|
|
||||||
container.current?.querySelectorAll<HTMLInputElement>(
|
|
||||||
"[data-outline-input]"
|
|
||||||
) || []
|
|
||||||
).find((node) => node.dataset.outlineInput === focusId);
|
|
||||||
if (input) {
|
|
||||||
input.focus();
|
|
||||||
input.select();
|
|
||||||
setFocusId(null);
|
|
||||||
}
|
|
||||||
}, [focusId, elements]);
|
|
||||||
const focusTopic = (id: string | null, direction?: number) => {
|
|
||||||
if (!id) return;
|
|
||||||
const target = direction
|
|
||||||
? visible[
|
|
||||||
visible.findIndex((entry) => entry.element.id === id) + direction
|
|
||||||
]?.element.id
|
|
||||||
: id;
|
|
||||||
if (target) setFocusId(target);
|
|
||||||
};
|
|
||||||
const action = (command: OutlineCommand) =>
|
|
||||||
focusTopic(onCommand(selectedId, command));
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
ref={container}
|
|
||||||
aria-label="大纲视图"
|
|
||||||
className="h-full overflow-auto bg-white"
|
|
||||||
data-flow-board-shell-control="true"
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (
|
|
||||||
(event.metaKey || event.ctrlKey) &&
|
|
||||||
event.key.toLowerCase() === "z"
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (event.shiftKey) props.onRedo();
|
|
||||||
else props.onUndo();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-1 border-b border-[#e5e8ee] bg-white px-5 py-3">
|
|
||||||
<h2 className="mr-2 text-sm font-semibold">大纲</h2>
|
|
||||||
<span className="mr-auto text-xs text-[#949ba7]">
|
|
||||||
{entries.length} 个主题
|
|
||||||
</span>
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
{ command: "child", label: "大纲新增子主题", icon: Plus },
|
|
||||||
{ command: "indent", label: "大纲缩进", icon: IndentIncrease },
|
|
||||||
{ command: "outdent", label: "大纲提升层级", icon: IndentDecrease },
|
|
||||||
{ command: "up", label: "大纲上移", icon: ArrowUp },
|
|
||||||
{ command: "down", label: "大纲下移", icon: ArrowDown },
|
|
||||||
{ command: "delete", label: "大纲删除主题", icon: Trash2 },
|
|
||||||
] as const
|
|
||||||
).map(({ command, label, icon: Icon }) => (
|
|
||||||
<button
|
|
||||||
key={command}
|
|
||||||
type="button"
|
|
||||||
title={label}
|
|
||||||
aria-label={label}
|
|
||||||
className="rounded p-1.5 text-[#64748b] hover:bg-[#edf3ff] disabled:opacity-30"
|
|
||||||
disabled={
|
|
||||||
(!selectedId && entries.length > 0) ||
|
|
||||||
(!selectedId && command !== "child")
|
|
||||||
}
|
|
||||||
onClick={() => action(command)}
|
|
||||||
>
|
|
||||||
<Icon size={16} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="mx-auto max-w-4xl px-4 py-6"
|
|
||||||
style={{ zoom: props.zoomPercent / 100 }}
|
|
||||||
>
|
|
||||||
{!entries.length ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => action("child")}
|
|
||||||
className="w-full rounded border border-dashed border-[#cbd5e1] p-8 text-sm text-[#64748b]"
|
|
||||||
>
|
|
||||||
添加中心主题
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
visible.map((entry) => (
|
|
||||||
<OutlineRow
|
|
||||||
key={entry.element.id}
|
|
||||||
entry={entry}
|
|
||||||
selected={entry.element.id === selectedId}
|
|
||||||
onSelect={props.onSelect}
|
|
||||||
onRename={props.onRename}
|
|
||||||
onToggle={props.onToggle}
|
|
||||||
onCommand={onCommand}
|
|
||||||
onFocusTopic={focusTopic}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<p className="mt-6 text-xs text-[#a1a8b4]">
|
|
||||||
Enter 新增同级 · Shift+Enter 新增子主题 · Tab 缩进 · Shift+Tab
|
|
||||||
提升层级 · Alt+↑↓ 调整顺序
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import type { PlaitElement } from "@plait/core";
|
|
||||||
import { getTopicEntries, getTopicTitle } from "@/lib/flow-board/topic-details";
|
|
||||||
|
|
||||||
export function XmindPresentation({ elements, title, onClose }: { elements: PlaitElement[]; title: string; onClose: () => void }) {
|
|
||||||
const slides = getTopicEntries(elements).filter(({ element, depth }) => depth === 0 || element.children?.length);
|
|
||||||
const [index,setIndex] = useState(0);
|
|
||||||
const entry = slides[Math.min(index,slides.length - 1)];
|
|
||||||
const next = () => setIndex(value => Math.min(slides.length - 1,value + 1));
|
|
||||||
const previous = () => setIndex(value => Math.max(0,value - 1));
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") { event.preventDefault(); onClose(); }
|
|
||||||
if (["ArrowRight","ArrowDown"," ","PageDown"].includes(event.key)) { event.preventDefault(); next(); }
|
|
||||||
if (["ArrowLeft","ArrowUp","PageUp"].includes(event.key)) { event.preventDefault(); previous(); }
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown",onKey);
|
|
||||||
return () => window.removeEventListener("keydown",onKey);
|
|
||||||
}, [onClose,slides.length]);
|
|
||||||
return <section className="xmind-presentation" aria-label="演示视图" data-flow-board-shell-control="true">
|
|
||||||
<header><span>{title}</span><button type="button" aria-label="退出演示" onClick={onClose}><X size={16} />退出演示</button></header>
|
|
||||||
<div className="xmind-presentation-slide"><h1>{entry?.title || title}</h1><ul>{(entry?.element.children || []).map(child => <li key={child.id}>{getTopicTitle(child)}</li>)}</ul></div>
|
|
||||||
<footer><button type="button" aria-label="上一页" disabled={index === 0} onClick={previous}><ChevronLeft size={18} /></button><span>{Math.min(index + 1,slides.length)} / {slides.length}</span><button type="button" aria-label="下一页" disabled={index >= slides.length - 1} onClick={next}><ChevronRight size={18} /></button></footer>
|
|
||||||
</section>;
|
|
||||||
}
|
|
||||||
@ -1,650 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
CalendarDays,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
CALENDAR_FREQUENCIES,
|
|
||||||
defaultWorkingCalendar,
|
|
||||||
isWorkingDate,
|
|
||||||
readWorkingCalendar,
|
|
||||||
validateWorkingCalendar,
|
|
||||||
type CalendarRule,
|
|
||||||
type ProjectCalendarConfig,
|
|
||||||
type ProjectWorkingCalendar,
|
|
||||||
} from "@/lib/flow-board/project-calendar";
|
|
||||||
import {
|
|
||||||
addDays,
|
|
||||||
dateIndex,
|
|
||||||
DAY,
|
|
||||||
indexDate,
|
|
||||||
todayDate,
|
|
||||||
} from "@/lib/flow-board/calendar-dates";
|
|
||||||
const STORAGE_KEY = "flow-board.personal-calendars.v1";
|
|
||||||
const WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"];
|
|
||||||
const FREQUENCY_LABELS: Record<CalendarRule["frequency"], string> = {
|
|
||||||
NONE: "不重复",
|
|
||||||
DAILY: "每天",
|
|
||||||
WEEKLY: "每周",
|
|
||||||
MONTHLY_BY_DATE: "每月(按日期)",
|
|
||||||
MONTHLY_BY_WEEKDAY: "每月(按星期)",
|
|
||||||
YEARLY: "每年",
|
|
||||||
};
|
|
||||||
const copy = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
|
|
||||||
function useCalendarDialogFocus(ref: React.RefObject<HTMLElement | null>) {
|
|
||||||
useEffect(() => {
|
|
||||||
const previous = document.activeElement as HTMLElement | null;
|
|
||||||
ref.current?.querySelector<HTMLElement>("input,button")?.focus();
|
|
||||||
return () => {
|
|
||||||
if (previous?.isConnected) previous.focus();
|
|
||||||
};
|
|
||||||
}, [ref]);
|
|
||||||
}
|
|
||||||
function trapCalendarTab(
|
|
||||||
event: React.KeyboardEvent,
|
|
||||||
panel: HTMLElement | null
|
|
||||||
) {
|
|
||||||
if (event.key !== "Tab") return;
|
|
||||||
const controls = Array.from(
|
|
||||||
panel?.querySelectorAll<HTMLElement>(
|
|
||||||
"button:not(:disabled),input:not(:disabled),select:not(:disabled)"
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
const first = controls[0],
|
|
||||||
last = controls.at(-1);
|
|
||||||
if (event.shiftKey && document.activeElement === first) {
|
|
||||||
event.preventDefault();
|
|
||||||
last?.focus();
|
|
||||||
}
|
|
||||||
if (!event.shiftKey && document.activeElement === last) {
|
|
||||||
event.preventDefault();
|
|
||||||
first?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function CalendarExceptionEditor({
|
|
||||||
rule,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
rule: CalendarRule;
|
|
||||||
onSave: (rule: CalendarRule) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const [draft, setDraft] = useState(() => copy(rule)),
|
|
||||||
[error, setError] = useState("");
|
|
||||||
const ref = useRef<HTMLElement>(null);
|
|
||||||
useCalendarDialogFocus(ref);
|
|
||||||
const patch = (changes: Partial<CalendarRule>) =>
|
|
||||||
setDraft((value) => ({ ...value, ...changes }));
|
|
||||||
const save = () => {
|
|
||||||
const problem = validateWorkingCalendar({
|
|
||||||
id: "validation",
|
|
||||||
name: "",
|
|
||||||
defaultWorkingDays: [1],
|
|
||||||
rules: [draft],
|
|
||||||
});
|
|
||||||
if (problem) {
|
|
||||||
setError(problem);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const selected = draft.selectedDates[0];
|
|
||||||
if (
|
|
||||||
(draft.effectiveRange?.start && selected < draft.effectiveRange.start) ||
|
|
||||||
(draft.effectiveRange?.end && selected > draft.effectiveRange.end)
|
|
||||||
) {
|
|
||||||
setError("例外日期必须在生效范围内。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSave({ ...draft, name: draft.name.trim() || "例外" });
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="xmind-calendar-exception-backdrop"
|
|
||||||
data-flow-board-shell-control="true"
|
|
||||||
onKeyUp={(event) => event.stopPropagation()}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
trapCalendarTab(event, ref.current);
|
|
||||||
if (event.key === "Escape") onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<section
|
|
||||||
ref={ref}
|
|
||||||
className="xmind-calendar-exception"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="工作日例外"
|
|
||||||
>
|
|
||||||
<header>
|
|
||||||
<h2>工作日例外</h2>
|
|
||||||
<button type="button" aria-label="关闭工作日例外" onClick={onClose}>
|
|
||||||
<X size={17} />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<div className="xmind-calendar-exception-fields">
|
|
||||||
<label>
|
|
||||||
名称
|
|
||||||
<input
|
|
||||||
aria-label="例外名称"
|
|
||||||
value={draft.name}
|
|
||||||
onChange={(event) => patch({ name: event.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
日期
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
aria-label="例外日期"
|
|
||||||
value={draft.selectedDates[0] || ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
patch({ selectedDates: [event.target.value] })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
类型
|
|
||||||
<select
|
|
||||||
aria-label="例外类型"
|
|
||||||
value={draft.type}
|
|
||||||
onChange={(event) =>
|
|
||||||
patch({ type: event.target.value as CalendarRule["type"] })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="NON_WORKING_DAY">休息日</option>
|
|
||||||
<option value="WORKING_DAY">工作日(补班)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
重复
|
|
||||||
<select
|
|
||||||
aria-label="例外重复规则"
|
|
||||||
value={draft.frequency}
|
|
||||||
onChange={(event) =>
|
|
||||||
patch({
|
|
||||||
frequency: event.target.value as CalendarRule["frequency"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{CALENDAR_FREQUENCIES.map((frequency) => (
|
|
||||||
<option key={frequency} value={frequency}>
|
|
||||||
{FREQUENCY_LABELS[frequency]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{draft.frequency !== "NONE" ? (
|
|
||||||
<label>
|
|
||||||
每隔
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
aria-label="例外重复间隔"
|
|
||||||
value={draft.interval || 1}
|
|
||||||
onChange={(event) =>
|
|
||||||
patch({ interval: Number(event.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
) : null}
|
|
||||||
<div className="xmind-calendar-range">
|
|
||||||
<label>
|
|
||||||
生效开始
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
aria-label="例外生效开始"
|
|
||||||
value={draft.effectiveRange?.start || ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
patch({
|
|
||||||
effectiveRange: {
|
|
||||||
...draft.effectiveRange,
|
|
||||||
start: event.target.value,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
生效结束
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
aria-label="例外生效结束"
|
|
||||||
value={draft.effectiveRange?.end || ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
patch({
|
|
||||||
effectiveRange: {
|
|
||||||
...draft.effectiveRange,
|
|
||||||
end: event.target.value,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p className="xmind-calendar-hint">
|
|
||||||
留空表示不限制生效日期。后添加的例外优先。
|
|
||||||
</p>
|
|
||||||
{error ? (
|
|
||||||
<p role="alert" className="xmind-task-error">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<footer>
|
|
||||||
<button type="button" onClick={onClose}>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button type="button" className="xmind-primary-button" onClick={save}>
|
|
||||||
保存例外
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export function XmindProjectCalendar({
|
|
||||||
config,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
config: ProjectCalendarConfig;
|
|
||||||
onSave: (config: ProjectCalendarConfig) => boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const ref = useRef<HTMLElement>(null);
|
|
||||||
useCalendarDialogFocus(ref);
|
|
||||||
const [draft, setDraft] = useState(() =>
|
|
||||||
copy(config.settings || defaultWorkingCalendar())
|
|
||||||
);
|
|
||||||
const [skip, setSkip] = useState(config.settings ? config.skip : true),
|
|
||||||
[error, setError] = useState("");
|
|
||||||
const [selectedDate, setSelectedDate] = useState(todayDate()),
|
|
||||||
[month, setMonth] = useState(todayDate().slice(0, 7));
|
|
||||||
const [exception, setException] = useState<CalendarRule | null>(null),
|
|
||||||
[manage, setManage] = useState(false);
|
|
||||||
const [templates, setTemplates] = useState<ProjectWorkingCalendar[]>(() => {
|
|
||||||
try {
|
|
||||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
|
||||||
return Array.isArray(stored)
|
|
||||||
? stored
|
|
||||||
.map(readWorkingCalendar)
|
|
||||||
.filter((item): item is ProjectWorkingCalendar => Boolean(item))
|
|
||||||
: [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const [templateName, setTemplateName] = useState("");
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
const storeTemplates = (value: ProjectWorkingCalendar[]) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
|
|
||||||
setTemplates(value);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
setError("个人日历保存失败,请检查浏览器存储空间。");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const save = () => {
|
|
||||||
const problem = validateWorkingCalendar(draft);
|
|
||||||
if (problem) {
|
|
||||||
setError(problem);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
onSave({
|
|
||||||
settings: { ...draft, name: draft.name.trim() || "项目日历" },
|
|
||||||
skip,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
const saveTemplate = () => {
|
|
||||||
const problem = validateWorkingCalendar(draft);
|
|
||||||
if (problem) {
|
|
||||||
setError(problem);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!templateName.trim()) {
|
|
||||||
setError("请输入个人日历名称。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const value = {
|
|
||||||
...copy(draft),
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
name: templateName.trim(),
|
|
||||||
};
|
|
||||||
if (storeTemplates([...templates, value])) {
|
|
||||||
setTemplateName("");
|
|
||||||
setError("");
|
|
||||||
setMessage(`已保存个人日历:${value.name}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const moveMonth = (step: number) => {
|
|
||||||
const date = new Date(`${month}-01T00:00:00Z`);
|
|
||||||
date.setUTCMonth(date.getUTCMonth() + step);
|
|
||||||
setMonth(date.toISOString().slice(0, 7));
|
|
||||||
};
|
|
||||||
const first = dateIndex(`${month}-01`),
|
|
||||||
offset = (new Date(first * DAY).getUTCDay() + 6) % 7;
|
|
||||||
const days = Array.from({ length: 42 }, (_, index) =>
|
|
||||||
indexDate(first - offset + index)
|
|
||||||
);
|
|
||||||
const addException = () =>
|
|
||||||
setException({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
name: "",
|
|
||||||
type: isWorkingDate(selectedDate, draft)
|
|
||||||
? "NON_WORKING_DAY"
|
|
||||||
: "WORKING_DAY",
|
|
||||||
frequency: "NONE",
|
|
||||||
selectedDates: [selectedDate],
|
|
||||||
effectiveRange: { start: selectedDate, end: selectedDate },
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="xmind-project-calendar-backdrop"
|
|
||||||
data-flow-board-shell-control="true"
|
|
||||||
onKeyUp={(event) => event.stopPropagation()}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (exception) return;
|
|
||||||
trapCalendarTab(event, ref.current);
|
|
||||||
if (event.key === "Escape") onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<section
|
|
||||||
ref={ref}
|
|
||||||
className="xmind-project-calendar"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="项目工作日"
|
|
||||||
>
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<h2>
|
|
||||||
<CalendarDays size={19} />
|
|
||||||
项目工作日
|
|
||||||
</h2>
|
|
||||||
<p>只应用于当前工作表,不影响其他工作表。</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" aria-label="关闭项目工作日" onClick={onClose}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<div className="xmind-project-calendar-content">
|
|
||||||
<div className="xmind-calendar-preview">
|
|
||||||
<div className="xmind-calendar-import">
|
|
||||||
<label>
|
|
||||||
导入日历
|
|
||||||
<select
|
|
||||||
aria-label="导入个人日历"
|
|
||||||
value=""
|
|
||||||
onChange={(event) => {
|
|
||||||
const value =
|
|
||||||
event.target.value === "basic"
|
|
||||||
? { ...defaultWorkingCalendar(), name: "基本日历" }
|
|
||||||
: templates.find(
|
|
||||||
(item) => item.id === event.target.value
|
|
||||||
);
|
|
||||||
if (value) {
|
|
||||||
setDraft(copy(value));
|
|
||||||
setError("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">当前项目:{draft.name}</option>
|
|
||||||
<option value="basic">基本日历(周一至周五)</option>
|
|
||||||
{templates.map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setManage((value) => !value)}
|
|
||||||
>
|
|
||||||
管理
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="xmind-calendar-month-header">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="上个月"
|
|
||||||
onClick={() => moveMonth(-1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={16} />
|
|
||||||
</button>
|
|
||||||
<strong>{month.replace("-", "年")}月</strong>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="下个月"
|
|
||||||
onClick={() => moveMonth(1)}
|
|
||||||
>
|
|
||||||
<ChevronRight size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="xmind-calendar-month-grid" aria-label="工作日预览">
|
|
||||||
{WEEKDAYS.map((day) => (
|
|
||||||
<span className="xmind-calendar-weekday" key={day}>
|
|
||||||
{day}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{days.map((date) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={date}
|
|
||||||
data-calendar-date={date}
|
|
||||||
data-working={isWorkingDate(date, draft)}
|
|
||||||
aria-label={`${date} ${isWorkingDate(date, draft) ? "工作日" : "休息日"}`}
|
|
||||||
aria-pressed={selectedDate === date}
|
|
||||||
className={`${date.slice(0, 7) !== month ? "is-other-month" : ""} ${isWorkingDate(date, draft) ? "" : "is-day-off"}`}
|
|
||||||
onClick={() => setSelectedDate(date)}
|
|
||||||
>
|
|
||||||
{Number(date.slice(8))}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="xmind-calendar-hint">
|
|
||||||
阴影为休息日。选择日期后可添加例外。
|
|
||||||
</p>
|
|
||||||
{manage ? (
|
|
||||||
<div className="xmind-calendar-personal">
|
|
||||||
<h3>个人日历</h3>
|
|
||||||
<p>保存在当前浏览器,可用于其他工作表。</p>
|
|
||||||
{templates.length ? (
|
|
||||||
templates.map((item) => (
|
|
||||||
<div key={item.id}>
|
|
||||||
<span>{item.name}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`删除个人日历 ${item.name}`}
|
|
||||||
onClick={() =>
|
|
||||||
storeTemplates(
|
|
||||||
templates.filter((value) => value.id !== item.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p>还没有个人日历。</p>
|
|
||||||
)}
|
|
||||||
<label>
|
|
||||||
名称
|
|
||||||
<input
|
|
||||||
aria-label="个人日历名称"
|
|
||||||
value={templateName}
|
|
||||||
onChange={(event) => setTemplateName(event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button type="button" onClick={saveTemplate}>
|
|
||||||
保存为个人日历
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="xmind-calendar-settings">
|
|
||||||
<label>
|
|
||||||
日历名称
|
|
||||||
<input
|
|
||||||
aria-label="项目日历名称"
|
|
||||||
value={draft.name}
|
|
||||||
onChange={(event) =>
|
|
||||||
setDraft((value) => ({ ...value, name: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="xmind-calendar-skip">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label="跳过非工作日"
|
|
||||||
checked={skip}
|
|
||||||
onChange={(event) => setSkip(event.target.checked)}
|
|
||||||
/>
|
|
||||||
跳过非工作日
|
|
||||||
</label>
|
|
||||||
<h3>每周工作日</h3>
|
|
||||||
<div className="xmind-calendar-working-week">
|
|
||||||
{WEEKDAYS.map((day, index) => (
|
|
||||||
<label key={day}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={`周${day}为工作日`}
|
|
||||||
checked={draft.defaultWorkingDays.includes(index + 1)}
|
|
||||||
onChange={(event) => {
|
|
||||||
const value = event.target.checked
|
|
||||||
? [...draft.defaultWorkingDays, index + 1].sort()
|
|
||||||
: draft.defaultWorkingDays.filter(
|
|
||||||
(value) => value !== index + 1
|
|
||||||
);
|
|
||||||
if (!value.length) {
|
|
||||||
setError("请至少保留一个每周工作日。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDraft((settings) => ({
|
|
||||||
...settings,
|
|
||||||
defaultWorkingDays: value,
|
|
||||||
}));
|
|
||||||
setError("");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{day}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="xmind-calendar-rule-heading">
|
|
||||||
<h3>例外</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="添加工作日例外"
|
|
||||||
onClick={addException}
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="xmind-calendar-rules">
|
|
||||||
{[...draft.rules].reverse().map((rule) => (
|
|
||||||
<div key={rule.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`编辑工作日例外 ${rule.name}`}
|
|
||||||
onClick={() => setException(copy(rule))}
|
|
||||||
>
|
|
||||||
<strong>{rule.name}</strong>
|
|
||||||
<span>
|
|
||||||
{rule.type === "WORKING_DAY" ? "补班" : "休息日"} ·{" "}
|
|
||||||
{rule.selectedDates.join("、")} ·{" "}
|
|
||||||
{FREQUENCY_LABELS[rule.frequency]}
|
|
||||||
{rule.interval && rule.interval > 1
|
|
||||||
? `(间隔 ${rule.interval})`
|
|
||||||
: ""}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`删除工作日例外 ${rule.name}`}
|
|
||||||
onClick={() =>
|
|
||||||
setDraft((value) => ({
|
|
||||||
...value,
|
|
||||||
rules: value.rules.filter(
|
|
||||||
(item) => item.id !== rule.id
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!draft.rules.length ? (
|
|
||||||
<p>可添加节假日、调休或重复例外。</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{error ? (
|
|
||||||
<p role="alert" className="xmind-task-error">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{message ? <p role="status">{message}</p> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer>
|
|
||||||
<div>
|
|
||||||
{config.settings ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="xmind-danger"
|
|
||||||
onClick={() => {
|
|
||||||
if (onSave({ settings: null, skip: false })) onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
移除项目日历
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button type="button" onClick={() => setManage(true)}>
|
|
||||||
保存为日历
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button type="button" onClick={onClose}>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="xmind-primary-button"
|
|
||||||
onClick={save}
|
|
||||||
>
|
|
||||||
保存工作日
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
{exception ? (
|
|
||||||
<CalendarExceptionEditor
|
|
||||||
rule={exception}
|
|
||||||
onSave={(rule) =>
|
|
||||||
setDraft((value) => ({
|
|
||||||
...value,
|
|
||||||
rules: value.rules.some((item) => item.id === rule.id)
|
|
||||||
? value.rules.map((item) => (item.id === rule.id ? rule : item))
|
|
||||||
: [...value.rules, rule],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
onClose={() => setException(null)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { searchTopics, type SearchSheet, type TopicSearchResult } from "@/lib/flow-board/topic-search";
|
|
||||||
|
|
||||||
export function XmindSearchPanel({ sheets, activeSheetId, onJump, onReplace, onClose }: {
|
|
||||||
sheets: SearchSheet[];
|
|
||||||
activeSheetId: string;
|
|
||||||
onJump: (result: TopicSearchResult) => void;
|
|
||||||
onReplace: (results: TopicSearchResult[], query: string, replacement: string, caseSensitive: boolean) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [replacement, setReplacement] = useState("");
|
|
||||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
|
||||||
const [scope, setScope] = useState("current");
|
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
const input = useRef<HTMLInputElement>(null);
|
|
||||||
const results = searchTopics(scope === "all" ? sheets : sheets.filter(sheet => sheet.id === activeSheetId),query,caseSensitive);
|
|
||||||
useEffect(() => { input.current?.focus(); }, []);
|
|
||||||
useEffect(() => { setActiveIndex(0); }, [query,caseSensitive,scope]);
|
|
||||||
const navigate = (step: number) => {
|
|
||||||
if (!results.length) return;
|
|
||||||
const next = (Math.min(activeIndex,results.length - 1) + step + results.length) % results.length;
|
|
||||||
setActiveIndex(next); onJump(results[next]);
|
|
||||||
};
|
|
||||||
return <aside className="xmind-search-panel" aria-label="查找与替换" data-flow-board-shell-control="true" onKeyDown={event => {
|
|
||||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); }
|
|
||||||
}}>
|
|
||||||
<div className="xmind-panel-heading"><span>查找与替换</span><button type="button" aria-label="关闭查找" onClick={onClose}><X size={16} /></button></div>
|
|
||||||
<div className="xmind-panel-content">
|
|
||||||
<label className="xmind-search-box"><Search size={15} /><input ref={input} aria-label="查找主题" placeholder="查找主题" value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); navigate(e.shiftKey ? -1 : 1); } }} /></label>
|
|
||||||
<div className="xmind-search-options"><label><input type="checkbox" checked={caseSensitive} onChange={e => setCaseSensitive(e.target.checked)} /> 区分大小写</label></div>
|
|
||||||
<select aria-label="查找范围" value={scope} onChange={e => setScope(e.target.value)}><option value="current">当前画布</option><option value="all">所有画布</option></select>
|
|
||||||
<label className="xmind-search-box"><input aria-label="替换为" placeholder="替换为" value={replacement} onChange={e => setReplacement(e.target.value)} /></label>
|
|
||||||
<div className="xmind-search-replace-buttons">
|
|
||||||
<button type="button" disabled={!results.length} onClick={() => onReplace([results[Math.min(activeIndex,results.length - 1)]],query,replacement,caseSensitive)}>替换</button>
|
|
||||||
<button type="button" disabled={!results.length} onClick={() => onReplace(results,query,replacement,caseSensitive)}>全部替换</button>
|
|
||||||
</div>
|
|
||||||
<div className="xmind-search-navigation"><span role="status">{query ? `${results.length} 个匹配主题` : "输入内容开始查找"}</span><button type="button" aria-label="上一个结果" disabled={!results.length} onClick={() => navigate(-1)}><ChevronUp size={16} /></button><button type="button" aria-label="下一个结果" disabled={!results.length} onClick={() => navigate(1)}><ChevronDown size={16} /></button></div>
|
|
||||||
<div aria-label="查找结果">{results.map((result,index) => <button type="button" key={`${result.sheetId}:${result.id}`} aria-label={`定位 ${result.title}`} className={`xmind-search-result ${index === activeIndex ? "is-active" : ""}`} onClick={() => { setActiveIndex(index); onJump(result); }}><span>{result.title}</span><small>{result.sheetTitle} · {result.occurrences} 处匹配</small></button>)}</div>
|
|
||||||
</div>
|
|
||||||
</aside>;
|
|
||||||
}
|
|
||||||
@ -1,384 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { CalendarDays, X } from "lucide-react";
|
|
||||||
import type { PlaitElement } from "@plait/core";
|
|
||||||
import { getTopicEntries, getTopicTitle } from "@/lib/flow-board/topic-details";
|
|
||||||
import {
|
|
||||||
activeWorkingCalendar,
|
|
||||||
getProjectCalendar,
|
|
||||||
taskDueDate,
|
|
||||||
workingDaysBetween,
|
|
||||||
} from "@/lib/flow-board/project-calendar";
|
|
||||||
import {
|
|
||||||
DAY,
|
|
||||||
getTopicTask,
|
|
||||||
newTopicTask,
|
|
||||||
validateTopicTask,
|
|
||||||
type TopicTask,
|
|
||||||
type TaskDependency,
|
|
||||||
} from "@/lib/flow-board/topic-tasks";
|
|
||||||
export function XmindTaskEditor({
|
|
||||||
topic,
|
|
||||||
elements,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
onGantt,
|
|
||||||
onCalendar,
|
|
||||||
}: {
|
|
||||||
topic: PlaitElement;
|
|
||||||
elements: PlaitElement[];
|
|
||||||
onSave: (id: string, task: TopicTask | null) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onGantt: () => void;
|
|
||||||
onCalendar: () => void;
|
|
||||||
}) {
|
|
||||||
const dialogRef = useRef<HTMLElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const previous = document.activeElement as HTMLElement | null;
|
|
||||||
dialogRef.current?.querySelector<HTMLInputElement>("input")?.focus();
|
|
||||||
return () => {
|
|
||||||
if (previous?.isConnected) previous.focus();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
const calendar = activeWorkingCalendar(getProjectCalendar(elements));
|
|
||||||
const existing = getTopicTask(topic);
|
|
||||||
const [draft, setDraft] = useState<TopicTask>(
|
|
||||||
() => existing || newTopicTask()
|
|
||||||
);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [assigneesDraft, setAssigneesDraft] = useState(
|
|
||||||
(existing?.assignees || []).join(", ")
|
|
||||||
);
|
|
||||||
const [dependency, setDependency] = useState("");
|
|
||||||
const [dependencyType, setDependencyType] =
|
|
||||||
useState<TaskDependency["type"]>("FS");
|
|
||||||
const [lag, setLag] = useState("0");
|
|
||||||
const source = JSON.stringify(existing);
|
|
||||||
useEffect(() => {
|
|
||||||
setDraft(getTopicTask(topic) || newTopicTask());
|
|
||||||
setAssigneesDraft((getTopicTask(topic)?.assignees || []).join(", "));
|
|
||||||
setError("");
|
|
||||||
}, [topic.id, source]);
|
|
||||||
const tasks = getTopicEntries(elements).filter(
|
|
||||||
(e) => e.element.id !== topic.id && getTopicTask(e.element)
|
|
||||||
);
|
|
||||||
const patch = (p: Partial<TopicTask>) => setDraft((t) => ({ ...t, ...p }));
|
|
||||||
const withAssignees = () => ({
|
|
||||||
...draft,
|
|
||||||
assignees: [
|
|
||||||
...new Set(
|
|
||||||
assigneesDraft
|
|
||||||
.split(/[,,]/)
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const save = () => {
|
|
||||||
const task = withAssignees();
|
|
||||||
const problem = validateTopicTask(topic.id, task, elements);
|
|
||||||
if (problem) {
|
|
||||||
setError(problem);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSave(topic.id, task);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="xmind-task-backdrop"
|
|
||||||
data-flow-board-shell-control="true"
|
|
||||||
onKeyUp={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
if (e.key === "Tab") {
|
|
||||||
const controls = Array.from(
|
|
||||||
dialogRef.current?.querySelectorAll<HTMLElement>(
|
|
||||||
"button:not(:disabled),input,select"
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
const first = controls[0],
|
|
||||||
last = controls.at(-1);
|
|
||||||
if (e.shiftKey && document.activeElement === first) {
|
|
||||||
e.preventDefault();
|
|
||||||
last?.focus();
|
|
||||||
}
|
|
||||||
if (!e.shiftKey && document.activeElement === last) {
|
|
||||||
e.preventDefault();
|
|
||||||
first?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<section
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="任务信息"
|
|
||||||
className="xmind-task-editor"
|
|
||||||
ref={dialogRef}
|
|
||||||
>
|
|
||||||
<header>
|
|
||||||
<h2>
|
|
||||||
<CalendarDays size={17} />
|
|
||||||
任务信息
|
|
||||||
</h2>
|
|
||||||
<button type="button" aria-label="关闭任务信息" onClick={onClose}>
|
|
||||||
<X size={17} />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<p className="xmind-task-name">{getTopicTitle(topic)}</p>
|
|
||||||
<div className="xmind-task-fields">
|
|
||||||
<label>
|
|
||||||
开始日期
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
aria-label="任务开始日期"
|
|
||||||
value={draft.start}
|
|
||||||
onChange={(e) => {
|
|
||||||
const start = e.target.value;
|
|
||||||
patch({
|
|
||||||
start,
|
|
||||||
due:
|
|
||||||
start && draft.duration >= 1
|
|
||||||
? taskDueDate(start, draft.duration, calendar)
|
|
||||||
: "",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
截止日期
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
aria-label="任务截止日期"
|
|
||||||
value={draft.due}
|
|
||||||
onChange={(e) => {
|
|
||||||
const due = e.target.value;
|
|
||||||
patch({
|
|
||||||
due,
|
|
||||||
...(draft.start && due && due >= draft.start
|
|
||||||
? {
|
|
||||||
duration: Math.max(
|
|
||||||
1,
|
|
||||||
workingDaysBetween(draft.start, due, calendar)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{calendar ? "时长(工作日)" : "时长(天)"}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
aria-label="任务工期"
|
|
||||||
min={1}
|
|
||||||
max={36500}
|
|
||||||
value={draft.duration}
|
|
||||||
onChange={(e) => {
|
|
||||||
const duration = Number(e.target.value);
|
|
||||||
patch({
|
|
||||||
duration,
|
|
||||||
...(draft.start && duration >= 1 && duration <= 36500
|
|
||||||
? { due: taskDueDate(draft.start, duration, calendar) }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
进度(%)
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
aria-label="任务进度"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={draft.progress}
|
|
||||||
onChange={(e) => patch({ progress: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
优先级
|
|
||||||
<select
|
|
||||||
aria-label="任务优先级"
|
|
||||||
value={draft.priority ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
patch({
|
|
||||||
priority:
|
|
||||||
e.target.value === "" ? null : Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">无</option>
|
|
||||||
{Array.from({ length: 10 }, (_, i) => (
|
|
||||||
<option value={i} key={i}>
|
|
||||||
P{i}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
负责人
|
|
||||||
<input
|
|
||||||
aria-label="任务负责人"
|
|
||||||
placeholder="多人用逗号分隔"
|
|
||||||
value={assigneesDraft}
|
|
||||||
onChange={(e) => setAssigneesDraft(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<h3>前置任务</h3>
|
|
||||||
<div className="xmind-task-dependencies">
|
|
||||||
{draft.dependencies.map((dep, i) => (
|
|
||||||
<div key={`${dep.id}-${i}`}>
|
|
||||||
<span>
|
|
||||||
{tasks.find((e) => e.element.id === dep.id)?.title ||
|
|
||||||
"已删除的任务"}{" "}
|
|
||||||
· {dep.type}
|
|
||||||
{dep.lag ? ` · ${dep.lag / DAY} 天` : ""}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`移除前置任务 ${tasks.find((e) => e.element.id === dep.id)?.title || dep.id}`}
|
|
||||||
onClick={() =>
|
|
||||||
patch({
|
|
||||||
dependencies: draft.dependencies.filter((_, n) => n !== i),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="xmind-task-add-dependency">
|
|
||||||
<select
|
|
||||||
aria-label="前置任务"
|
|
||||||
value={dependency}
|
|
||||||
onChange={(e) => setDependency(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">选择任务</option>
|
|
||||||
{tasks.map((e) => (
|
|
||||||
<option value={e.element.id} key={e.element.id}>
|
|
||||||
{e.title}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="依赖类型"
|
|
||||||
value={dependencyType}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDependencyType(e.target.value as TaskDependency["type"])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="FS">完成 → 开始</option>
|
|
||||||
<option value="SS">开始 → 开始</option>
|
|
||||||
<option value="FF">完成 → 完成</option>
|
|
||||||
<option value="SF">开始 → 完成</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
aria-label="依赖间隔天数"
|
|
||||||
value={lag}
|
|
||||||
onChange={(e) => setLag(e.target.value)}
|
|
||||||
title="间隔天数(负数为提前)"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!dependency}
|
|
||||||
onClick={() => {
|
|
||||||
const next = {
|
|
||||||
...draft,
|
|
||||||
dependencies: [
|
|
||||||
...draft.dependencies.filter((d) => d.id !== dependency),
|
|
||||||
{
|
|
||||||
id: dependency,
|
|
||||||
type: dependencyType,
|
|
||||||
lag: Number(lag) * DAY,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const problem = validateTopicTask(topic.id, next, elements);
|
|
||||||
if (problem) setError(problem);
|
|
||||||
else {
|
|
||||||
setDraft(next);
|
|
||||||
setDependency("");
|
|
||||||
setError("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
添加
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{error ? (
|
|
||||||
<p role="alert" className="xmind-task-error">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<div className="xmind-task-calendar-info">
|
|
||||||
<span>
|
|
||||||
{calendar ? `已跳过非工作日 · ${calendar.name}` : "包含非工作日"}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const task = withAssignees(),
|
|
||||||
problem = validateTopicTask(topic.id, task, elements);
|
|
||||||
if (problem) {
|
|
||||||
setError(problem);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSave(topic.id, task);
|
|
||||||
onClose();
|
|
||||||
onCalendar();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
项目工作日
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<footer>
|
|
||||||
{existing ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="xmind-danger"
|
|
||||||
onClick={() => {
|
|
||||||
onSave(topic.id, null);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
移除任务
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button type="button" onClick={onClose}>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const task = withAssignees();
|
|
||||||
const problem = validateTopicTask(topic.id, task, elements);
|
|
||||||
if (problem) {
|
|
||||||
setError(problem);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSave(topic.id, task);
|
|
||||||
onClose();
|
|
||||||
onGantt();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
查看甘特图
|
|
||||||
</button>
|
|
||||||
<button type="button" className="xmind-primary-button" onClick={save}>
|
|
||||||
保存任务
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import {
|
|
||||||
type PlaitBoard,
|
|
||||||
type PlaitElement,
|
|
||||||
toHostPointFromViewBoxPoint,
|
|
||||||
toScreenPointFromHostPoint,
|
|
||||||
} from "@plait/core";
|
|
||||||
import { MindElement, getRectangleByNode } from "@plait/mind";
|
|
||||||
import { FileText } from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { getTopicDetails, getTopicTitle } from "@/lib/flow-board/topic-details";
|
|
||||||
import { getTopicTask } from "@/lib/flow-board/topic-tasks";
|
|
||||||
export function XmindTopicBadges({
|
|
||||||
board,
|
|
||||||
elements,
|
|
||||||
viewport,
|
|
||||||
onNotes,
|
|
||||||
onTask,
|
|
||||||
}: {
|
|
||||||
board: PlaitBoard | null;
|
|
||||||
elements: PlaitElement[];
|
|
||||||
viewport: unknown;
|
|
||||||
onNotes: (id: string) => void;
|
|
||||||
onTask: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [badges, setBadges] = useState<
|
|
||||||
{
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
notes: boolean;
|
|
||||||
progress: number | null;
|
|
||||||
left: number;
|
|
||||||
top: number;
|
|
||||||
}[]
|
|
||||||
>([]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!board) return;
|
|
||||||
const update = () => {
|
|
||||||
const host = hostRef.current;
|
|
||||||
if (!host) return;
|
|
||||||
const origin = host.getBoundingClientRect();
|
|
||||||
const result: typeof badges = [];
|
|
||||||
const visit = (children: PlaitElement[]) => {
|
|
||||||
for (const element of children) {
|
|
||||||
const notes = Boolean(getTopicDetails(element).notes.trim()),
|
|
||||||
task = getTopicTask(element);
|
|
||||||
if (notes || task) {
|
|
||||||
const node = MindElement.getNode(element as MindElement);
|
|
||||||
if (node) {
|
|
||||||
const rect = getRectangleByNode(node);
|
|
||||||
const point = toScreenPointFromHostPoint(
|
|
||||||
board,
|
|
||||||
toHostPointFromViewBoxPoint(board, [
|
|
||||||
rect.x + rect.width,
|
|
||||||
rect.y + rect.height / 2,
|
|
||||||
])
|
|
||||||
);
|
|
||||||
result.push({
|
|
||||||
id: element.id,
|
|
||||||
title: getTopicTitle(element),
|
|
||||||
notes,
|
|
||||||
progress: task?.progress ?? null,
|
|
||||||
left: point[0] - origin.x + 5,
|
|
||||||
top: point[1] - origin.y - 9,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!element.isCollapsed && element.children) visit(element.children);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
visit(elements);
|
|
||||||
setBadges(result);
|
|
||||||
};
|
|
||||||
const frame = requestAnimationFrame(update);
|
|
||||||
window.addEventListener("resize", update);
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(frame);
|
|
||||||
window.removeEventListener("resize", update);
|
|
||||||
};
|
|
||||||
}, [board, elements, viewport]);
|
|
||||||
return (
|
|
||||||
<div ref={hostRef} className="xmind-topic-badges" aria-label="主题内容提示">
|
|
||||||
{badges.map((b) => (
|
|
||||||
<div
|
|
||||||
key={b.id}
|
|
||||||
className="xmind-topic-badge"
|
|
||||||
style={{ left: b.left, top: b.top }}
|
|
||||||
data-flow-board-shell-control="true"
|
|
||||||
>
|
|
||||||
{b.progress !== null ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`编辑任务 ${b.title}`}
|
|
||||||
title={`任务进度 ${b.progress}%`}
|
|
||||||
onClick={() => onTask(b.id)}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx={8}
|
|
||||||
cy={8}
|
|
||||||
r={6}
|
|
||||||
fill="#f8fafc"
|
|
||||||
stroke="#afb7c4"
|
|
||||||
strokeWidth={1.4}
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx={8}
|
|
||||||
cy={8}
|
|
||||||
r={6}
|
|
||||||
fill="none"
|
|
||||||
stroke="#6b8ee4"
|
|
||||||
strokeWidth={3}
|
|
||||||
strokeDasharray={`${(b.progress * 37.7) / 100} 37.7`}
|
|
||||||
transform="rotate(-90 8 8)"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{b.notes ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`查看笔记 ${b.title}`}
|
|
||||||
title="查看笔记"
|
|
||||||
onClick={() => onNotes(b.id)}
|
|
||||||
>
|
|
||||||
<FileText size={15} />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user