Compare commits
2 Commits
main
...
codex/vide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39d58d0f2c | ||
|
|
85d678eaf4 |
@ -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
|
|
||||||
11
.gitignore
vendored
11
.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/
|
||||||
@ -126,7 +120,6 @@ services/smart-edit/__pycache__/
|
|||||||
# Local verification artefacts (also excluded from Tailwind's automatic source scan).
|
# Local verification artefacts (also excluded from Tailwind's automatic source scan).
|
||||||
.qa/
|
.qa/
|
||||||
.next-video-*/
|
.next-video-*/
|
||||||
.next-tvstar-*/
|
|
||||||
|
|
||||||
services/video-upscale/dist/
|
services/video-upscale/dist/
|
||||||
services/video-upscale/__pycache__/
|
services/video-upscale/__pycache__/
|
||||||
@ -134,7 +127,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>
|
||||||
|
|
||||||
{/* 批量处理配置 */}
|
{/* 批量处理配置 */}
|
||||||
|
|||||||
@ -8906,7 +8906,9 @@ export function AdminSettingsClient() {
|
|||||||
{
|
{
|
||||||
key: "realtime-canvas",
|
key: "realtime-canvas",
|
||||||
title: "实时与队列",
|
title: "实时与队列",
|
||||||
description: "生图并发不限,任务即时发起",
|
description: `单模型并发 ${canvasImageTaskQueueConfig.concurrency},批次间隔 ${(
|
||||||
|
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
|
||||||
|
).toFixed(1)} 秒`,
|
||||||
status: realtimeCanvasConfig.enabled ? "实时已启用" : "实时已关闭",
|
status: realtimeCanvasConfig.enabled ? "实时已启用" : "实时已关闭",
|
||||||
tone: realtimeCanvasConfig.enabled ? "enabled" : "neutral",
|
tone: realtimeCanvasConfig.enabled ? "enabled" : "neutral",
|
||||||
icon: SlidersHorizontal,
|
icon: SlidersHorizontal,
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
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 { geolocation } from "@vercel/functions";
|
import { geolocation } from "@vercel/functions";
|
||||||
import {
|
import {
|
||||||
@ -196,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,
|
||||||
@ -368,10 +365,7 @@ import {
|
|||||||
resolveReplacementReferenceGeometry,
|
resolveReplacementReferenceGeometry,
|
||||||
resolveReplacementScenePlanningRuntime,
|
resolveReplacementScenePlanningRuntime,
|
||||||
} from "./_lib/detail-replacement";
|
} from "./_lib/detail-replacement";
|
||||||
import {
|
import { selectDetailReplacementGenerationInputs } from "@/lib/ai/detail-replacement-inputs";
|
||||||
selectDetailReplacementGenerationInputs,
|
|
||||||
shouldIncludeDetailReplacementModelImage,
|
|
||||||
} from "@/lib/ai/detail-replacement-inputs";
|
|
||||||
import {
|
import {
|
||||||
normalizeAgentReportForDisplay,
|
normalizeAgentReportForDisplay,
|
||||||
sanitizeAgentOutputSettingsSection,
|
sanitizeAgentOutputSettingsSection,
|
||||||
@ -426,6 +420,8 @@ const chatApiLogger = createLogger("chat.route");
|
|||||||
|
|
||||||
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
||||||
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
||||||
|
const DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN =
|
||||||
|
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
|
||||||
|
|
||||||
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
|
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
|
||||||
const normalizedMarkdown = String(markdown || "").trim();
|
const normalizedMarkdown = String(markdown || "").trim();
|
||||||
@ -449,6 +445,35 @@ function getPublicImageGenerationErrorText(error: unknown) {
|
|||||||
return publicMessage;
|
return publicMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replacementScenePlanShouldIncludeModelImage(scenePlan: any) {
|
||||||
|
if (!scenePlan) return false;
|
||||||
|
if (scenePlan.useModelImage === true) return true;
|
||||||
|
if (
|
||||||
|
scenePlan.peoplePolicy &&
|
||||||
|
scenePlan.peoplePolicy !== "none"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (scenePlan.generationRoute === "people_relation_locked") return true;
|
||||||
|
if (scenePlan.pageKind === "people_scene" || scenePlan.pageKind === "action_poster") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
scenePlan.modelImageReason,
|
||||||
|
scenePlan.routeReason,
|
||||||
|
scenePlan.sceneSummary,
|
||||||
|
scenePlan.cameraSummary,
|
||||||
|
scenePlan.placementSummary,
|
||||||
|
scenePlan.preserveSummary,
|
||||||
|
scenePlan.styleSummary,
|
||||||
|
scenePlan.avoidSummary,
|
||||||
|
scenePlan.generationPrompt,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
|
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
|
||||||
1,
|
1,
|
||||||
@ -907,15 +932,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();
|
||||||
@ -1210,14 +1226,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);
|
||||||
@ -1338,7 +1346,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:
|
||||||
@ -5935,7 +5942,7 @@ export async function POST(request: Request) {
|
|||||||
const shouldIncludeModelImage =
|
const shouldIncludeModelImage =
|
||||||
detailReplacementStrategy !== "product_only" &&
|
detailReplacementStrategy !== "product_only" &&
|
||||||
Boolean(modelInputImageUrl) &&
|
Boolean(modelInputImageUrl) &&
|
||||||
(shouldIncludeDetailReplacementModelImage(
|
(replacementScenePlanShouldIncludeModelImage(
|
||||||
replacementExecutionPlan.scenePlan,
|
replacementExecutionPlan.scenePlan,
|
||||||
) ||
|
) ||
|
||||||
selectedProductContextImageUrls.length === 0);
|
selectedProductContextImageUrls.length === 0);
|
||||||
@ -6828,7 +6835,7 @@ export async function POST(request: Request) {
|
|||||||
!(isDetailReplacementPreset &&
|
!(isDetailReplacementPreset &&
|
||||||
detailReplacementStrategy === "product_only") &&
|
detailReplacementStrategy === "product_only") &&
|
||||||
Boolean(modelInputImageUrl) &&
|
Boolean(modelInputImageUrl) &&
|
||||||
(shouldIncludeDetailReplacementModelImage(
|
(replacementScenePlanShouldIncludeModelImage(
|
||||||
replacementExecutionPlan.scenePlan,
|
replacementExecutionPlan.scenePlan,
|
||||||
) ||
|
) ||
|
||||||
(isDetailReplacementPreset &&
|
(isDetailReplacementPreset &&
|
||||||
@ -8886,7 +8893,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) {
|
||||||
@ -11243,7 +11250,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(
|
||||||
@ -12272,70 +12279,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),
|
||||||
|
|||||||
@ -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,295 +0,0 @@
|
|||||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
|
|
||||||
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
|
|
||||||
import {
|
|
||||||
createCanvasImageTask,
|
|
||||||
enqueueCanvasImageTask,
|
|
||||||
ensureCanvasImageTaskActive,
|
|
||||||
getCanvasImageTaskForUser,
|
|
||||||
} from "@/lib/canvas/image-task-queue";
|
|
||||||
import {
|
|
||||||
checkUserImageQuota,
|
|
||||||
getChatById,
|
|
||||||
getEnabledAiModelsWithProvider,
|
|
||||||
getPointsSystemConfig,
|
|
||||||
getUserById,
|
|
||||||
saveChat,
|
|
||||||
} from "@/lib/db/queries";
|
|
||||||
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
|
||||||
import { resolveDirectReplacementModelRef } from "@/app/(chat)/api/chat/_lib/detail-replacement";
|
|
||||||
import {
|
|
||||||
buildAgentSlotPrompt,
|
|
||||||
normalizeAgentSupplementPrompt,
|
|
||||||
} from "@/app/(chat)/api/chat/_lib/agent-scene-workflow";
|
|
||||||
import {
|
|
||||||
detailReplacementSubmissionKey,
|
|
||||||
isDetailReplacementBatchRequest,
|
|
||||||
isDetailReplacementUuid,
|
|
||||||
type DetailReplacementBatchReference,
|
|
||||||
type DetailReplacementBatchRequest,
|
|
||||||
type DetailReplacementBatchResult,
|
|
||||||
} from "@/lib/agents/detail-replacement-batch";
|
|
||||||
|
|
||||||
export const maxDuration = 1200;
|
|
||||||
|
|
||||||
function scopedChatId(projectId: string) {
|
|
||||||
const normalized = projectId.toLowerCase().replace(/-/g, "");
|
|
||||||
const scoped = `${normalized.slice(0, 20)}a93e17a60001`;
|
|
||||||
return [scoped.slice(0, 8), scoped.slice(8, 12), scoped.slice(12, 16), scoped.slice(16, 20), scoped.slice(20, 32)].join("-");
|
|
||||||
}
|
|
||||||
|
|
||||||
function taskResponse(task: Awaited<ReturnType<typeof getCanvasImageTaskForUser>>) {
|
|
||||||
const result = task?.result as DetailReplacementBatchResult | null;
|
|
||||||
const request = task?.request as DetailReplacementBatchRequest | null;
|
|
||||||
return {
|
|
||||||
id: task?.id,
|
|
||||||
status: task?.status,
|
|
||||||
attempts: task?.attempts,
|
|
||||||
error: task?.error || null,
|
|
||||||
cards: Array.isArray(result?.cards) ? result.cards : [],
|
|
||||||
currentCardId: result?.currentCardId || null,
|
|
||||||
requestedCardIds: request?.references?.map((item) => item.id) || [],
|
|
||||||
createdAt: task?.createdAt || null,
|
|
||||||
queueStatus: task?.queueStatus || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findActiveBatchId(params: {
|
|
||||||
userId: string;
|
|
||||||
projectId: string;
|
|
||||||
submissionKey: string;
|
|
||||||
}) {
|
|
||||||
const [row] = await db.select({ id: canvasImageTask.id })
|
|
||||||
.from(canvasImageTask)
|
|
||||||
.where(and(
|
|
||||||
eq(canvasImageTask.userId, params.userId),
|
|
||||||
inArray(canvasImageTask.status, ["pending", "running"]),
|
|
||||||
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
|
|
||||||
sql`${canvasImageTask.request}->>'projectId' = ${params.projectId}`,
|
|
||||||
sql`${canvasImageTask.request}->>'submissionKey' = ${params.submissionKey}`,
|
|
||||||
))
|
|
||||||
.orderBy(desc(canvasImageTask.createdAt)).limit(1);
|
|
||||||
return row?.id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUniqueViolation(error: unknown) {
|
|
||||||
for (let cursor: unknown = error; cursor && typeof cursor === "object"; ) {
|
|
||||||
if ((cursor as { code?: unknown }).code === "23505") return true;
|
|
||||||
cursor = (cursor as { cause?: unknown }).cause;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
const params = new URL(request.url).searchParams;
|
|
||||||
const projectId = String(params.get("projectId") || "").trim();
|
|
||||||
const taskId = String(params.get("taskId") || "").trim();
|
|
||||||
if (!isDetailReplacementUuid(projectId)) {
|
|
||||||
return NextResponse.json({ error: "项目编号无效" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const [project] = await db.select({ id: canvasProject.id }).from(canvasProject).where(and(
|
|
||||||
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
|
|
||||||
)).limit(1);
|
|
||||||
if (!project) return NextResponse.json({ error: "项目不存在" }, { status: 404 });
|
|
||||||
|
|
||||||
const [candidate] = await db.select({ id: canvasImageTask.id, request: canvasImageTask.request })
|
|
||||||
.from(canvasImageTask)
|
|
||||||
.where(and(
|
|
||||||
eq(canvasImageTask.userId, session.user.id),
|
|
||||||
...(isDetailReplacementUuid(taskId) ? [eq(canvasImageTask.id, taskId)] : []),
|
|
||||||
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
|
|
||||||
sql`${canvasImageTask.request}->>'projectId' = ${projectId}`,
|
|
||||||
))
|
|
||||||
// A batch that is still working matters more than a newer finished one, so the
|
|
||||||
// workspace keeps showing live progress after a rerun of a single card.
|
|
||||||
.orderBy(
|
|
||||||
sql`case when ${canvasImageTask.status} in ('pending', 'running') then 0 else 1 end`,
|
|
||||||
desc(canvasImageTask.createdAt),
|
|
||||||
).limit(1);
|
|
||||||
if (!candidate || !isDetailReplacementBatchRequest(candidate.request)) {
|
|
||||||
return NextResponse.json({ task: null });
|
|
||||||
}
|
|
||||||
let task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
|
|
||||||
if (task && (task.status === "failed" ||
|
|
||||||
(task.status === "running" && Date.now() - task.updatedAt.getTime() > 90_000))) {
|
|
||||||
await ensureCanvasImageTaskActive({ taskId: task.id, userId: session.user.id });
|
|
||||||
task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
|
|
||||||
}
|
|
||||||
return NextResponse.json({ task: taskResponse(task) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
const body = await request.json();
|
|
||||||
const projectId = String(body.projectId || "").trim();
|
|
||||||
const taskId = String(body.requestId || "").trim();
|
|
||||||
if (!isDetailReplacementUuid(projectId) || !isDetailReplacementUuid(taskId)) {
|
|
||||||
return NextResponse.json({ error: "任务编号或项目编号无效" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const [project] = await db.select().from(canvasProject).where(and(
|
|
||||||
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
|
|
||||||
)).limit(1);
|
|
||||||
if (!project || !isAgentProjectRow(project)) {
|
|
||||||
return NextResponse.json({ error: "风格化项目不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = extractAgentProjectStateFromCanvasData(project.canvasData);
|
|
||||||
const knownReferences = new Map(
|
|
||||||
(state.workflowState.agentModeState?.detailReplacement?.references || [])
|
|
||||||
.map((item) => [String(item.id), String(item.imageUrl)]),
|
|
||||||
);
|
|
||||||
const requested = Array.isArray(body.references) ? body.references : [];
|
|
||||||
const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
|
|
||||||
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
|
|
||||||
return {
|
|
||||||
id: String(item.id || "").slice(0, 128),
|
|
||||||
label: String(item.label || "详情参考图").slice(0, 255),
|
|
||||||
imageUrl: String(item.imageUrl || "").trim(),
|
|
||||||
candidateId: String(item.candidateId || "").slice(0, 128) || null,
|
|
||||||
passthrough: item.passthrough === true,
|
|
||||||
rerunReferenceImages: Array.isArray(item.rerunReferenceImages)
|
|
||||||
? item.rerunReferenceImages.slice(0, 6).map((entry: { url?: string }) => ({ url: String(entry?.url || "") }))
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
references.length === 0 || references.length > 40 ||
|
|
||||||
new Set(references.map((item) => item.id)).size !== references.length ||
|
|
||||||
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
|
|
||||||
) {
|
|
||||||
return NextResponse.json({ error: "详情参考图尚未保存或已发生变化,请刷新后重试" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const productImageUrls = Array.isArray(body.productImageUrls)
|
|
||||||
? Array.from(new Set(body.productImageUrls.map((url: unknown) => String(url || "").trim())
|
|
||||||
.filter((url: string) => /^https?:\/\//i.test(url)))).slice(0, 16) as string[]
|
|
||||||
: [];
|
|
||||||
if (productImageUrls.length === 0 && !references.every((item) => item.passthrough)) {
|
|
||||||
return NextResponse.json({ error: "请先在输入图区选择至少一张产品图" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const strategy = body.replacementStrategy === "product_only" ? "product_only" : "scene_replace";
|
|
||||||
const prompt = String(body.prompt || "").trim().slice(0, 24_000);
|
|
||||||
if (strategy !== "product_only" && !prompt) {
|
|
||||||
return NextResponse.json({ error: "请先配置风格化提示词" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const models = await getEnabledAiModelsWithProvider();
|
|
||||||
const model = await resolveDirectReplacementModelRef({
|
|
||||||
selectedChatModel: String(body.model || "auto"),
|
|
||||||
finalModelId: String(body.model || "auto"),
|
|
||||||
models,
|
|
||||||
});
|
|
||||||
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
|
|
||||||
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const quota = await checkUserImageQuota(session.user.id);
|
|
||||||
if (!quota.allowed) {
|
|
||||||
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });
|
|
||||||
}
|
|
||||||
const requestedImageCount = references.filter((item) => !item.passthrough).length;
|
|
||||||
if (quota.limit != null && quota.used + requestedImageCount > quota.limit) {
|
|
||||||
return NextResponse.json({ error: `本月剩余生图配额不足 ${requestedImageCount} 张` }, { status: 429 });
|
|
||||||
}
|
|
||||||
const pointsConfig = await getPointsSystemConfig();
|
|
||||||
if (pointsConfig.enabled && pointsConfig.blockOnInsufficient !== false) {
|
|
||||||
const selectedModel = models.find((entry) => entry.model.id === model)?.model;
|
|
||||||
const unitCost = getModelGenerationCreditCost(
|
|
||||||
selectedModel as Parameters<typeof getModelGenerationCreditCost>[0], "image", {
|
|
||||||
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
|
|
||||||
});
|
|
||||||
const user = await getUserById({ id: session.user.id });
|
|
||||||
const expectedCost = unitCost * requestedImageCount;
|
|
||||||
if (!user?.isHighVip && expectedCost > Number(user?.pointsBalance || 0)) {
|
|
||||||
return NextResponse.json({ error: `积分不足,本批次预计消耗 ${expectedCost} 积分` }, { status: 402 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const chatId = scopedChatId(projectId);
|
|
||||||
const existingChat = await getChatById({ id: chatId });
|
|
||||||
if (existingChat && existingChat.userId !== session.user.id) {
|
|
||||||
return NextResponse.json({ error: "无权访问项目对话" }, { status: 403 });
|
|
||||||
}
|
|
||||||
if (!existingChat) await saveChat({
|
|
||||||
id: chatId, userId: session.user.id, title: project.title || "详情替换", visibility: "private",
|
|
||||||
});
|
|
||||||
const taskRequest: DetailReplacementBatchRequest = {
|
|
||||||
taskKind: "agent-detail-replacement-batch",
|
|
||||||
projectId,
|
|
||||||
chatId,
|
|
||||||
model,
|
|
||||||
prompt: strategy === "product_only"
|
|
||||||
? "仅替换参考详情图中的产品主体"
|
|
||||||
: buildAgentSlotPrompt({
|
|
||||||
workflowPrompt: prompt,
|
|
||||||
slotPrompt: "",
|
|
||||||
userSupplementPrompt: normalizeAgentSupplementPrompt(
|
|
||||||
String(body.userPrompt || ""), "详情参考图替换",
|
|
||||||
),
|
|
||||||
includeSlotTitle: false,
|
|
||||||
modeType: "detail_replacement",
|
|
||||||
}),
|
|
||||||
userPrompt: String(body.userPrompt || "").slice(0, 12_000),
|
|
||||||
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
|
|
||||||
replacementStrategy: strategy,
|
|
||||||
productImageUrls,
|
|
||||||
modelImageUrl: /^https?:\/\//i.test(String(body.modelImageUrl || ""))
|
|
||||||
? String(body.modelImageUrl) : null,
|
|
||||||
references,
|
|
||||||
};
|
|
||||||
const submissionKey = detailReplacementSubmissionKey(taskRequest);
|
|
||||||
taskRequest.submissionKey = submissionKey;
|
|
||||||
|
|
||||||
const duplicateId = await findActiveBatchId({
|
|
||||||
userId: session.user.id, projectId, submissionKey,
|
|
||||||
});
|
|
||||||
if (duplicateId) {
|
|
||||||
await ensureCanvasImageTaskActive({ taskId: duplicateId, userId: session.user.id });
|
|
||||||
const duplicate = await getCanvasImageTaskForUser({ taskId: duplicateId, userId: session.user.id });
|
|
||||||
return NextResponse.json({ task: taskResponse(duplicate) });
|
|
||||||
}
|
|
||||||
|
|
||||||
let task: Awaited<ReturnType<typeof createCanvasImageTask>>;
|
|
||||||
try {
|
|
||||||
task = await createCanvasImageTask({
|
|
||||||
taskId, userId: session.user.id, request: taskRequest as unknown as Record<string, unknown>,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (!isUniqueViolation(error)) throw error;
|
|
||||||
// Two identical submissions raced past the lookup above; the database
|
|
||||||
// unique index kept one of them, so answer with that batch.
|
|
||||||
const racedId = await findActiveBatchId({
|
|
||||||
userId: session.user.id, projectId, submissionKey,
|
|
||||||
});
|
|
||||||
if (!racedId) throw error;
|
|
||||||
const raced = await getCanvasImageTaskForUser({ taskId: racedId, userId: session.user.id });
|
|
||||||
return NextResponse.json({ task: taskResponse(raced) });
|
|
||||||
}
|
|
||||||
const existingTask = await getCanvasImageTaskForUser({ taskId: task.id, userId: session.user.id });
|
|
||||||
const storedRequest = existingTask?.request as DetailReplacementBatchRequest | null;
|
|
||||||
if (
|
|
||||||
!isDetailReplacementBatchRequest(storedRequest) ||
|
|
||||||
storedRequest.projectId !== projectId ||
|
|
||||||
(storedRequest.submissionKey && storedRequest.submissionKey !== submissionKey)
|
|
||||||
) {
|
|
||||||
return NextResponse.json({ error: "任务编号已被其他请求使用" }, { status: 409 });
|
|
||||||
}
|
|
||||||
await db.insert(message).values({
|
|
||||||
id: taskId,
|
|
||||||
chatId,
|
|
||||||
role: "user",
|
|
||||||
parts: [{ type: "text", text: taskRequest.userPrompt || `批量替换 ${references.length} 张详情图` }],
|
|
||||||
attachments: [],
|
|
||||||
createdAt: new Date(),
|
|
||||||
}).onConflictDoNothing({ target: message.id });
|
|
||||||
await enqueueCanvasImageTask(task.id);
|
|
||||||
const current = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
|
|
||||||
return NextResponse.json({ task: taskResponse(current) });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Detail Replacement Batch] Failed to start", error);
|
|
||||||
return NextResponse.json({ error: error instanceof Error ? error.message : "提交详情替换任务失败" }, { 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 =
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
import { and, desc, eq, lte } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { tvstarSession } from "@/lib/db/schema";
|
|
||||||
import { ownsTaskProject } from "@/lib/video-canvas/task-store";
|
|
||||||
import { tvstarSessionSchema } from "@/lib/video-canvas/tvstar-session";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
async function authorize(projectId: string) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return { error: NextResponse.json({ error: "未登录" }, { status: 401 }) };
|
|
||||||
if (!z.string().uuid().safeParse(projectId).success || !await ownsTaskProject(session.user.id, projectId)) {
|
|
||||||
return { error: NextResponse.json({ error: "画布不存在" }, { status: 404 }) };
|
|
||||||
}
|
|
||||||
return { userId: session.user.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await params;
|
|
||||||
const access = await authorize(id);
|
|
||||||
if (access.error) return access.error;
|
|
||||||
const rows = await db.select({ snapshot: tvstarSession.snapshot, shareToken: tvstarSession.shareToken })
|
|
||||||
.from(tvstarSession)
|
|
||||||
.where(and(eq(tvstarSession.userId, access.userId!), eq(tvstarSession.projectId, id)))
|
|
||||||
.orderBy(desc(tvstarSession.clientUpdatedAt)).limit(20);
|
|
||||||
return NextResponse.json({ sessions: rows.map(({ snapshot, shareToken }) => ({
|
|
||||||
...(snapshot as Record<string, unknown>), shareToken: shareToken || undefined,
|
|
||||||
})) }, { headers: { "Cache-Control": "no-store" } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await params;
|
|
||||||
const access = await authorize(id);
|
|
||||||
if (access.error) return access.error;
|
|
||||||
const raw = await request.text();
|
|
||||||
if (raw.length > 900_000) return NextResponse.json({ error: "对话记录过大" }, { status: 413 });
|
|
||||||
let body: unknown;
|
|
||||||
try { body = JSON.parse(raw); } catch { return NextResponse.json({ error: "请求格式无效" }, { status: 400 }); }
|
|
||||||
const parsed = z.object({ sessions: z.array(tvstarSessionSchema).max(20) }).safeParse(body);
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: "对话记录格式无效" }, { status: 400 });
|
|
||||||
for (const session of parsed.data.sessions) {
|
|
||||||
const clientUpdatedAt = new Date(Math.min(session.updatedAt, Date.now() + 60_000));
|
|
||||||
await db.insert(tvstarSession).values({
|
|
||||||
id: session.id, projectId: id, userId: access.userId!, title: session.title,
|
|
||||||
snapshot: session, clientUpdatedAt,
|
|
||||||
}).onConflictDoNothing({ target: tvstarSession.id });
|
|
||||||
await db.update(tvstarSession).set({ title: session.title, snapshot: session, clientUpdatedAt, updatedAt: new Date() })
|
|
||||||
.where(and(
|
|
||||||
eq(tvstarSession.id, session.id), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!),
|
|
||||||
lte(tvstarSession.clientUpdatedAt, clientUpdatedAt),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { tvstarSession } from "@/lib/db/schema";
|
|
||||||
import { ownsTaskProject } from "@/lib/video-canvas/task-store";
|
|
||||||
import { publicTVstarSnapshot, tvstarSessionSchema } from "@/lib/video-canvas/tvstar-session";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
async function owner(projectId: string) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return { error: NextResponse.json({ error: "未登录" }, { status: 401 }) };
|
|
||||||
if (!z.string().uuid().safeParse(projectId).success || !await ownsTaskProject(session.user.id, projectId)) {
|
|
||||||
return { error: NextResponse.json({ error: "画布不存在" }, { status: 404 }) };
|
|
||||||
}
|
|
||||||
return { userId: session.user.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await params;
|
|
||||||
const access = await owner(id);
|
|
||||||
if (access.error) return access.error;
|
|
||||||
const raw = await request.text();
|
|
||||||
if (raw.length > 300_000) return NextResponse.json({ error: "对话记录过大" }, { status: 413 });
|
|
||||||
let body: unknown;
|
|
||||||
try { body = JSON.parse(raw); } catch { return NextResponse.json({ error: "请求格式无效" }, { status: 400 }); }
|
|
||||||
const parsed = z.object({ session: tvstarSessionSchema }).safeParse(body);
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: "对话记录格式无效" }, { status: 400 });
|
|
||||||
const current = parsed.data.session;
|
|
||||||
const condition = and(eq(tvstarSession.id, current.id), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!));
|
|
||||||
await db.insert(tvstarSession).values({
|
|
||||||
id: current.id, projectId: id, userId: access.userId!, title: current.title,
|
|
||||||
snapshot: current, clientUpdatedAt: new Date(Math.min(current.updatedAt, Date.now() + 60_000)),
|
|
||||||
}).onConflictDoNothing({ target: tvstarSession.id });
|
|
||||||
const existing = (await db.select({ shareToken: tvstarSession.shareToken }).from(tvstarSession).where(condition).limit(1))[0];
|
|
||||||
if (!existing) return NextResponse.json({ error: "对话不存在" }, { status: 404 });
|
|
||||||
const token = existing.shareToken || randomBytes(24).toString("base64url");
|
|
||||||
if (!existing.shareToken) {
|
|
||||||
await db.update(tvstarSession).set({
|
|
||||||
shareToken: token, sharedSnapshot: publicTVstarSnapshot(current), sharedAt: new Date(), updatedAt: new Date(),
|
|
||||||
}).where(condition);
|
|
||||||
}
|
|
||||||
return NextResponse.json({ url: `/tvstar/share/${token}`, token });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await params;
|
|
||||||
const access = await owner(id);
|
|
||||||
if (access.error) return access.error;
|
|
||||||
const sessionId = new URL(request.url).searchParams.get("sessionId") || "";
|
|
||||||
if (!z.string().uuid().safeParse(sessionId).success) return NextResponse.json({ error: "对话 ID 无效" }, { status: 400 });
|
|
||||||
await db.update(tvstarSession).set({ shareToken: null, sharedSnapshot: null, sharedAt: null, updatedAt: new Date() })
|
|
||||||
.where(and(eq(tvstarSession.id, sessionId), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!)));
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@ -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,9 +1,5 @@
|
|||||||
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
|
|
||||||
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,
|
||||||
@ -13,31 +9,6 @@ import {
|
|||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 120;
|
||||||
|
|
||||||
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;
|
|
||||||
}))];
|
|
||||||
for (let attempt = 0; attempt < 3; 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: 12_000, retryCount: 1, signal,
|
|
||||||
});
|
|
||||||
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
|
|
||||||
}
|
|
||||||
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
|
|
||||||
// No AI/model request is involved and a completed source fetch is shared.
|
|
||||||
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
|
|
||||||
}
|
|
||||||
throw new Error("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();
|
||||||
const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim();
|
const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim();
|
||||||
@ -84,41 +55,12 @@ export async function POST(request: NextRequest) {
|
|||||||
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 urls = resolveDownloadUrls(body, requestOrigin);
|
const urls = resolveDownloadUrls(body, requestOrigin);
|
||||||
const requestedUrls = [...urls];
|
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
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 } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
|
|
||||||
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
|
|
||||||
if (task?.targetUrl) {
|
|
||||||
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
|
|
||||||
after(() => prepareImageArchiveDownload(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 NextResponse.json({ delivery: "preparing", retryAfter: 3,
|
|
||||||
message: "正在准备原图,完成后将自动下载" }, {
|
|
||||||
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (result.status === "failed") {
|
|
||||||
return NextResponse.json({ success: false, error: result.error }, { status: 502 });
|
|
||||||
}
|
|
||||||
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[] = [];
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +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";
|
|
||||||
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 = 60 * 60_000;
|
const TASK_TTL_MS = 30 * 60 * 1000;
|
||||||
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
|
|
||||||
const active = running.__canvasZipRunning ??= new Set<string>();
|
const globalTaskStore = globalThis as typeof globalThis & {
|
||||||
|
__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, "-")
|
||||||
@ -24,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 {
|
||||||
@ -78,161 +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();
|
||||||
task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now();
|
const folder = zip.folder(task.folderName) || zip;
|
||||||
await saveZipTask(task);
|
const results: Array<
|
||||||
// Stage each image on disk, not all PNG buffers in the web process heap.
|
| Awaited<ReturnType<typeof fetchCanvasDownloadAsset>>
|
||||||
// A restart resumes from these files using the current owner's session.
|
| null
|
||||||
|
> = new Array(task.files.length).fill(null);
|
||||||
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);
|
|
||||||
try {
|
|
||||||
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition });
|
|
||||||
if (cdn) {
|
|
||||||
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
|
|
||||||
if (cdn.url) task.cdnUrl = cdn.url;
|
|
||||||
}
|
|
||||||
} catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
|
|
||||||
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.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 = () => 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 + 6 * TASK_TTL_MS };
|
if (result?.ok) {
|
||||||
await saveZipTask(task);
|
task.successCount += 1;
|
||||||
} else if (task.status === "failed" && body.retry === true) {
|
} else {
|
||||||
task.status = "pending"; task.error = undefined;
|
task.failedCount += 1;
|
||||||
await assertZipTaskCapacity(session.user.id);
|
|
||||||
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() + 6 * TASK_TTL_MS;
|
|
||||||
await saveZipTask(task);
|
|
||||||
}
|
}
|
||||||
if (task.status === "pending" || task.status === "running") schedule(task, request);
|
task.stage = `正在处理 ${task.processedCount}/${task.totalCount} 张`;
|
||||||
after(() => cleanupZipTasks().catch(() => { console.warn("[CanvasDownloadZipTask] Expired file cleanup deferred"); }));
|
task.updatedAt = Date.now();
|
||||||
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) {
|
||||||
if (request.nextUrl.searchParams.get("download") === "1") {
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
if (task.status !== "completed" || !task.zipFile) return NextResponse.json({ error: "ZIP 尚未准备好" }, { status: 409 });
|
|
||||||
if (task.cdnUrl && request.nextUrl.searchParams.get("local") !== "1")
|
|
||||||
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 NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } });
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
} catch {
|
const requestOrigin = getRequestOrigin(request);
|
||||||
return NextResponse.json({ error: "查询打包任务失败,请稍后重试" }, { status: 500 });
|
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 (task.status !== "completed" || !task.zipBuffer) {
|
||||||
|
return NextResponse.json({ success: false, error: "ZIP 文件尚未准备好" }, { status: 409 });
|
||||||
|
}
|
||||||
|
return new NextResponse(task.zipBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"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",
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { videoUpscaleSchema } from "@/lib/video-canvas/video-upscale-contract";
|
import { videoUpscaleSchema } from "@/lib/video-canvas/video-upscale-contract";
|
||||||
|
import { videoEditRequestSchema } from "@/lib/video-canvas/video-edit-contract";
|
||||||
|
import { canvasLayerSplitSchema } from "@/lib/video-canvas/layer-split-contract";
|
||||||
import { smartEditRequestSchema } from "@/lib/video-canvas/smart-edit/contract";
|
import { smartEditRequestSchema } from "@/lib/video-canvas/smart-edit/contract";
|
||||||
import { audioRequestSchema } from "@/lib/video-canvas/audio-contract";
|
import { audioRequestSchema } from "@/lib/video-canvas/audio-contract";
|
||||||
import { canResumeCanvasTask } from "@/lib/video-canvas/task-recovery";
|
import { canResumeCanvasTask } from "@/lib/video-canvas/task-recovery";
|
||||||
@ -14,7 +16,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 +30,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) =>
|
||||||
@ -65,9 +66,10 @@ 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});
|
||||||
|
if (parsed.data.kind === "video-edit" && !videoEditRequestSchema.safeParse(parsed.data.params).success) throw Object.assign(Error("视频编辑参数无效"), {status:400});
|
||||||
|
if (parsed.data.kind === "image-layer-split" && !canvasLayerSplitSchema.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,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,35 +0,0 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import JSZip from "jszip";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const packageFiles = [
|
|
||||||
"package.json", "package-lock.json", "api-client.mjs", "cli.mjs", "mcp-server.mjs",
|
|
||||||
"yedir-canvas/SKILL.md", "yedir-canvas/agents/openai.yaml",
|
|
||||||
"yedir-canvas/references/operation-protocol.md",
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id || session.user.type === "guest") {
|
|
||||||
return NextResponse.json({ error: "请先登录正式账号" }, { status: 401 });
|
|
||||||
}
|
|
||||||
const zip = new JSZip();
|
|
||||||
const base = path.join(process.cwd(), "integrations", "openclaw");
|
|
||||||
try {
|
|
||||||
await Promise.all(packageFiles.map(async file => {
|
|
||||||
zip.file(`tvstar-mcp/${file}`, await readFile(path.join(base, file)));
|
|
||||||
}));
|
|
||||||
const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
|
|
||||||
return new NextResponse(archive, { headers: {
|
|
||||||
"Content-Type": "application/zip",
|
|
||||||
"Content-Disposition": 'attachment; filename="tvstar-mcp.zip"',
|
|
||||||
"Cache-Control": "private, no-store",
|
|
||||||
} });
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "插件安装包暂不可用" }, { status: 503 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@ -75,7 +74,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useCanvasImageArchives } from "@/hooks/use-canvas-image-archives";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
// 深色主题组件 V2 (新版布局)
|
// 深色主题组件 V2 (新版布局)
|
||||||
import type { AgentGenerationWorkspaceDarkV2Props } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
|
import type { AgentGenerationWorkspaceDarkV2Props } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
|
||||||
@ -88,9 +86,7 @@ import {
|
|||||||
CanvasAddImageDialog,
|
CanvasAddImageDialog,
|
||||||
type CanvasAddImageDialogImage,
|
type CanvasAddImageDialogImage,
|
||||||
} from "@/components/canvas/CanvasAddImageDialog";
|
} from "@/components/canvas/CanvasAddImageDialog";
|
||||||
import { CanvasAgentUiListener } from "@/components/canvas/chat-panel/canvas-agent-ui-listener";
|
|
||||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
import { CanvasSurfaceHeader } from "@/components/canvas/CanvasSurfaceHeader";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -253,6 +249,7 @@ import type {
|
|||||||
AgentWorkflowUiConfig,
|
AgentWorkflowUiConfig,
|
||||||
} from "@/types/agent";
|
} from "@/types/agent";
|
||||||
import type {
|
import type {
|
||||||
|
AgentSlotNodeData,
|
||||||
CanvasEdge,
|
CanvasEdge,
|
||||||
CanvasInitialProject,
|
CanvasInitialProject,
|
||||||
CanvasNode,
|
CanvasNode,
|
||||||
@ -265,7 +262,7 @@ import type {
|
|||||||
|
|
||||||
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
|
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
|
||||||
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
|
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
|
||||||
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
|
const CANVAS_SURFACE_BACKGROUND = "#f4f4f5";
|
||||||
|
|
||||||
const fetchCanvasSiteSettings = async (url: string) => {
|
const fetchCanvasSiteSettings = async (url: string) => {
|
||||||
const separator = url.includes("?") ? "&" : "?";
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
@ -1909,8 +1906,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
[isVideoMode]
|
[isVideoMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
useCanvasImageArchives(id);
|
|
||||||
|
|
||||||
// Store state
|
// Store state
|
||||||
const projectTitle = useCanvasStore((state) => state.projectTitle);
|
const projectTitle = useCanvasStore((state) => state.projectTitle);
|
||||||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||||||
@ -3146,7 +3141,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
const updateWidthFromClientX = (clientX: number) => {
|
const updateWidthFromClientX = (clientX: number) => {
|
||||||
const currentViewportWidth = window.innerWidth;
|
const currentViewportWidth = window.innerWidth;
|
||||||
const nextWidth = currentViewportWidth - DOCKED_PANEL_INSET - clientX;
|
const nextWidth =
|
||||||
|
currentViewportWidth - DOCKED_PANEL_INSET - clientX;
|
||||||
|
|
||||||
setFloatingPanelWidth(
|
setFloatingPanelWidth(
|
||||||
clampFloatingPanelWidth(nextWidth, currentViewportWidth)
|
clampFloatingPanelWidth(nextWidth, currentViewportWidth)
|
||||||
@ -5076,16 +5072,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
workflowInputs.brand,
|
workflowInputs.brand,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const canvasSaveQueue = useRef<ReturnType<typeof createCanvasSaveQueue> | null>(null);
|
|
||||||
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
|
|
||||||
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const canvasPersistenceScope = useRef(id);
|
|
||||||
canvasPersistenceScope.current = id;
|
|
||||||
const persistCanvasState = useCallback(
|
const persistCanvasState = useCallback(
|
||||||
(reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => {
|
async (reason: "autosave" | "delete" | "beforeunload") => {
|
||||||
if (canvasPersistenceScope.current !== id) return false;
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const canvasData = buildPersistedCanvasData();
|
const canvasData = buildPersistedCanvasData();
|
||||||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||||||
const result = await canvasService.saveCanvasState(
|
const result = await canvasService.saveCanvasState(
|
||||||
@ -5097,7 +5085,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
baseCanvasUpdatedAt: canvasUpdatedAt,
|
baseCanvasUpdatedAt: canvasUpdatedAt,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (canvasPersistenceScope.current !== id) return false;
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||||
if (result.status === 409) {
|
if (result.status === 409) {
|
||||||
@ -5119,41 +5106,25 @@ export default function CanvasEditorLegacyPage({
|
|||||||
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||||
}
|
}
|
||||||
return result.success;
|
return result.success;
|
||||||
} finally {
|
},
|
||||||
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
|
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const persistCanvasBeforeAgent = useCallback(() => {
|
|
||||||
// Flush the pending local edit once instead of racing its scheduled autosave.
|
|
||||||
if (canvasAutosaveTimer.current) {
|
|
||||||
clearTimeout(canvasAutosaveTimer.current);
|
|
||||||
canvasAutosaveTimer.current = null;
|
|
||||||
}
|
|
||||||
return persistCanvasState("autosave");
|
|
||||||
}, [persistCanvasState]);
|
|
||||||
|
|
||||||
// 自动保存逻辑
|
// 自动保存逻辑
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) return;
|
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) return;
|
||||||
|
|
||||||
const saveTimer = setTimeout(async () => {
|
const saveTimer = setTimeout(async () => {
|
||||||
canvasAutosaveTimer.current = null;
|
setIsSaving(true);
|
||||||
if (isAgentOperationRefreshActive()) return;
|
|
||||||
try {
|
try {
|
||||||
await persistCanvasState("autosave");
|
await persistCanvasState("autosave");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Auto save failed:", error);
|
console.error("Auto save failed:", error);
|
||||||
}
|
}
|
||||||
|
setIsSaving(false);
|
||||||
}, 3000); // 3秒自动保存
|
}, 3000); // 3秒自动保存
|
||||||
|
|
||||||
canvasAutosaveTimer.current = saveTimer;
|
return () => clearTimeout(saveTimer);
|
||||||
return () => {
|
|
||||||
clearTimeout(saveTimer);
|
|
||||||
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
|
|
||||||
};
|
|
||||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -7544,7 +7515,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
materialMimeType: image.mimeType || undefined,
|
materialMimeType: image.mimeType || undefined,
|
||||||
originalHeight: dimensions.originalHeight,
|
originalHeight: dimensions.originalHeight,
|
||||||
originalWidth: dimensions.originalWidth,
|
originalWidth: dimensions.originalWidth,
|
||||||
source: image.sourceType === "upload" ? "local-upload" : "agent-workflow-material-search",
|
source: "agent-workflow-material-search",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
connections: [],
|
connections: [],
|
||||||
@ -7556,7 +7527,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
if (createdNodeIds.length > 0) {
|
if (createdNodeIds.length > 0) {
|
||||||
selectNodes(createdNodeIds);
|
selectNodes(createdNodeIds);
|
||||||
toast.success(`已添加 ${createdNodeIds.length} 张${validImages.every((image) => image.sourceType === "upload") ? "图片" : "白底图"}到画布`);
|
toast.success(`已添加 ${createdNodeIds.length} 张白底图到画布`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCanvasNode, getVisibleCanvasOrigin, selectNodes]
|
[createCanvasNode, getVisibleCanvasOrigin, selectNodes]
|
||||||
@ -7572,6 +7543,64 @@ export default function CanvasEditorLegacyPage({
|
|||||||
setIsAddImageDialogOpen(true);
|
setIsAddImageDialogOpen(true);
|
||||||
}, [handleAddWorkflowNode, isVideoMode]);
|
}, [handleAddWorkflowNode, isVideoMode]);
|
||||||
|
|
||||||
|
const handleAddAgentSlotNode = useCallback((slotType: AgentSlotNodeData["slotType"]) => {
|
||||||
|
if (isVideoMode) return;
|
||||||
|
|
||||||
|
const slotDefaults: Record<
|
||||||
|
AgentSlotNodeData["slotType"],
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
size: CanvasNode["size"];
|
||||||
|
aspectRatio: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
image: {
|
||||||
|
label: "图片槽位",
|
||||||
|
size: { width: 320, height: 320 },
|
||||||
|
aspectRatio: "1:1",
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
label: "视频槽位",
|
||||||
|
size: { width: 360, height: 220 },
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
},
|
||||||
|
"ui-screen": {
|
||||||
|
label: "UI 槽位",
|
||||||
|
size: { width: 360, height: 260 },
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const defaults = slotDefaults[slotType];
|
||||||
|
const origin = getVisibleCanvasOrigin();
|
||||||
|
createCanvasNode({
|
||||||
|
type: "agent-slot",
|
||||||
|
position: {
|
||||||
|
x: origin.x + 40,
|
||||||
|
y: origin.y + 40,
|
||||||
|
},
|
||||||
|
size: defaults.size,
|
||||||
|
data: {
|
||||||
|
slotType,
|
||||||
|
label: defaults.label,
|
||||||
|
status: "empty",
|
||||||
|
aspectRatio: defaults.aspectRatio,
|
||||||
|
fillPolicy: "replace-empty",
|
||||||
|
acceptedSources: ["text", "image", "selection", "annotation"],
|
||||||
|
filledNodeIds: [],
|
||||||
|
metadata: {
|
||||||
|
createdBy: "toolbar",
|
||||||
|
createdSlotType: slotType,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connections: [],
|
||||||
|
});
|
||||||
|
}, [createCanvasNode, getVisibleCanvasOrigin, isVideoMode]);
|
||||||
|
|
||||||
|
const handleAddAgentImageSlotNode = useCallback(() => {
|
||||||
|
handleAddAgentSlotNode("image");
|
||||||
|
}, [handleAddAgentSlotNode]);
|
||||||
|
|
||||||
const handleAddVideoNode = useCallback(() => {
|
const handleAddVideoNode = useCallback(() => {
|
||||||
if (isVideoMode) {
|
if (isVideoMode) {
|
||||||
handleAddWorkflowNode("video-input");
|
handleAddWorkflowNode("video-input");
|
||||||
@ -10283,11 +10312,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div
|
<div
|
||||||
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
className="relative h-dvh w-full overflow-hidden overscroll-none"
|
||||||
className={cn(
|
|
||||||
"h-dvh w-full overflow-hidden overscroll-none",
|
|
||||||
isVideoMode ? "relative" : "lovart-canvas-surface fixed inset-0"
|
|
||||||
)}
|
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--canvas-surface-background": canvasSurfaceBackground,
|
"--canvas-surface-background": canvasSurfaceBackground,
|
||||||
@ -10295,12 +10320,10 @@ export default function CanvasEditorLegacyPage({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CanvasAgentUiListener projectId={id} workspace="canvas" />
|
|
||||||
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
|
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-0"
|
className="absolute inset-0 z-0"
|
||||||
data-canvas-stage="true"
|
data-canvas-stage="true"
|
||||||
style={isVideoMode ? undefined : { right: panelOcclusionRight, top: 40 }}
|
|
||||||
key={`workspace-${id}`}
|
key={`workspace-${id}`}
|
||||||
>
|
>
|
||||||
<CanvasWorkspace
|
<CanvasWorkspace
|
||||||
@ -10311,10 +10334,9 @@ export default function CanvasEditorLegacyPage({
|
|||||||
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
|
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
|
||||||
: '点击顶部"添加"按钮开始创作'
|
: '点击顶部"添加"按钮开始创作'
|
||||||
}
|
}
|
||||||
occlusionRight={0}
|
occlusionRight={panelOcclusionRight}
|
||||||
onBlankCanvasDoubleClick={handleOpenVideoCanvasInsertMenu}
|
onBlankCanvasDoubleClick={handleOpenVideoCanvasInsertMenu}
|
||||||
showEmptyState={false}
|
showEmptyState={!isVideoMode}
|
||||||
showZoomControls={isVideoMode}
|
|
||||||
workspaceLabel={workspaceLabel}
|
workspaceLabel={workspaceLabel}
|
||||||
workspaceVariant={isVideoMode ? "video" : "canvas"}
|
workspaceVariant={isVideoMode ? "video" : "canvas"}
|
||||||
layerSplitEnabled={isLayerSplitEnabled}
|
layerSplitEnabled={isLayerSplitEnabled}
|
||||||
@ -11003,27 +11025,14 @@ export default function CanvasEditorLegacyPage({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isVideoMode ? brandEntry : (
|
{brandEntry}
|
||||||
<CanvasSurfaceHeader
|
|
||||||
isSaving={isSaving}
|
|
||||||
onAddImage={handleAddImageNode}
|
|
||||||
onHome={goHome}
|
|
||||||
onOpenFiles={() => {
|
|
||||||
setIsPanelOpen(true);
|
|
||||||
setPanelMode("fileList");
|
|
||||||
}}
|
|
||||||
onSave={() => void handleSave()}
|
|
||||||
projectTitle={projectTitle}
|
|
||||||
rightInset={panelOcclusionRight}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{agentDisabledNotice}
|
{agentDisabledNotice}
|
||||||
|
|
||||||
{/* 2. Floating Actions (Top Right) */}
|
{/* 2. Floating Actions (Top Right) */}
|
||||||
{!isVideoMode && !isPanelOpen && (
|
{!isVideoMode && !isPanelOpen && (
|
||||||
<div
|
<div
|
||||||
className="absolute z-20"
|
className="absolute z-20"
|
||||||
style={{ top: 46, right: 12 }}
|
style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -11055,7 +11064,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
)}
|
)}
|
||||||
data-canvas-right-panel="true"
|
data-canvas-right-panel="true"
|
||||||
style={{
|
style={{
|
||||||
top: 0,
|
top: DOCKED_PANEL_INSET,
|
||||||
right: DOCKED_PANEL_INSET,
|
right: DOCKED_PANEL_INSET,
|
||||||
bottom: DOCKED_PANEL_INSET,
|
bottom: DOCKED_PANEL_INSET,
|
||||||
width: resolvedPanelWidth,
|
width: resolvedPanelWidth,
|
||||||
@ -11086,7 +11095,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden border-l border-[#ededed] bg-white dark:border-zinc-800 dark:bg-zinc-950">
|
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
|
||||||
{!areSecondaryPanelsReady ? (
|
{!areSecondaryPanelsReady ? (
|
||||||
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
|
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
|
||||||
) : panelMode === "chat" ? (
|
) : panelMode === "chat" ? (
|
||||||
@ -11103,7 +11112,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
onClose={() => setIsPanelOpen(false)}
|
onClose={() => setIsPanelOpen(false)}
|
||||||
onOpenFileList={() => setPanelMode("fileList")}
|
onOpenFileList={() => setPanelMode("fileList")}
|
||||||
onRequireLogin={openLoginDialog}
|
onRequireLogin={openLoginDialog}
|
||||||
onBeforeAgentRequest={persistCanvasBeforeAgent}
|
|
||||||
selectedAgentPresetId={
|
selectedAgentPresetId={
|
||||||
isCanvasAgentModeEnabled ? selectedAgentPresetId : null
|
isCanvasAgentModeEnabled ? selectedAgentPresetId : null
|
||||||
}
|
}
|
||||||
@ -11193,11 +11201,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
className={cn(
|
className={cn(
|
||||||
isVideoMode
|
isVideoMode
|
||||||
? VIDEO_CANVAS_TOOLBAR_CLASS
|
? VIDEO_CANVAS_TOOLBAR_CLASS
|
||||||
: "border border-[#e9e9e9] bg-white shadow-[0_1px_3px_rgba(0,0,0,0.035)] dark:border-white/10 dark:bg-zinc-950",
|
: "border bg-white shadow-2xl dark:bg-zinc-950",
|
||||||
!isVideoMode &&
|
!isVideoMode &&
|
||||||
(shouldDockFloatingToolbarToSide
|
(shouldDockFloatingToolbarToSide
|
||||||
? "flex flex-col items-center gap-1 rounded-[24px] px-1.5 py-2.5"
|
? "flex flex-col items-center gap-1 rounded-[24px] px-1.5 py-2.5"
|
||||||
: "flex items-center gap-1 rounded-[14px] px-1.5 py-1.5")
|
: "flex items-center gap-1 rounded-[24px] px-2.5 py-1.5")
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
shouldDockFloatingToolbarToSide
|
shouldDockFloatingToolbarToSide
|
||||||
@ -11215,7 +11223,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
tool === "select" &&
|
tool === "select" &&
|
||||||
(isVideoMode
|
(isVideoMode
|
||||||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||||
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
|
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
||||||
)}
|
)}
|
||||||
onClick={() => setTool("select")}
|
onClick={() => setTool("select")}
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -11225,8 +11233,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
active={tool === "select"}
|
active={tool === "select"}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={MousePointer}
|
fallback={MousePointer}
|
||||||
referenceName={isVideoMode ? undefined : "cursor"}
|
|
||||||
referenceSize={20}
|
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"selectTool"
|
"selectTool"
|
||||||
@ -11240,6 +11246,63 @@ export default function CanvasEditorLegacyPage({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{!isVideoMode &&
|
||||||
|
isCanvasToolbarItemEnabled(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
"addAgentImageSlot"
|
||||||
|
) ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button className="h-9 w-9 rounded-xl" size="icon" variant="ghost">
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={ImagePlus}
|
||||||
|
item={getCanvasToolbarItem(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
"addAgentImageSlot"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side={floatingToolbarTooltipSide}>
|
||||||
|
{
|
||||||
|
getCanvasToolbarItem(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
"addAgentImageSlot"
|
||||||
|
).label
|
||||||
|
}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
side={floatingToolbarDropdownSide}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => handleAddAgentImageSlotNode()}
|
||||||
|
>
|
||||||
|
<ImagePlus className="h-4 w-4" />
|
||||||
|
图片槽位
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => handleAddAgentSlotNode("video")}
|
||||||
|
>
|
||||||
|
<Video className="h-4 w-4" />
|
||||||
|
视频槽位
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => handleAddAgentSlotNode("ui-screen")}
|
||||||
|
>
|
||||||
|
<MonitorSmartphone className="h-4 w-4" />
|
||||||
|
UI 槽位
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@ -11250,7 +11313,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
tool === "pan" &&
|
tool === "pan" &&
|
||||||
(isVideoMode
|
(isVideoMode
|
||||||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||||
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
|
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
||||||
)}
|
)}
|
||||||
onClick={() => setTool("pan")}
|
onClick={() => setTool("pan")}
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -11260,8 +11323,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
active={tool === "pan"}
|
active={tool === "pan"}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Hand}
|
fallback={Hand}
|
||||||
referenceName={isVideoMode ? undefined : "pan"}
|
|
||||||
referenceSize={20}
|
|
||||||
item={getCanvasToolbarItem(canvasToolbarConfig, "panTool")}
|
item={getCanvasToolbarItem(canvasToolbarConfig, "panTool")}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
@ -11382,7 +11443,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
tool === "draw" &&
|
tool === "draw" &&
|
||||||
(isVideoMode
|
(isVideoMode
|
||||||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||||
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
|
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
||||||
)}
|
)}
|
||||||
onClick={() => setTool("draw")}
|
onClick={() => setTool("draw")}
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -11423,7 +11484,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
isAgentMode &&
|
isAgentMode &&
|
||||||
(isVideoMode
|
(isVideoMode
|
||||||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||||
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
|
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
||||||
)}
|
)}
|
||||||
onClick={handleStyleModeToolbarClick}
|
onClick={handleStyleModeToolbarClick}
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -11439,14 +11500,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
fallback={
|
fallback={
|
||||||
shouldUseStyleModeButtonForStylePro ? Sparkles : Wand2
|
shouldUseStyleModeButtonForStylePro ? Sparkles : Wand2
|
||||||
}
|
}
|
||||||
referenceName={
|
|
||||||
isVideoMode
|
|
||||||
? undefined
|
|
||||||
: shouldUseStyleModeButtonForStylePro
|
|
||||||
? "style-controls"
|
|
||||||
: "creativity"
|
|
||||||
}
|
|
||||||
referenceSize={20}
|
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"styleMode"
|
"styleMode"
|
||||||
@ -11476,8 +11529,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Sparkles}
|
fallback={Sparkles}
|
||||||
referenceName={isVideoMode ? undefined : "style-controls"}
|
|
||||||
referenceSize={20}
|
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"stylePro"
|
"stylePro"
|
||||||
@ -11507,8 +11558,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Layers}
|
fallback={Layers}
|
||||||
referenceName={isVideoMode ? undefined : "layers"}
|
|
||||||
referenceSize={20}
|
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"batchProcessing"
|
"batchProcessing"
|
||||||
@ -11540,8 +11589,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={ImagePlus}
|
fallback={ImagePlus}
|
||||||
referenceName={isVideoMode ? undefined : "image"}
|
|
||||||
referenceSize={20}
|
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
isVideoMode ? "addImageInput" : "addImage"
|
isVideoMode ? "addImageInput" : "addImage"
|
||||||
@ -11574,8 +11621,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Type}
|
fallback={Type}
|
||||||
referenceName={isVideoMode ? undefined : "text"}
|
|
||||||
referenceSize={20}
|
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
isVideoMode ? "addScriptNode" : "addText"
|
isVideoMode ? "addScriptNode" : "addText"
|
||||||
@ -11733,8 +11778,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={PenTool}
|
fallback={PenTool}
|
||||||
referenceName={isVideoMode ? undefined : "draw"}
|
|
||||||
referenceSize={20}
|
|
||||||
item={resolveCanvasToolbarItemWithFallback(
|
item={resolveCanvasToolbarItemWithFallback(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"realtimeCanvas",
|
"realtimeCanvas",
|
||||||
@ -11782,8 +11825,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Sparkles}
|
fallback={Sparkles}
|
||||||
referenceName={isVideoMode ? undefined : "image-enhance"}
|
|
||||||
referenceSize={20}
|
|
||||||
item={resolveCanvasToolbarItemWithFallback(
|
item={resolveCanvasToolbarItemWithFallback(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"enhanceCanvas",
|
"enhanceCanvas",
|
||||||
@ -11837,8 +11878,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Box}
|
fallback={Box}
|
||||||
referenceName={isVideoMode ? undefined : "model-auto"}
|
|
||||||
referenceSize={20}
|
|
||||||
item={getCanvasToolbarItem(canvasToolbarConfig, "model3d")}
|
item={getCanvasToolbarItem(canvasToolbarConfig, "model3d")}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
@ -11928,8 +11967,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Plus}
|
fallback={Plus}
|
||||||
referenceName={isVideoMode ? undefined : "plus"}
|
|
||||||
referenceSize={20}
|
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
isVideoMode ? "newVideoCanvas" : "newCanvas"
|
isVideoMode ? "newVideoCanvas" : "newCanvas"
|
||||||
@ -11957,15 +11994,12 @@ export default function CanvasEditorLegacyPage({
|
|||||||
type="file"
|
type="file"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Compact zoom and history controls. */}
|
{/* 6. Bottom Left Compact Controls */}
|
||||||
<div
|
<div
|
||||||
data-canvas-compact-controls="true"
|
|
||||||
className="absolute z-20"
|
className="absolute z-20"
|
||||||
style={{
|
style={{
|
||||||
bottom: `${FLOATING_BOTTOM_INSET + 4}px`,
|
bottom: `${FLOATING_BOTTOM_INSET + 4}px`,
|
||||||
...(isVideoMode
|
left: `${compactControlsLeft + 8}px`,
|
||||||
? { left: `${compactControlsLeft + 8}px` }
|
|
||||||
: { right: `${panelOcclusionRight + 16}px` }),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -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%); }
|
|
||||||
|
|||||||
@ -94,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;
|
||||||
@ -132,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);
|
||||||
}
|
}
|
||||||
@ -159,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;
|
||||||
}
|
}
|
||||||
@ -177,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({
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
type KeyItem = { id: string; name: string; tokenPrefix: string; scopes: string[]; revokedAt: string | null; expiresAt: string | null };
|
|
||||||
|
|
||||||
export default function TVstarPluginPage() {
|
|
||||||
const [keys, setKeys] = useState<KeyItem[]>([]);
|
|
||||||
const [createdToken, setCreatedToken] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [origin, setOrigin] = useState("https://ai.yydd.ai");
|
|
||||||
useEffect(() => { setOrigin(window.location.origin); }, []);
|
|
||||||
const loadKeys = useCallback(async () => {
|
|
||||||
const response = await fetch("/api/user/access-keys", { cache: "no-store" });
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok || !data.success) throw new Error(data.error || "读取密钥失败");
|
|
||||||
setKeys(Array.isArray(data.data) ? data.data : []);
|
|
||||||
}, []);
|
|
||||||
useEffect(() => { void loadKeys().catch(error => toast.error(error.message)); }, [loadKeys]);
|
|
||||||
|
|
||||||
const createKey = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/user/access-keys", { method: "POST", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "TVstar MCP", scopes: ["canvas:read", "canvas:write"], expiresInDays: 90 }) });
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok || !data.success || !data.data?.token) throw new Error(data.error || "创建密钥失败");
|
|
||||||
setCreatedToken(data.data.token);
|
|
||||||
await loadKeys();
|
|
||||||
toast.success("密钥已创建;请立即复制,关闭后不会再次显示");
|
|
||||||
} catch (error) { toast.error(error instanceof Error ? error.message : "创建密钥失败"); }
|
|
||||||
finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
const revokeKey = async (id: string) => {
|
|
||||||
if (!window.confirm("撤销后使用此密钥的 Agent 将失去画布访问权限,确定继续吗?")) return;
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/user/access-keys/${id}`, { method: "DELETE" });
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok || !data.success) throw new Error(data.error || "撤销失败");
|
|
||||||
setCreatedToken("");
|
|
||||||
await loadKeys();
|
|
||||||
toast.success("密钥已撤销");
|
|
||||||
} catch (error) { toast.error(error instanceof Error ? error.message : "撤销失败"); }
|
|
||||||
finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const example = JSON.stringify({ mcpServers: { tvstar: { command: "node", args: ["/替换为解压目录/tvstar-mcp/mcp-server.mjs"],
|
|
||||||
env: { YEDIR_BASE_URL: origin, YEDIR_ACCESS_TOKEN: "粘贴新创建的 Access Key" } } } }, null, 2);
|
|
||||||
|
|
||||||
return <main className="min-h-screen bg-[#151515] px-4 py-10 text-white">
|
|
||||||
<div className="mx-auto max-w-4xl">
|
|
||||||
<p className="text-sm text-cyan-300">TVstar Plugin</p>
|
|
||||||
<h1 className="mt-2 text-3xl font-semibold">让 Agent 操作视频画布</h1>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-white/60">当前插件提供画布列表、读取和有版本保护的节点操作。视频生成与合成仍由 TVstar 画布内的任务系统执行,MCP 暂未提供直接发起生成的工具。</p>
|
|
||||||
<div className="mt-8 grid gap-4 md:grid-cols-2">
|
|
||||||
<section className="rounded-2xl border border-white/10 bg-[#222] p-5">
|
|
||||||
<h2 className="font-medium">1. 下载并安装</h2>
|
|
||||||
<p className="mt-2 text-sm text-white/50">需要 Node.js 20 或更新版本。解压后在 tvstar-mcp 目录运行:</p>
|
|
||||||
<a className="mt-4 inline-block rounded-lg bg-white px-4 py-2 text-sm font-medium text-black" href="/api/tvstar/plugin/download">下载 TVstar MCP 安装包</a>
|
|
||||||
<pre className="mt-4 overflow-x-auto rounded-lg bg-black/40 p-3 text-xs">cd tvstar-mcp{"\n"}npm ci</pre>
|
|
||||||
</section>
|
|
||||||
<section className="rounded-2xl border border-white/10 bg-[#222] p-5">
|
|
||||||
<h2 className="font-medium">2. 创建画布 Access Key</h2>
|
|
||||||
<p className="mt-2 text-sm text-white/50">读取和修改画布需要对应权限。密钥只在创建时显示一次。</p>
|
|
||||||
<button className="mt-4 rounded-lg bg-white px-4 py-2 text-sm font-medium text-black disabled:opacity-50" disabled={busy} onClick={() => void createKey()}>{busy ? "处理中…" : "创建 Access Key"}</button>
|
|
||||||
{createdToken ? <div className="mt-3 break-all rounded-lg border border-amber-300/30 bg-amber-300/5 p-3 text-xs text-amber-100"><p>{createdToken}</p><button className="mt-2 underline" onClick={() => void navigator.clipboard.writeText(createdToken).then(() => toast.success("已复制"))}>复制密钥</button></div> : null}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<section className="mt-4 rounded-2xl border border-white/10 bg-[#222] p-5">
|
|
||||||
<h2 className="font-medium">3. 配置支持 STDIO MCP 的 Agent</h2>
|
|
||||||
<p className="mt-2 text-sm text-white/50">将安装包的绝对路径和密钥填入客户端配置。不同 Agent 的配置文件位置和外层字段可能不同,请以所用客户端的说明为准。</p>
|
|
||||||
<pre className="mt-4 overflow-x-auto rounded-lg bg-black/40 p-4 text-xs leading-6 text-cyan-100">{example}</pre>
|
|
||||||
<button className="mt-3 text-sm text-cyan-300 underline" onClick={() => void navigator.clipboard.writeText(example).then(() => toast.success("配置示例已复制"))}>复制配置示例</button>
|
|
||||||
</section>
|
|
||||||
<section className="mt-4 rounded-2xl border border-white/10 bg-[#222] p-5">
|
|
||||||
<h2 className="font-medium">已创建的 Access Key</h2>
|
|
||||||
<div className="mt-3 space-y-2">{keys.length ? keys.map(key => <div className="flex items-center justify-between gap-4 rounded-lg bg-black/20 p-3 text-sm" key={key.id}><span className="min-w-0 truncate">{key.name} · {key.tokenPrefix} · {key.revokedAt ? "已撤销" : "有效"}</span>{!key.revokedAt ? <button className="shrink-0 text-rose-300 disabled:opacity-40" disabled={busy} onClick={() => void revokeKey(key.id)}>撤销</button> : null}</div>) : <p className="text-sm text-white/40">暂无密钥</p>}</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>;
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { tvstarSession } from "@/lib/db/schema";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type SharedSnapshot = {
|
|
||||||
title: string;
|
|
||||||
goal: string;
|
|
||||||
createdAt: number;
|
|
||||||
messages: { role: "user" | "assistant"; content: string; createdAt: number }[];
|
|
||||||
plan: { summary: string; deliverables: string[] } | null;
|
|
||||||
shots: { shot: string; duration: string; sceneDescription: string }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function TVstarSharedConversation({ params }: { params: Promise<{ token: string }> }) {
|
|
||||||
const { token } = await params;
|
|
||||||
if (!/^[A-Za-z0-9_-]{32}$/.test(token || "")) notFound();
|
|
||||||
const row = (await db.select({ snapshot: tvstarSession.sharedSnapshot }).from(tvstarSession)
|
|
||||||
.where(eq(tvstarSession.shareToken, token)).limit(1))[0];
|
|
||||||
if (!row?.snapshot) notFound();
|
|
||||||
const shared = row.snapshot as SharedSnapshot;
|
|
||||||
return <main className="min-h-screen bg-[#141414] px-4 py-12 text-white">
|
|
||||||
<article className="mx-auto max-w-2xl rounded-2xl border border-white/10 bg-[#1d1d1d] p-6 shadow-2xl">
|
|
||||||
<p className="text-xs tracking-widest text-cyan-300">TVstar · 只读分享</p>
|
|
||||||
<h1 className="mt-3 text-2xl font-semibold">{shared.title}</h1>
|
|
||||||
<p className="mt-2 text-sm text-white/50">{new Date(shared.createdAt).toLocaleString("zh-CN")}</p>
|
|
||||||
<p className="mt-6 whitespace-pre-wrap border-l-2 border-cyan-400 pl-4 text-sm text-white/70">{shared.goal}</p>
|
|
||||||
<section aria-label="分享的对话" className="mt-8 space-y-3">
|
|
||||||
{(shared.messages || []).map((message, index) => <div className={`rounded-xl p-4 text-sm leading-6 ${message.role === "user" ? "ml-8 bg-[#303846]" : "mr-8 bg-[#282828]"}`} key={`${message.createdAt}-${index}`}>
|
|
||||||
<p className="mb-1 text-xs text-white/40">{message.role === "user" ? "用户" : "TVstar"}</p>
|
|
||||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
|
||||||
</div>)}
|
|
||||||
</section>
|
|
||||||
{shared.plan ? <section className="mt-8 border-t border-white/10 pt-5"><h2 className="text-lg">创作规划</h2><p className="mt-2 whitespace-pre-wrap text-sm text-white/70">{shared.plan.summary}</p></section> : null}
|
|
||||||
{shared.shots?.length ? <section className="mt-8 border-t border-white/10 pt-5"><h2 className="text-lg">分镜</h2><ol className="mt-3 space-y-3">{shared.shots.map((shot, index) => <li className="rounded-lg bg-white/5 p-3 text-sm" key={`${shot.shot}-${index}`}><strong>{shot.shot} · {shot.duration}</strong><p className="mt-1 whitespace-pre-wrap text-white/65">{shot.sceneDescription}</p></li>)}</ol></section> : null}
|
|
||||||
<p className="mt-8 border-t border-white/10 pt-5 text-xs text-white/40">此页面是分享时的对话快照,无法编辑画布;对话正文中的文字和链接会原样显示。</p>
|
|
||||||
</article>
|
|
||||||
</main>;
|
|
||||||
}
|
|
||||||
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}"
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||||
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
|
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
@ -2565,23 +2564,10 @@ interface LocalGenerationPendingState {
|
|||||||
expectedCount: number;
|
expectedCount: number;
|
||||||
userTurnCountAtStart: number;
|
userTurnCountAtStart: number;
|
||||||
requestMessageId?: string | null;
|
requestMessageId?: string | null;
|
||||||
backendTaskId?: string | null;
|
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
requestedCardIds?: string[] | null;
|
requestedCardIds?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DetailReplacementBatchTaskView = {
|
|
||||||
id: string;
|
|
||||||
status: "pending" | "running" | "succeeded" | "failed";
|
|
||||||
error: string | null;
|
|
||||||
attempts: number;
|
|
||||||
cards: DetailReplacementBatchCard[];
|
|
||||||
currentCardId: string | null;
|
|
||||||
requestedCardIds: string[];
|
|
||||||
createdAt: string;
|
|
||||||
queueStatus?: { queuePosition?: number } | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AGENT_PENDING_GENERATION_STORAGE_KEY = "ai-chatbot-agent-pending-generations";
|
const AGENT_PENDING_GENERATION_STORAGE_KEY = "ai-chatbot-agent-pending-generations";
|
||||||
const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000;
|
const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000;
|
||||||
const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000;
|
const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000;
|
||||||
@ -2633,7 +2619,6 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
|
|||||||
expectedCount: Number(record.expectedCount),
|
expectedCount: Number(record.expectedCount),
|
||||||
userTurnCountAtStart: Number(record.userTurnCountAtStart),
|
userTurnCountAtStart: Number(record.userTurnCountAtStart),
|
||||||
requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null,
|
requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null,
|
||||||
backendTaskId: typeof record.backendTaskId === "string" ? record.backendTaskId : null,
|
|
||||||
timestamp: Number(record.timestamp),
|
timestamp: Number(record.timestamp),
|
||||||
requestedCardIds: Array.isArray(record.requestedCardIds)
|
requestedCardIds: Array.isArray(record.requestedCardIds)
|
||||||
? record.requestedCardIds
|
? record.requestedCardIds
|
||||||
@ -2666,6 +2651,16 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DETAIL_RESULT_PROCESSING_STAGES = [
|
||||||
|
"解码图像",
|
||||||
|
"定位选区",
|
||||||
|
"分离主体",
|
||||||
|
"重绘背景",
|
||||||
|
"融合边缘",
|
||||||
|
"检查细节",
|
||||||
|
"输出结果",
|
||||||
|
];
|
||||||
|
|
||||||
function DetailResultProcessingScanOverlay({
|
function DetailResultProcessingScanOverlay({
|
||||||
imageUrl,
|
imageUrl,
|
||||||
label,
|
label,
|
||||||
@ -2673,6 +2668,29 @@ function DetailResultProcessingScanOverlay({
|
|||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
label: string;
|
label: string;
|
||||||
}) {
|
}) {
|
||||||
|
const [progress, setProgress] = useState(6);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const durationMs = 240_000;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
const elapsed = Date.now() - startedAt;
|
||||||
|
const linear = Math.min(1, elapsed / durationMs);
|
||||||
|
const eased = 1 - Math.pow(1 - linear, 1.8);
|
||||||
|
const drift = Math.sin(elapsed / 3200) * 1.2;
|
||||||
|
const next = Math.max(6, Math.min(96, Math.round(eased * 96 + drift)));
|
||||||
|
setProgress((current) => Math.max(current, next));
|
||||||
|
}, 720);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [imageUrl, label]);
|
||||||
|
|
||||||
|
const stageIndex = Math.min(
|
||||||
|
DETAIL_RESULT_PROCESSING_STAGES.length - 1,
|
||||||
|
Math.floor((progress / 100) * DETAIL_RESULT_PROCESSING_STAGES.length)
|
||||||
|
);
|
||||||
|
const stageName = DETAIL_RESULT_PROCESSING_STAGES[stageIndex] || "处理中";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
|
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
|
||||||
<style>{`
|
<style>{`
|
||||||
@ -2786,6 +2804,26 @@ function DetailResultProcessingScanOverlay({
|
|||||||
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
|
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
|
||||||
/>
|
/>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
<span className="min-w-[34px] text-right tabular-nums text-slate-300">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-3 left-3 z-[70] isolate inline-flex items-center gap-2 rounded-full border border-white/14 bg-[#080b12]/95 px-2.5 py-1.5 text-[11px] font-semibold leading-none text-slate-100 shadow-[0_12px_28px_rgba(0,0,0,0.32)]"
|
||||||
|
style={{
|
||||||
|
WebkitFontSmoothing: "antialiased",
|
||||||
|
textShadow: "0 1px 1px rgba(0,0,0,0.72)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="tabular-nums text-sky-300">{String(stageIndex + 1).padStart(2, "0")}</span>
|
||||||
|
<span>{stageName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 z-[60] h-0.5 bg-white/5">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-r-full shadow-[0_0_8px_rgba(125,211,252,0.6)] transition-[width] duration-300 ease-linear"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(90deg, #7dd3fc, #a78bfa)",
|
||||||
|
width: `${progress}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -3128,8 +3166,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null);
|
const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null);
|
||||||
const detailGenerationCancelRef = useRef(false);
|
const detailGenerationCancelRef = useRef(false);
|
||||||
const workspaceStateSignatureRef = useRef<string>("");
|
const workspaceStateSignatureRef = useRef<string>("");
|
||||||
const detailBatchSubmittingRef = useRef(false);
|
|
||||||
const reportedDetailBatchFailureRef = useRef<string | null>(null);
|
|
||||||
const generationRequestSnapshotRef = useRef<{
|
const generationRequestSnapshotRef = useRef<{
|
||||||
hiddenPrompt: string | null;
|
hiddenPrompt: string | null;
|
||||||
replacementExecutionCardIds: string[] | null;
|
replacementExecutionCardIds: string[] | null;
|
||||||
@ -3464,75 +3500,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
|
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
|
||||||
const isReplacementPreset =
|
const isReplacementPreset =
|
||||||
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement";
|
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement";
|
||||||
const { data: detailBatchData, mutate: refreshDetailBatch } = useSWR<{
|
|
||||||
task: DetailReplacementBatchTaskView | null;
|
|
||||||
}>(
|
|
||||||
projectId && currentResultModeType === "detail_replacement"
|
|
||||||
? `/api/agents/detail-replacement/batches?projectId=${encodeURIComponent(projectId)}`
|
|
||||||
: null,
|
|
||||||
fetcher,
|
|
||||||
{ refreshInterval: 3000, revalidateOnFocus: true },
|
|
||||||
);
|
|
||||||
const detailBatchTask = detailBatchData?.task || null;
|
|
||||||
useEffect(() => {
|
|
||||||
if (!detailBatchData || currentResultModeType !== "detail_replacement") return;
|
|
||||||
const task = detailBatchData.task;
|
|
||||||
if (task && (task.status === "pending" || task.status === "running")) {
|
|
||||||
setPendingGeneration((current) => {
|
|
||||||
if (current?.backendTaskId === task.id) return current;
|
|
||||||
return {
|
|
||||||
chatId,
|
|
||||||
requestMessageId: task.id,
|
|
||||||
backendTaskId: task.id,
|
|
||||||
modeType: "detail_replacement",
|
|
||||||
expectedCount: task.requestedCardIds.length,
|
|
||||||
requestedCardIds: task.requestedCardIds,
|
|
||||||
userTurnCountAtStart: 0,
|
|
||||||
timestamp: Date.parse(task.createdAt) || Date.now(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setPendingGeneration((current) =>
|
|
||||||
current?.modeType === "detail_replacement" &&
|
|
||||||
(!current.backendTaskId || current.backendTaskId === task?.id)
|
|
||||||
? null
|
|
||||||
: current,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [chatId, currentResultModeType, detailBatchData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cards = detailBatchTask?.cards || [];
|
|
||||||
if (cards.length === 0) return;
|
|
||||||
setReplacementResultState((current) => {
|
|
||||||
const next = { ...current.detail_replacement };
|
|
||||||
let changed = false;
|
|
||||||
for (const card of cards) {
|
|
||||||
const reference = detailReplacementReferenceCards.find((item) => item.id === card.cardId);
|
|
||||||
if (!reference || !card.imageUrl || next[card.cardId]?.url === card.imageUrl) continue;
|
|
||||||
next[card.cardId] = {
|
|
||||||
url: card.imageUrl,
|
|
||||||
text: card.text,
|
|
||||||
status: card.status,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
compareSourceUrl: reference.imageUrl,
|
|
||||||
referenceImageUrl: reference.imageUrl,
|
|
||||||
label: reference.label,
|
|
||||||
};
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
return changed ? { ...current, detail_replacement: next } : current;
|
|
||||||
});
|
|
||||||
}, [detailBatchTask?.cards, detailReplacementReferenceCards]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
detailBatchTask?.status !== "failed" ||
|
|
||||||
detailBatchTask.attempts < 3 ||
|
|
||||||
reportedDetailBatchFailureRef.current === detailBatchTask.id
|
|
||||||
) return;
|
|
||||||
reportedDetailBatchFailureRef.current = detailBatchTask.id;
|
|
||||||
toast.error(detailBatchTask.error || "详情替换批次中断,已保留生成成功的图片,可继续处理剩余图片");
|
|
||||||
}, [detailBatchTask]);
|
|
||||||
const isProductOnlyDetailReplacementMode =
|
const isProductOnlyDetailReplacementMode =
|
||||||
currentResultModeType === "detail_replacement" &&
|
currentResultModeType === "detail_replacement" &&
|
||||||
detailReplacementStrategy === "product_only";
|
detailReplacementStrategy === "product_only";
|
||||||
@ -5029,17 +4996,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
Array.isArray(pendingGeneration.requestedCardIds)
|
Array.isArray(pendingGeneration.requestedCardIds)
|
||||||
? pendingGeneration.requestedCardIds.length
|
? pendingGeneration.requestedCardIds.length
|
||||||
: null,
|
: null,
|
||||||
status: detailBatchTask?.requestedCardIds.includes(card.id)
|
status: getReplacementCardStatus({
|
||||||
? resultMeta?.url
|
|
||||||
? resultMeta.status
|
|
||||||
: detailBatchTask.status === "running"
|
|
||||||
? detailBatchTask.currentCardId === card.id ? "generating" : "queued"
|
|
||||||
: detailBatchTask.status === "pending"
|
|
||||||
? "queued"
|
|
||||||
: detailBatchTask.status === "failed"
|
|
||||||
? "missing"
|
|
||||||
: "waiting"
|
|
||||||
: getReplacementCardStatus({
|
|
||||||
index,
|
index,
|
||||||
resultUrl,
|
resultUrl,
|
||||||
resultText,
|
resultText,
|
||||||
@ -5053,7 +5010,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
detailReplacementReferenceCards,
|
detailReplacementReferenceCards,
|
||||||
detailBatchTask,
|
|
||||||
isDetailResultModeGenerating,
|
isDetailResultModeGenerating,
|
||||||
replacementRunRecovery.completedCardIds,
|
replacementRunRecovery.completedCardIds,
|
||||||
latestReplacementResults,
|
latestReplacementResults,
|
||||||
@ -5208,7 +5164,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const detailRemainingCount = useMemo(
|
const detailRemainingCount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
detailResultCards.filter(
|
detailResultCards.filter(
|
||||||
(card) => !["completed", "passthrough"].includes(card.status)
|
(card) => !["completed", "passthrough", "fallback"].includes(card.status)
|
||||||
).length,
|
).length,
|
||||||
[detailResultCards]
|
[detailResultCards]
|
||||||
);
|
);
|
||||||
@ -5483,7 +5439,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [currentResultModeType]);
|
}, [currentResultModeType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
|
if (!pendingGeneration || !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.
|
||||||
stop();
|
stop();
|
||||||
transportGenerationStartedAtRef.current = null;
|
transportGenerationStartedAtRef.current = null;
|
||||||
@ -5491,7 +5447,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [pendingGeneration, replacementRunRecovery.done, stop]);
|
}, [pendingGeneration, replacementRunRecovery.done, stop]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || pendingGeneration.backendTaskId) {
|
if (!pendingGeneration) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5501,7 +5457,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [pendingGeneration, pendingGenerationProgressCount]);
|
}, [pendingGeneration, pendingGenerationProgressCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || pendingGeneration.backendTaskId || !chatError) {
|
if (!pendingGeneration || !chatError) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5516,7 +5472,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [chatError, clearError, pendingGeneration]);
|
}, [chatError, clearError, pendingGeneration]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || pendingGeneration.backendTaskId) {
|
if (!pendingGeneration) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -6242,6 +6198,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const processedStatuses = new Set<ReplacementCardStatus>([
|
const processedStatuses = new Set<ReplacementCardStatus>([
|
||||||
"completed",
|
"completed",
|
||||||
"passthrough",
|
"passthrough",
|
||||||
|
"fallback",
|
||||||
]);
|
]);
|
||||||
const remainingIds = resultCards
|
const remainingIds = resultCards
|
||||||
.filter((card) => !card.passthrough && !processedStatuses.has(card.status))
|
.filter((card) => !card.passthrough && !processedStatuses.has(card.status))
|
||||||
@ -6347,86 +6304,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置");
|
toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (replacementModeType === "detail_replacement") {
|
|
||||||
if (detailBatchSubmittingRef.current) return;
|
|
||||||
if (!isValidChatUuid(projectId)) {
|
|
||||||
toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const productImageUrls = Array.from(new Set(
|
|
||||||
(detailReplacementProductSourceImages.length > 0
|
|
||||||
? detailReplacementProductSourceImages.map((item) => item.url)
|
|
||||||
: selectedProductAttachments.map((item) => item.url))
|
|
||||||
.filter((url) => isValidChatHttpUrl(url)),
|
|
||||||
));
|
|
||||||
if (productImageUrls.length === 0 && !replacementExecutionCards.every((card) => card.passthrough)) {
|
|
||||||
toast.error("请先在输入图区选择至少一张产品图");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
detailBatchSubmittingRef.current = true;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const saved = await onPersistProject?.();
|
|
||||||
if (saved === false) throw new Error("项目尚未保存,请重试");
|
|
||||||
const requestId = generateUUID();
|
|
||||||
const response = await fetchWithErrorHandlers("/api/agents/detail-replacement/batches", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
projectId,
|
|
||||||
requestId,
|
|
||||||
model: resolvedModelId,
|
|
||||||
imageSize: imageParams.imageSize,
|
|
||||||
replacementStrategy: detailReplacementStrategy,
|
|
||||||
prompt: detailReplacementStrategy === "product_only"
|
|
||||||
? "仅替换参考详情图中的产品主体"
|
|
||||||
: String(resolvedWorkflowContext?.promptText || "").trim(),
|
|
||||||
userPrompt: String(options?.promptText || input || "").trim(),
|
|
||||||
productImageUrls,
|
|
||||||
modelImageUrl: detailReplacementModelSourceImage?.url || selectedModelAttachment?.url || null,
|
|
||||||
references: replacementExecutionCards.map((card) => ({
|
|
||||||
id: card.id,
|
|
||||||
label: card.label,
|
|
||||||
imageUrl: card.imageUrl,
|
|
||||||
candidateId: card.candidateId,
|
|
||||||
passthrough: card.passthrough,
|
|
||||||
rerunReferenceImages:
|
|
||||||
options?.replacementRerunReferenceImages?.cardId === card.id
|
|
||||||
? options.replacementRerunReferenceImages.images
|
|
||||||
: [],
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok || !payload?.task?.id) {
|
|
||||||
throw new Error(payload?.error || "详情替换任务提交失败");
|
|
||||||
}
|
|
||||||
setPendingGeneration({
|
|
||||||
chatId,
|
|
||||||
requestMessageId: requestId,
|
|
||||||
backendTaskId: payload.task.id,
|
|
||||||
modeType: "detail_replacement",
|
|
||||||
expectedCount: replacementExecutionCards.length,
|
|
||||||
userTurnCountAtStart: userMessageCount,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
requestedCardIds: replacementExecutionCards.map((card) => card.id),
|
|
||||||
});
|
|
||||||
setReplacementExecutionContext({
|
|
||||||
modeType: "detail_replacement",
|
|
||||||
cardIds: replacementExecutionCards.map((card) => card.id),
|
|
||||||
});
|
|
||||||
setActiveResultModeType("detail_replacement");
|
|
||||||
setSelectedResultUrl(null);
|
|
||||||
await refreshDetailBatch();
|
|
||||||
toast.success(`已提交 ${replacementExecutionCards.length} 张详情图,离开页面后仍会继续处理`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "详情替换任务提交失败");
|
|
||||||
} finally {
|
|
||||||
detailBatchSubmittingRef.current = false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
|
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
|
||||||
const sanitizedAttachments = sceneMessageAttachments;
|
const sanitizedAttachments = sceneMessageAttachments;
|
||||||
if (
|
if (
|
||||||
@ -6576,8 +6453,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
detailResultCards,
|
detailResultCards,
|
||||||
detailReplacementReferenceCards.length,
|
detailReplacementReferenceCards.length,
|
||||||
detailReplacementStrategy,
|
detailReplacementStrategy,
|
||||||
detailReplacementProductSourceImages,
|
|
||||||
detailReplacementModelSourceImage,
|
|
||||||
handleRunAiDetailGeneration,
|
handleRunAiDetailGeneration,
|
||||||
currentResultModeType,
|
currentResultModeType,
|
||||||
getReplacementExecutionCards,
|
getReplacementExecutionCards,
|
||||||
@ -6608,10 +6483,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
scenePrimaryReferenceCount,
|
scenePrimaryReferenceCount,
|
||||||
sceneSecondaryReferenceCount,
|
sceneSecondaryReferenceCount,
|
||||||
selectedModelAttachment,
|
selectedModelAttachment,
|
||||||
selectedProductAttachments,
|
|
||||||
onPersistProject,
|
|
||||||
projectId,
|
|
||||||
refreshDetailBatch,
|
|
||||||
]);
|
]);
|
||||||
const handleStopGeneration = useCallback(() => {
|
const handleStopGeneration = useCallback(() => {
|
||||||
detailGenerationCancelRef.current = true;
|
detailGenerationCancelRef.current = true;
|
||||||
@ -9468,7 +9339,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{isAnyGenerationRunning && !pendingGeneration?.backendTaskId ? (
|
{isAnyGenerationRunning ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -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,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
|
||||||
|
|
||||||
import { Loader2, Save } from "lucide-react";
|
|
||||||
|
|
||||||
type CanvasSurfaceHeaderProps = {
|
|
||||||
isSaving: boolean;
|
|
||||||
onAddImage: () => void;
|
|
||||||
onHome: () => void;
|
|
||||||
onOpenFiles: () => void;
|
|
||||||
onSave: () => 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({
|
|
||||||
isSaving,
|
|
||||||
onAddImage,
|
|
||||||
onHome,
|
|
||||||
onOpenFiles,
|
|
||||||
onSave,
|
|
||||||
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>
|
|
||||||
<button
|
|
||||||
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] border border-[#e5e5e5] px-3 text-[12px] text-[#333333] transition-colors hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:text-white"
|
|
||||||
disabled={isSaving}
|
|
||||||
onClick={onSave}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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)} />;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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,324 +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 {
|
|
||||||
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";
|
|
||||||
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;
|
|
||||||
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) => 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -42,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";
|
||||||
@ -56,7 +56,6 @@ 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 {
|
import {
|
||||||
@ -1308,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"
|
||||||
@ -1535,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 ||
|
||||||
@ -1565,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);
|
||||||
@ -8335,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("已取消下载");
|
||||||
@ -9646,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} />
|
||||||
@ -10200,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()}
|
||||||
@ -10229,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"
|
||||||
>
|
>
|
||||||
@ -10241,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>
|
||||||
@ -10257,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}
|
||||||
@ -10268,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>
|
||||||
@ -10277,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")}
|
||||||
@ -10288,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} />
|
||||||
@ -10298,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")}
|
||||||
@ -10309,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
|
||||||
@ -10322,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
|
||||||
@ -10332,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 ? (
|
||||||
@ -10343,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}
|
||||||
@ -10357,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")}
|
||||||
@ -10368,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>
|
||||||
@ -10377,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")}
|
||||||
@ -10388,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} />
|
||||||
@ -10398,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")}
|
||||||
@ -10409,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
|
||||||
@ -10422,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")}
|
||||||
@ -10433,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
|
||||||
@ -10446,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")}
|
||||||
@ -10457,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
|
||||||
@ -10470,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")}
|
||||||
@ -10488,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")}
|
||||||
@ -10499,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
|
||||||
@ -10512,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")}
|
||||||
@ -10534,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")}
|
||||||
@ -10556,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")}
|
||||||
@ -10567,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>
|
||||||
@ -10576,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")}
|
||||||
@ -10587,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} />
|
||||||
@ -10597,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")}
|
||||||
@ -10608,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>
|
||||||
@ -10617,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")}
|
||||||
@ -10628,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>
|
||||||
@ -10637,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")}
|
||||||
@ -10648,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>
|
||||||
@ -10657,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")}
|
||||||
@ -10679,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")}
|
||||||
@ -10690,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>
|
||||||
@ -10716,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") ? (
|
||||||
@ -10732,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} />
|
||||||
@ -10745,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} />
|
||||||
@ -10760,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} />
|
||||||
@ -10773,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
|
||||||
@ -10798,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
|
||||||
@ -10841,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>
|
||||||
@ -10853,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} />
|
||||||
@ -10866,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>
|
||||||
@ -10878,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>
|
||||||
@ -10890,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>
|
||||||
@ -10917,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>
|
||||||
@ -10958,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"
|
||||||
>
|
>
|
||||||
@ -10969,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
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,144 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ArrowRight, Check, Loader2, Sparkles, Upload, X } from "lucide-react";
|
|
||||||
import { requestCreativeAnalysis, type CreativeAnalysisModel } from "./creativeAnalysisClient";
|
|
||||||
|
|
||||||
export type StudioCharacter = {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
look?: string;
|
|
||||||
wardrobe?: string;
|
|
||||||
signatureAction?: string;
|
|
||||||
traits?: string[];
|
|
||||||
prompt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = ["电影写实", "国风动画", "二维动漫", "三维动画", "插画概念"];
|
|
||||||
const views = ["角色立绘", "脸部近景", "表情参考", "角色三视图"] as const;
|
|
||||||
export type StudioView = (typeof views)[number];
|
|
||||||
|
|
||||||
export function CharacterStudioPanel({
|
|
||||||
canvasId,
|
|
||||||
models,
|
|
||||||
onApply,
|
|
||||||
onClose,
|
|
||||||
onOpenLibrary,
|
|
||||||
onUpload,
|
|
||||||
}: {
|
|
||||||
canvasId: string;
|
|
||||||
models: CreativeAnalysisModel[];
|
|
||||||
onApply: (character: StudioCharacter, style: string, selectedViews: StudioView[], referenceUrl?: string) => Promise<boolean>;
|
|
||||||
onClose: () => void;
|
|
||||||
onOpenLibrary: () => void;
|
|
||||||
onUpload: (file: File) => Promise<string>;
|
|
||||||
}) {
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [style, setStyle] = useState(styles[0]);
|
|
||||||
const [characters, setCharacters] = useState<StudioCharacter[]>([]);
|
|
||||||
const [selected, setSelected] = useState(0);
|
|
||||||
const [selectedViews, setSelectedViews] = useState<StudioView[]>([...views]);
|
|
||||||
const [referenceUrl, setReferenceUrl] = useState<string>();
|
|
||||||
const [referenceName, setReferenceName] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [restoredDraftFor, setRestoredDraftFor] = useState("");
|
|
||||||
const character = characters[selected];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const draft = JSON.parse(localStorage.getItem(`tvstar:character-draft:${canvasId}`) || "null");
|
|
||||||
if (draft && typeof draft === "object") {
|
|
||||||
if (typeof draft.description === "string") setDescription(draft.description);
|
|
||||||
if (styles.includes(draft.style)) setStyle(draft.style);
|
|
||||||
if (Array.isArray(draft.characters)) setCharacters(draft.characters.filter((item: StudioCharacter) =>
|
|
||||||
item && typeof item.name === "string" && typeof item.prompt === "string").slice(0, 8));
|
|
||||||
if (Number.isInteger(draft.selected)) setSelected(Math.max(0, draft.selected));
|
|
||||||
if (Array.isArray(draft.selectedViews)) setSelectedViews(draft.selectedViews.filter((item: StudioView) =>
|
|
||||||
views.includes(item)));
|
|
||||||
if (typeof draft.referenceUrl === "string") setReferenceUrl(draft.referenceUrl);
|
|
||||||
if (typeof draft.referenceName === "string") setReferenceName(draft.referenceName);
|
|
||||||
}
|
|
||||||
} catch { /* a damaged local draft must not block the studio */ }
|
|
||||||
setRestoredDraftFor(canvasId);
|
|
||||||
}, [canvasId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (restoredDraftFor !== canvasId) return;
|
|
||||||
try { localStorage.setItem(`tvstar:character-draft:${canvasId}`, JSON.stringify({
|
|
||||||
description, style, characters, selected, selectedViews, referenceUrl, referenceName,
|
|
||||||
})); } catch { /* storage is optional */ }
|
|
||||||
}, [canvasId, characters, description, referenceName, referenceUrl, restoredDraftFor, selected, selectedViews, style]);
|
|
||||||
|
|
||||||
const resetDraft = () => {
|
|
||||||
setDescription(""); setStyle(styles[0]); setCharacters([]); setSelected(0);
|
|
||||||
setSelectedViews([...views]); setReferenceUrl(undefined); setReferenceName(""); setError("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const analyze = async () => {
|
|
||||||
if (!description.trim() || busy) return;
|
|
||||||
setError(""); setBusy(true);
|
|
||||||
try {
|
|
||||||
const data = await requestCreativeAnalysis({
|
|
||||||
mode: "character-scene-extraction",
|
|
||||||
nodeTitle: "角色造型室",
|
|
||||||
models,
|
|
||||||
prompt: `视觉风格:${style}。请提取并具体定义角色身份、稳定的面部与服装特征,提供可复用的造型提示词。\n角色描述:${description.trim()}`,
|
|
||||||
imageUrls: referenceUrl ? [referenceUrl] : [],
|
|
||||||
});
|
|
||||||
const items = Array.isArray(data.characters) ? data.characters.filter((item): item is StudioCharacter => Boolean(item && typeof item === "object" && (item as StudioCharacter).name && (item as StudioCharacter).prompt)).slice(0, 8) : [];
|
|
||||||
if (!items.length) throw new Error("模型没有返回有效角色,请补充外观描述后重试");
|
|
||||||
setCharacters(items);
|
|
||||||
setSelected(0);
|
|
||||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "角色分析失败"); }
|
|
||||||
finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const upload = async (file?: File) => {
|
|
||||||
if (!file || !file.type.startsWith("image/")) return;
|
|
||||||
setError(""); setBusy(true);
|
|
||||||
try { setReferenceUrl(await onUpload(file)); setReferenceName(file.name); }
|
|
||||||
catch (cause) { setError(cause instanceof Error ? cause.message : "参考图上传失败"); }
|
|
||||||
finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleView = (view: StudioView) => setSelectedViews(current => current.includes(view) ? current.filter(item => item !== view) : [...current, view]);
|
|
||||||
|
|
||||||
const apply = async () => {
|
|
||||||
if (!character || !selectedViews.length || busy) return;
|
|
||||||
setError(""); setBusy(true);
|
|
||||||
try {
|
|
||||||
if (!await onApply(character, style, selectedViews, referenceUrl))
|
|
||||||
setError("角色视图任务未能启动,请检查模型与生成配置后重试");
|
|
||||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "角色视图任务未能启动"); }
|
|
||||||
finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
return <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/65 p-4" data-canvas-overlay="true" role="presentation">
|
|
||||||
<section aria-label="角色造型室" aria-modal="true" className="flex h-[min(720px,calc(100vh-32px))] w-[min(1060px,calc(100vw-32px))] flex-col overflow-hidden rounded-[22px] border border-white/10 bg-[#202020] text-white shadow-[0_30px_90px_rgba(0,0,0,.6)]" role="dialog">
|
|
||||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-white/10 px-5"><div><h2 className="text-base font-medium">角色造型室</h2><p className="text-[11px] text-white/40">建立稳定角色设定,再送入画布生成视图</p></div><div className="flex items-center gap-2"><button className="rounded-lg px-2 py-1 text-xs text-white/50 hover:bg-white/10" disabled={busy} onClick={resetDraft}>新角色</button><button aria-label="关闭角色造型室" className="rounded-lg p-2 text-white/60 hover:bg-white/10" onClick={onClose}><X className="size-4" /></button></div></header>
|
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(260px,340px)_1fr] max-md:grid-cols-1">
|
|
||||||
<div className="flex min-h-0 flex-col border-r border-white/10 p-5 max-md:border-b max-md:border-r-0">
|
|
||||||
<label className="mb-2 text-xs text-white/65" htmlFor="tvstar-character-description">角色描述</label>
|
|
||||||
<textarea className="min-h-44 w-full resize-none rounded-xl border border-white/10 bg-[#292929] p-3 text-[13px] leading-6 outline-none placeholder:text-white/30 focus:border-cyan-300/50" id="tvstar-character-description" onChange={event => setDescription(event.target.value)} placeholder="例如:二十多岁的机械师,短银发,左眼下有一颗痣,穿旧皮夹克,性格沉着…" value={description} />
|
|
||||||
<label className="mb-2 mt-4 text-xs text-white/65" htmlFor="tvstar-character-style">视觉风格</label>
|
|
||||||
<select className="h-10 rounded-xl border border-white/10 bg-[#292929] px-3 text-[13px] outline-none" id="tvstar-character-style" onChange={event => setStyle(event.target.value)} value={style}>{styles.map(item => <option key={item}>{item}</option>)}</select>
|
|
||||||
<label className="mt-4 flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-white/15 bg-white/[.03] px-3 py-3 text-xs text-white/60 hover:bg-white/[.06]"><Upload className="size-4" />{referenceName || "上传角色参考图(可选)"}<input accept="image/*" className="hidden" onChange={event => { const file = event.target.files?.[0]; event.target.value = ""; void upload(file); }} type="file" /></label>
|
|
||||||
{referenceUrl ? <button className="mt-2 self-start text-[11px] text-white/40 hover:text-white" onClick={() => { setReferenceUrl(undefined); setReferenceName(""); }}>移除参考图</button> : null}
|
|
||||||
{error ? <p className="mt-3 rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-200" role="alert">{error}</p> : null}
|
|
||||||
<div className="mt-auto space-y-2 pt-5"><button className="flex h-10 w-full items-center justify-center gap-2 rounded-xl bg-white text-[13px] font-medium text-[#1f1f1f] disabled:opacity-40" disabled={!description.trim() || busy} onClick={() => void analyze()}>{busy ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}{busy ? "正在分析角色…" : "分析角色设定"}</button><button className="flex h-9 w-full items-center justify-center gap-1 rounded-xl text-xs text-white/55 hover:bg-white/5" onClick={onOpenLibrary}>浏览现有角色库 <ArrowRight className="size-3" /></button></div>
|
|
||||||
</div>
|
|
||||||
<div className="min-h-0 overflow-y-auto p-5">
|
|
||||||
{character ? <>
|
|
||||||
<div className="mb-4 flex flex-wrap gap-2">{characters.map((item, index) => <button aria-pressed={selected === index} className={`rounded-lg px-3 py-1.5 text-xs ${selected === index ? "bg-white text-[#252525]" : "bg-white/10 text-white/65 hover:bg-white/15"}`} key={`${index}-${item.name}`} onClick={() => setSelected(index)}>{item.name}</button>)}</div>
|
|
||||||
<h3 className="mb-2 text-lg font-medium">{character.name}</h3><p className="text-[13px] leading-6 text-white/65">{character.description}</p>
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-2 text-xs max-sm:grid-cols-1">{[["外观锚点", character.look], ["服装与道具", character.wardrobe], ["代表动作", character.signatureAction], ["性格关键词", character.traits?.join(" · ")]].filter(([, value]) => value).map(([label, value]) => <div className="rounded-xl bg-white/5 p-3" key={label}><span className="mb-1 block text-white/40">{label}</span><span className="leading-5 text-white/80">{value}</span></div>)}</div>
|
|
||||||
<div className="mt-5"><h4 className="mb-3 text-xs text-white/55">选择要创建的角色视图</h4><div className="grid grid-cols-4 gap-2 max-md:grid-cols-2">{views.map((view, index) => <button aria-pressed={selectedViews.includes(view)} className={`relative flex h-32 flex-col items-center justify-center rounded-xl border text-xs ${selectedViews.includes(view) ? "border-cyan-300/70 bg-cyan-300/10" : "border-white/10 bg-white/5"}`} key={view} onClick={() => toggleView(view)}><span className="mb-3 text-2xl text-white/30">{["◉", "◌", "☺", "◧"][index]}</span>{view}{selectedViews.includes(view) ? <Check className="absolute right-2 top-2 size-3 text-cyan-300" /> : null}</button>)}</div></div>
|
|
||||||
<div className="mt-5 rounded-xl border border-white/10 bg-white/5 p-3"><p className="mb-2 text-[11px] text-white/40">一致性提示词</p><p className="text-xs leading-5 text-white/70">{character.prompt}</p></div>
|
|
||||||
<button className="mt-4 h-10 rounded-xl bg-white px-5 text-[13px] font-medium text-[#252525] disabled:opacity-40" disabled={!selectedViews.length || busy} onClick={() => void apply()}>{busy ? "正在启动角色出图…" : `生成并应用至画布 · ${selectedViews.length} 个视图`}</button>
|
|
||||||
</> : <div className="flex h-full min-h-56 flex-col items-center justify-center text-center"><div className="mb-5 flex size-20 items-center justify-center rounded-full bg-gradient-to-br from-cyan-300/15 via-indigo-400/15 to-violet-400/15 text-3xl text-cyan-200">☺</div><p className="text-sm text-white/75">从一个角色想法开始</p><p className="mt-2 max-w-sm text-xs leading-5 text-white/40">分析身份、面部锚点、服装与动作,再创建立绘、近景、表情和三视图节点。</p></div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
@ -1,832 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { ArrowUp, Check, ChevronDown, Clock3, Copy, Download, FilePlus2, Loader2, Minus, PanelRight, Paperclip, Plug, Plus, Share2, SlidersHorizontal, Sparkles } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { requestCreativeAnalysis, type CreativeAnalysisModel } from "./creativeAnalysisClient";
|
|
||||||
import { buildTVstarRevisionPrompt, tvstarAllowsGeneration, tvstarRequestsRunExisting, tvstarWantsDraftNodes, type TVstarMessage } from "./session-context";
|
|
||||||
import type { CanvasGenerationTask } from "@/lib/video-canvas/task-contract";
|
|
||||||
import { mergeTVstarSessions } from "@/lib/video-canvas/tvstar-session";
|
|
||||||
|
|
||||||
export type TVstarShot = {
|
|
||||||
shot: string;
|
|
||||||
duration: string;
|
|
||||||
scene: string;
|
|
||||||
sceneDescription: string;
|
|
||||||
shotSize: string;
|
|
||||||
lighting: string;
|
|
||||||
dialogue: string;
|
|
||||||
backgroundMusic: string;
|
|
||||||
voiceAndSfx: string;
|
|
||||||
cameraMovement: string;
|
|
||||||
imagePrompt: string;
|
|
||||||
motionPrompt: string;
|
|
||||||
keyframeUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TVstarReference = { id: string; title: string; url: string; type: "image" | "video"; duration?: number };
|
|
||||||
export type TVstarNodeContext = { id: string; title: string; type: string; description: string };
|
|
||||||
export type TVstarModel = CreativeAnalysisModel;
|
|
||||||
export type TVstarGenerationModel = { id: string; label: string; kind: "image" | "video"; supportsReference?: boolean };
|
|
||||||
export type TVstarGenerationSelection = { imageModelRef?: string; videoModelRef?: string };
|
|
||||||
|
|
||||||
type Plan = {
|
|
||||||
summary: string;
|
|
||||||
agentPlan: string[];
|
|
||||||
deliverables: string[];
|
|
||||||
recommendedNodes: string[];
|
|
||||||
model?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Session = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
goal: string;
|
|
||||||
mode: Mode;
|
|
||||||
plan?: Plan;
|
|
||||||
shots?: TVstarShot[];
|
|
||||||
references?: TVstarReference[];
|
|
||||||
nodeContexts?: TVstarNodeContext[];
|
|
||||||
messages?: TVstarMessage[];
|
|
||||||
imageModelRef?: string;
|
|
||||||
videoModelRef?: string;
|
|
||||||
autoGenerate?: boolean;
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt?: number;
|
|
||||||
shareToken?: string;
|
|
||||||
pendingStage?: "plan" | "shots" | "submit";
|
|
||||||
runSubmitted?: boolean;
|
|
||||||
preparedOnly?: boolean;
|
|
||||||
groupId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Mode = "full" | "brainstorm" | "script" | "storyboard" | "edit";
|
|
||||||
|
|
||||||
const modes: { value: Mode; label: string; description: string }[] = [
|
|
||||||
{ value: "full", label: "全能创作", description: "自动规划、生成镜头、选片并合成视频" },
|
|
||||||
{ value: "brainstorm", label: "创意脑暴", description: "拆解方向与交付物" },
|
|
||||||
{ value: "script", label: "剧本创编", description: "直接生成结构化镜头脚本" },
|
|
||||||
{ value: "storyboard", label: "批量分镜", description: "将故事拆成画布镜头" },
|
|
||||||
{ value: "edit", label: "片段智能剪辑", description: "从引用的视频中选高光并直接合成" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const templates = [
|
|
||||||
{ name: "产品广告", category: "商业广告", description: "使用场景、卖点和品牌收尾", prompt: "创作一支 15 秒的产品广告。先明确产品、受众与核心卖点,用使用场景建立需求,安排产品特写和清晰的品牌收尾。" },
|
|
||||||
{ name: "新中式美学 TVC", category: "商业广告", description: "中式材质、留白和电影光影", prompt: "创作一支新中式美学 TVC。统一器物、空间、服饰与色彩;用留白、自然材质和克制的镜头运动呈现产品,不要无关人物。" },
|
|
||||||
{ name: "电商展示片", category: "商业广告", description: "卖点可见、动作清楚、适合竖屏", prompt: "创作一支电商商品展示视频。每个镜头只传达一个可见卖点,展示使用动作、细节特写和明确收尾;避免生成无法验证的功效。" },
|
|
||||||
{ name: "电影感短片", category: "剧情短片", description: "人物、冲突、情绪转折", prompt: "创作一支 30 秒的电影感短片。先明确人物、冲突与情绪转折,再安排景别变化、动作衔接和声音。" },
|
|
||||||
{ name: "角色出场", category: "剧情短片", description: "外观锚点与层层揭示", prompt: "设计角色首次出场的短片。保持面部、发型、服装和道具一致,通过景别和运镜逐步揭示人物。" },
|
|
||||||
{ name: "旅行 Vlog", category: "剧情短片", description: "地点、人物和环境声连贯", prompt: "创作电影感旅行 Vlog。建立地点与人物,按出发、探索、发现、收尾推进;保持时空顺序和环境声连续。" },
|
|
||||||
{ name: "参考片风格复刻", category: "素材改编", description: "分析参考视频后生成新内容", prompt: "分析我引用的参考视频的镜头节奏、构图、光影和情绪,生成主题不同但视听语言一致的新作品。不要复制原片人物、标识或台词。" },
|
|
||||||
{ name: "故事改编", category: "素材改编", description: "保留人物关系和关键情节", prompt: "将我上传或粘贴的故事改编为可生成的视频分镜,保留角色关系和关键情节,压缩次要情节并保持前后连贯。" },
|
|
||||||
{ name: "分镜预演", category: "分镜脚本", description: "先确定镜头与动作,再制作", prompt: "先拆出清楚的镜头目标、景别、时长、人物动作和前后衔接。每个镜头只描述一个可执行动作,优先保证角色和场景一致。" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const panel = "rounded-xl border border-white/10 bg-[#252525]";
|
|
||||||
|
|
||||||
function isShot(value: unknown): value is TVstarShot {
|
|
||||||
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({
|
|
||||||
canvasId,
|
|
||||||
models,
|
|
||||||
generationModels,
|
|
||||||
tasks,
|
|
||||||
references,
|
|
||||||
canvasNodes,
|
|
||||||
selectedCanvasNode,
|
|
||||||
onApply,
|
|
||||||
onPrepare,
|
|
||||||
onRunPrepared,
|
|
||||||
onEdit,
|
|
||||||
onClose,
|
|
||||||
onOpenPlugin,
|
|
||||||
docked,
|
|
||||||
dockWidth,
|
|
||||||
onDockChange,
|
|
||||||
onDockWidthChange,
|
|
||||||
onUpload,
|
|
||||||
}: {
|
|
||||||
canvasId: string;
|
|
||||||
models: TVstarModel[];
|
|
||||||
generationModels: TVstarGenerationModel[];
|
|
||||||
tasks: CanvasGenerationTask[];
|
|
||||||
references: TVstarReference[];
|
|
||||||
canvasNodes: TVstarNodeContext[];
|
|
||||||
selectedCanvasNode: TVstarNodeContext | null;
|
|
||||||
onApply: (shots: TVstarShot[], goal: string, references: TVstarReference[], selection: TVstarGenerationSelection) => Promise<string | false>;
|
|
||||||
onPrepare: (shots: TVstarShot[], goal: string, references: TVstarReference[], selection: TVstarGenerationSelection) => Promise<string | false>;
|
|
||||||
onRunPrepared: (groupId: string) => Promise<string | false>;
|
|
||||||
onEdit: (references: TVstarReference[], goal: string) => Promise<string | false>;
|
|
||||||
onClose: () => void;
|
|
||||||
onOpenPlugin: () => void;
|
|
||||||
docked: boolean;
|
|
||||||
dockWidth: number;
|
|
||||||
onDockChange: (value: boolean) => void;
|
|
||||||
onDockWidthChange: (value: number) => void;
|
|
||||||
onUpload: (file: File) => Promise<{ url: string; type: "image" | "video"; duration?: number }>;
|
|
||||||
}) {
|
|
||||||
const key = `tvstar:sessions:${canvasId}`;
|
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
|
||||||
const sessionsRef = useRef<Session[]>([]);
|
|
||||||
const [syncReady, setSyncReady] = useState(false);
|
|
||||||
const [syncError, setSyncError] = useState(false);
|
|
||||||
const [shareConfirm, setShareConfirm] = useState(false);
|
|
||||||
const [shareBusy, setShareBusy] = useState(false);
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
|
||||||
const [goal, setGoal] = useState("");
|
|
||||||
const [mode, setMode] = useState<Mode>("full");
|
|
||||||
const [modeOpen, setModeOpen] = useState(false);
|
|
||||||
const [modelOpen, setModelOpen] = useState(false);
|
|
||||||
const [modelRef, setModelRef] = useState("");
|
|
||||||
const [modelTab, setModelTab] = useState<"image" | "video" | "planning">("video");
|
|
||||||
const [imageModelRef, setImageModelRef] = useState("");
|
|
||||||
const [videoModelRef, setVideoModelRef] = useState("");
|
|
||||||
const [autoGenerate, setAutoGenerate] = useState(true);
|
|
||||||
const [policyOpen, setPolicyOpen] = useState(false);
|
|
||||||
const [templatesOpen, setTemplatesOpen] = useState(false);
|
|
||||||
const [skillSearch, setSkillSearch] = useState("");
|
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
|
||||||
const [headerMenu, setHeaderMenu] = useState<"share" | "settings" | null>(null);
|
|
||||||
const [notificationsEnabled, setNotificationsEnabled] = useState(false);
|
|
||||||
const [notificationSound, setNotificationSound] = useState(false);
|
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
|
||||||
const notifiedTaskRef = useRef<string | null>(null);
|
|
||||||
const [referencesOpen, setReferencesOpen] = useState(false);
|
|
||||||
const [selectedRefs, setSelectedRefs] = useState<TVstarReference[]>([]);
|
|
||||||
const [selectedContextNodes, setSelectedContextNodes] = useState<TVstarNodeContext[]>([]);
|
|
||||||
const lastSelectedCanvasNodeId = useRef<string | null>(null);
|
|
||||||
const [restoredDraftFor, setRestoredDraftFor] = useState("");
|
|
||||||
const [busy, setBusy] = useState<"plan" | "shots" | "submit" | "upload" | null>(null);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
const onApplyRef = useRef(onApply);
|
|
||||||
const onPrepareRef = useRef(onPrepare);
|
|
||||||
const onRunPreparedRef = useRef(onRunPrepared);
|
|
||||||
const onEditRef = useRef(onEdit);
|
|
||||||
const modelsRef = useRef(models);
|
|
||||||
useEffect(() => { onApplyRef.current = onApply; }, [onApply]);
|
|
||||||
useEffect(() => { onPrepareRef.current = onPrepare; }, [onPrepare]);
|
|
||||||
useEffect(() => { onRunPreparedRef.current = onRunPrepared; }, [onRunPrepared]);
|
|
||||||
useEffect(() => { onEditRef.current = onEdit; }, [onEdit]);
|
|
||||||
useEffect(() => { modelsRef.current = models; }, [models]);
|
|
||||||
const active = sessions.find(item => item.id === activeId);
|
|
||||||
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 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 productionStatus = active?.preparedOnly ? "待运行工作流已建好,检查后可开始制作"
|
|
||||||
: activeTasks.some(task => task.status === "failed" || task.status === "interrupted")
|
|
||||||
? "有任务需要处理,请查看画布中的失败节点"
|
|
||||||
: finalUrl ? "成片已完成" : activeTasks.some(task => task.status === "running")
|
|
||||||
? "正在生成或分析视频" : activeTasks.length ? "任务已排队" : "任务已提交,正在同步进度";
|
|
||||||
const mention = goal.match(/(?:^|\s)@([^@\s]*)$/)?.[1];
|
|
||||||
const mentionMatches = mention === undefined ? [] : references.filter(item =>
|
|
||||||
item.title.toLowerCase().includes(mention.toLowerCase()) &&
|
|
||||||
!selectedRefs.some(selected => selected.url === item.url),
|
|
||||||
).slice(0, 6);
|
|
||||||
const mentionModelMatches = mention === undefined ? [] : generationModels.filter(item =>
|
|
||||||
item.label.toLowerCase().includes(mention.toLowerCase()) &&
|
|
||||||
(item.kind !== "image" || !(selectedRefs.length || active?.references?.length) || item.supportsReference !== false),
|
|
||||||
).slice(0, 8);
|
|
||||||
const mentionNodeMatches = mention === undefined ? [] : canvasNodes.filter(item =>
|
|
||||||
item.title.toLowerCase().includes(mention.toLowerCase()) &&
|
|
||||||
!selectedContextNodes.some(selected => selected.id === item.id),
|
|
||||||
).slice(0, 6);
|
|
||||||
const message = (role: TVstarMessage["role"], content: string): TVstarMessage => ({
|
|
||||||
id: crypto.randomUUID(), role, content, createdAt: Date.now(),
|
|
||||||
});
|
|
||||||
const updateSession = (updated: Session) => saveSessions(sessionsRef.current.map(item => item.id === updated.id ? updated : item));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const saved = JSON.parse(localStorage.getItem(key) || "[]");
|
|
||||||
if (Array.isArray(saved)) {
|
|
||||||
const restoredSessions = saved.filter((item): item is Session => Boolean(item && typeof item.id === "string" && typeof item.createdAt === "number" && Array.isArray(item.messages))).slice(0, 20);
|
|
||||||
sessionsRef.current = restoredSessions;
|
|
||||||
setSessions(restoredSessions);
|
|
||||||
const remembered = localStorage.getItem(`tvstar:active:${canvasId}`);
|
|
||||||
const restored = saved.find((item: Session) => item.id === remembered) || saved[0];
|
|
||||||
setActiveId(restored?.id || null);
|
|
||||||
setImageModelRef(restored?.imageModelRef || "");
|
|
||||||
setVideoModelRef(restored?.videoModelRef || "");
|
|
||||||
}
|
|
||||||
setAutoGenerate(localStorage.getItem(`tvstar:auto:${canvasId}`) !== "manual");
|
|
||||||
setNotificationsEnabled(localStorage.getItem(`tvstar:notifications:${canvasId}`) === "1");
|
|
||||||
setNotificationSound(localStorage.getItem(`tvstar:notification-sound:${canvasId}`) === "1");
|
|
||||||
setGoal(localStorage.getItem(`tvstar:draft:${canvasId}`) || "");
|
|
||||||
const draftRefs = JSON.parse(localStorage.getItem(`tvstar:draft-refs:${canvasId}`) || "[]");
|
|
||||||
if (Array.isArray(draftRefs)) setSelectedRefs(draftRefs.filter((item): item is TVstarReference =>
|
|
||||||
Boolean(item && typeof item.url === "string" && (item.type === "image" || item.type === "video"))).slice(0, 6));
|
|
||||||
const draftNodes = JSON.parse(localStorage.getItem(`tvstar:draft-nodes:${canvasId}`) || "[]");
|
|
||||||
if (Array.isArray(draftNodes)) setSelectedContextNodes(draftNodes.filter((item): item is TVstarNodeContext =>
|
|
||||||
Boolean(item && typeof item.id === "string" && typeof item.title === "string")).slice(0, 6));
|
|
||||||
} catch { /* a damaged local draft must not block the canvas */ }
|
|
||||||
setRestoredDraftFor(canvasId);
|
|
||||||
}, [key]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (restoredDraftFor !== canvasId) return;
|
|
||||||
let cancelled = false;
|
|
||||||
void fetch(`/api/canvas/${encodeURIComponent(canvasId)}/tvstar-sessions`, { cache: "no-store" })
|
|
||||||
.then(async response => {
|
|
||||||
if (!response.ok) throw new Error("读取对话失败");
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const rawRemote: unknown[] = Array.isArray(data.sessions) ? data.sessions : [];
|
|
||||||
const remote: Session[] = rawRemote.filter((item): item is Session =>
|
|
||||||
Boolean(item && typeof item === "object" && typeof (item as Session).id === "string" && typeof (item as Session).createdAt === "number"));
|
|
||||||
const sharedById = new Map(remote.map(item => [item.id, item.shareToken]));
|
|
||||||
const merged = mergeTVstarSessions(sessionsRef.current, remote).map(item =>
|
|
||||||
sharedById.has(item.id) ? { ...item, shareToken: sharedById.get(item.id) } : item);
|
|
||||||
sessionsRef.current = merged;
|
|
||||||
setSessions(merged);
|
|
||||||
try { localStorage.setItem(key, JSON.stringify(merged)); } catch { /* browser storage may be unavailable */ }
|
|
||||||
setActiveId(current => current || merged[0]?.id || null);
|
|
||||||
setSyncError(false);
|
|
||||||
}).catch(() => { if (!cancelled) setSyncError(true); })
|
|
||||||
.finally(() => { if (!cancelled) setSyncReady(true); });
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [canvasId, key, restoredDraftFor]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!syncReady || restoredDraftFor !== canvasId || !sessions.length) return;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
void fetch(`/api/canvas/${encodeURIComponent(canvasId)}/tvstar-sessions`, {
|
|
||||||
method: "PUT", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ sessions: sessions.map(item => ({ ...item, updatedAt: item.updatedAt || item.createdAt })) }),
|
|
||||||
signal: controller.signal,
|
|
||||||
}).then(response => { if (!response.ok) throw new Error("保存对话失败"); setSyncError(false); })
|
|
||||||
.catch(error => { if (error?.name !== "AbortError") setSyncError(true); });
|
|
||||||
}, 450);
|
|
||||||
return () => { window.clearTimeout(timer); controller.abort(); };
|
|
||||||
}, [canvasId, restoredDraftFor, sessions, syncReady]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (restoredDraftFor !== canvasId) return;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(`tvstar:draft:${canvasId}`, goal);
|
|
||||||
localStorage.setItem(`tvstar:draft-refs:${canvasId}`, JSON.stringify(selectedRefs));
|
|
||||||
localStorage.setItem(`tvstar:draft-nodes:${canvasId}`, JSON.stringify(selectedContextNodes));
|
|
||||||
} catch { /* storage is optional */ }
|
|
||||||
}, [canvasId, goal, restoredDraftFor, selectedRefs, selectedContextNodes]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!finalTask || finalTask.status !== "succeeded" || !finalUrl || !notificationsEnabled) return;
|
|
||||||
if (notifiedTaskRef.current === finalTask.id || typeof Notification === "undefined" || Notification.permission !== "granted") return;
|
|
||||||
notifiedTaskRef.current = finalTask.id;
|
|
||||||
new Notification("TVstar 成片已完成", { body: active?.title || "视频已完成,可以在画布中预览。" });
|
|
||||||
if (notificationSound) {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const context = audioContextRef.current || new AudioContext();
|
|
||||||
audioContextRef.current = context;
|
|
||||||
await context.resume();
|
|
||||||
const oscillator = context.createOscillator();
|
|
||||||
const gain = context.createGain();
|
|
||||||
oscillator.type = "sine";
|
|
||||||
oscillator.frequency.setValueAtTime(720, context.currentTime);
|
|
||||||
oscillator.frequency.setValueAtTime(960, context.currentTime + 0.12);
|
|
||||||
gain.gain.setValueAtTime(0.0001, context.currentTime);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.08, context.currentTime + 0.02);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.32);
|
|
||||||
oscillator.connect(gain).connect(context.destination);
|
|
||||||
oscillator.start();
|
|
||||||
oscillator.stop(context.currentTime + 0.34);
|
|
||||||
} catch { /* Browser playback may require another user gesture. */ }
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
}, [active?.title, finalTask, finalUrl, notificationSound, notificationsEnabled]);
|
|
||||||
|
|
||||||
useEffect(() => () => { void audioContextRef.current?.close(); }, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedCanvasNode) { lastSelectedCanvasNodeId.current = null; return; }
|
|
||||||
if (lastSelectedCanvasNodeId.current === selectedCanvasNode.id) return;
|
|
||||||
lastSelectedCanvasNodeId.current = selectedCanvasNode.id;
|
|
||||||
setSelectedContextNodes(current => current.some(item => item.id === selectedCanvasNode.id)
|
|
||||||
? current : [...current, selectedCanvasNode].slice(0, 6));
|
|
||||||
const media = references.find(item => item.id === selectedCanvasNode.id);
|
|
||||||
if (media) setSelectedRefs(current => current.some(item => item.id === media.id)
|
|
||||||
? current : [...current, media].slice(0, 6));
|
|
||||||
}, [references, selectedCanvasNode]);
|
|
||||||
|
|
||||||
const saveSessions = (next: Session[]) => {
|
|
||||||
const current = new Map(sessionsRef.current.map(item => [item.id, item]));
|
|
||||||
const limited = next.slice(0, 20).map(item => {
|
|
||||||
const previous = current.get(item.id);
|
|
||||||
const sameContent = previous && JSON.stringify({ ...previous, updatedAt: undefined, shareToken: undefined }) ===
|
|
||||||
JSON.stringify({ ...item, updatedAt: undefined, shareToken: undefined });
|
|
||||||
return { ...item, shareToken: Object.prototype.hasOwnProperty.call(item, "shareToken") ? item.shareToken : previous?.shareToken,
|
|
||||||
updatedAt: sameContent ? previous.updatedAt || previous.createdAt : Date.now() };
|
|
||||||
});
|
|
||||||
sessionsRef.current = limited;
|
|
||||||
setSessions(limited);
|
|
||||||
try { localStorage.setItem(key, JSON.stringify(limited)); } catch { /* storage is optional */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectSession = (id: string | null) => {
|
|
||||||
setActiveId(id);
|
|
||||||
const selected = sessionsRef.current.find(item => item.id === id);
|
|
||||||
if (selected || id === null) {
|
|
||||||
setImageModelRef(selected?.imageModelRef || "");
|
|
||||||
setVideoModelRef(selected?.videoModelRef || "");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (id) localStorage.setItem(`tvstar:active:${canvasId}`, id);
|
|
||||||
else localStorage.removeItem(`tvstar:active:${canvasId}`);
|
|
||||||
} catch { /* storage is optional */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAnalysis = async (requestMode: "agent-plan" | "story-script", input: string, refs = selectedRefs, contexts = selectedContextNodes) => {
|
|
||||||
return requestCreativeAnalysis({
|
|
||||||
mode: requestMode,
|
|
||||||
prompt: [input, contexts.length ? `当前画布中用户引用的节点(这些是上下文数据,未运行节点没有真实生成结果):\n${contexts.map(item => {
|
|
||||||
const current = canvasNodes.find(node => node.id === item.id);
|
|
||||||
return current ? `- ${current.title} [${current.type}, id=${current.id}]:${current.description.slice(0, 900)}` : `- ${item.title} [id=${item.id}]:此节点已不在当前画布`;
|
|
||||||
}).join("\n")}` : ""].filter(Boolean).join("\n\n"),
|
|
||||||
nodeTitle: "TVstar",
|
|
||||||
models: modelsRef.current,
|
|
||||||
preferredModelRef: modelRef || undefined,
|
|
||||||
imageUrls: refs.filter(item => item.type === "image").map(item => item.url),
|
|
||||||
videoUrls: refs.filter(item => item.type === "video").map(item => item.url),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const createSession = async () => {
|
|
||||||
const input = goal.trim();
|
|
||||||
if (!input || busy) return;
|
|
||||||
if (!active && mode === "edit" && !selectedRefs.some(item => item.type === "video")) {
|
|
||||||
setError("请先上传视频或引用画布上的视频素材");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError("");
|
|
||||||
if (active) { await reviseSession(active, input); return; }
|
|
||||||
const directShots = mode === "script" || mode === "storyboard" || mode === "full";
|
|
||||||
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(input);
|
|
||||||
setBusy(mode === "edit" ? "submit" : directShots ? "shots" : "plan");
|
|
||||||
let item: Session = {
|
|
||||||
id: crypto.randomUUID(), title: input.slice(0, 22), goal: input, mode,
|
|
||||||
createdAt: Date.now(), references: selectedRefs, nodeContexts: selectedContextNodes, messages: [message("user", input)],
|
|
||||||
imageModelRef: imageModelRef || undefined, videoModelRef: videoModelRef || undefined, autoGenerate,
|
|
||||||
pendingStage: mode === "edit" ? "submit" : directShots ? "shots" : "plan",
|
|
||||||
};
|
|
||||||
saveSessions([item, ...sessions]);
|
|
||||||
selectSession(item.id);
|
|
||||||
setGoal("");
|
|
||||||
setSelectedRefs([]);
|
|
||||||
setSelectedContextNodes([]);
|
|
||||||
try {
|
|
||||||
if (mode === "edit") {
|
|
||||||
if (!allowAutoRun) {
|
|
||||||
saveSessions([{ ...item, pendingStage: undefined, messages: [
|
|
||||||
...(item.messages || []), message("assistant", "已收到视频素材和剪辑要求,检查后可开始选片。"),
|
|
||||||
] }, ...sessions]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const submitted = await onEditRef.current(item.references || [], input);
|
|
||||||
if (!submitted) throw new Error("视频剪辑任务提交失败,可在同一对话中重试");
|
|
||||||
item = { ...item, pendingStage: undefined, runSubmitted: true, groupId: submitted, messages: [
|
|
||||||
...(item.messages || []), message("assistant", "已提交已有视频的高光选片与合成任务。"),
|
|
||||||
] };
|
|
||||||
saveSessions([item, ...sessions]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await runAnalysis(directShots ? "story-script" : "agent-plan", input, item.references || [], item.nodeContexts || []);
|
|
||||||
const shots = directShots ? productionShots(data.analysisRows) : undefined;
|
|
||||||
if (directShots && !shots?.length) throw new Error("模型没有返回有效分镜,请调整创作目标后重试");
|
|
||||||
item = {
|
|
||||||
...item,
|
|
||||||
plan: directShots && mode !== "full" ? undefined : {
|
|
||||||
summary: String(data.summary || ""),
|
|
||||||
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) : [],
|
|
||||||
recommendedNodes: Array.isArray(data.recommendedNodes) ? data.recommendedNodes.map(String) : [],
|
|
||||||
model: typeof data.model === "string" ? data.model : undefined,
|
|
||||||
},
|
|
||||||
shots,
|
|
||||||
pendingStage: mode === "full" ? "shots" : undefined,
|
|
||||||
};
|
|
||||||
item.messages = [...(item.messages || []), message("assistant", directShots
|
|
||||||
? `已生成 ${shots?.length || 0} 个可执行分镜。`
|
|
||||||
: item.plan?.summary || "已完成创作规划。")];
|
|
||||||
saveSessions([item, ...sessions]);
|
|
||||||
if (directShots && shots?.length && (mode === "storyboard" || (tvstarWantsDraftNodes(input) && !allowAutoRun))) {
|
|
||||||
const prepared = await onPrepareRef.current(shots, input, item.references || [], item);
|
|
||||||
if (!prepared) throw new Error("待运行工作流创建失败,分镜已保留,可重试");
|
|
||||||
saveSessions([{ ...item, groupId: prepared, preparedOnly: true, messages: [
|
|
||||||
...(item.messages || []), message("assistant", "已在画布中创建未执行的分镜工作流,没有提交生成任务。"),
|
|
||||||
] }, ...sessions]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mode === "full") {
|
|
||||||
setBusy("shots");
|
|
||||||
const storyboard = item.shots?.length ? { analysisRows: item.shots } : await runAnalysis("story-script", [input, item.plan?.summary,
|
|
||||||
item.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), item.references || [], item.nodeContexts || []);
|
|
||||||
const generated = productionShots(storyboard.analysisRows);
|
|
||||||
if (!generated.length) throw new Error("模型没有返回有效分镜,请重试");
|
|
||||||
item = { ...item, shots: generated, pendingStage: allowAutoRun ? "submit" : undefined, messages: [
|
|
||||||
...(item.messages || []), message("assistant", allowAutoRun
|
|
||||||
? `已生成 ${generated.length} 个分镜,正在提交画布制作。`
|
|
||||||
: `已生成 ${generated.length} 个分镜,检查后可点击“生成视频并合成成片”。`),
|
|
||||||
] };
|
|
||||||
saveSessions([item, ...sessions]);
|
|
||||||
if (!allowAutoRun) {
|
|
||||||
if (tvstarWantsDraftNodes(input)) {
|
|
||||||
const prepared = await onPrepareRef.current(generated, input, item.references || [], item);
|
|
||||||
if (!prepared) throw new Error("待运行工作流创建失败,分镜已保留,可重试");
|
|
||||||
saveSessions([{ ...item, groupId: prepared, preparedOnly: true, messages: [
|
|
||||||
...(item.messages || []), message("assistant", "已在画布中创建未执行的脚本、关键帧、视频和成片节点,并连好依赖;没有提交生成任务。"),
|
|
||||||
] }, ...sessions]);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy("submit");
|
|
||||||
const submitted = await onApplyRef.current(generated, input, item.references || [], item);
|
|
||||||
if (!submitted) throw new Error("视频任务提交失败,分镜已保留,可再次制作");
|
|
||||||
saveSessions([{ ...item, pendingStage: undefined, runSubmitted: true, groupId: submitted, messages: [
|
|
||||||
...(item.messages || []), message("assistant", "已将分镜、生图、生视频和成片剪辑任务提交到画布。"),
|
|
||||||
] }, ...sessions]);
|
|
||||||
}
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : "TVstar 分析失败");
|
|
||||||
} finally { setBusy(null); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const reviseSession = async (session: Session, instruction: string) => {
|
|
||||||
setError("");
|
|
||||||
setBusy(session.mode === "brainstorm" ? "plan" : session.mode === "edit" ? "submit" : "shots");
|
|
||||||
const mergedRefs = [...(session.references || []), ...selectedRefs].filter((item, index, list) =>
|
|
||||||
list.findIndex(candidate => candidate.url === item.url) === index).slice(0, 6);
|
|
||||||
const mergedContexts = [...(session.nodeContexts || []), ...selectedContextNodes].filter((item, index, list) =>
|
|
||||||
list.findIndex(candidate => candidate.id === item.id) === index).slice(0, 6);
|
|
||||||
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(instruction);
|
|
||||||
const pending: Session = { ...session, references: mergedRefs, nodeContexts: mergedContexts,
|
|
||||||
imageModelRef: imageModelRef || undefined, videoModelRef: videoModelRef || undefined, autoGenerate,
|
|
||||||
messages: [
|
|
||||||
...(session.messages || []), message("user", instruction),
|
|
||||||
] };
|
|
||||||
updateSession(pending);
|
|
||||||
setGoal("");
|
|
||||||
setSelectedRefs([]);
|
|
||||||
setSelectedContextNodes([]);
|
|
||||||
try {
|
|
||||||
if (session.preparedOnly && session.groupId && tvstarRequestsRunExisting(instruction)) {
|
|
||||||
setBusy("submit");
|
|
||||||
const submitted = await onRunPreparedRef.current(session.groupId);
|
|
||||||
if (!submitted) throw new Error("现有画布工作流提交失败,请检查节点后重试");
|
|
||||||
updateSession({ ...pending, preparedOnly: false, runSubmitted: true, groupId: submitted, messages: [
|
|
||||||
...(pending.messages || []), message("assistant", "已启动画布中的待运行工作流。"),
|
|
||||||
] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (session.mode === "edit") {
|
|
||||||
if (!allowAutoRun) {
|
|
||||||
updateSession({ ...pending, pendingStage: undefined, runSubmitted: false, messages: [
|
|
||||||
...(pending.messages || []), message("assistant", "已更新剪辑要求,点击下方按钮开始选片。"),
|
|
||||||
] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const submitted = await onEditRef.current(mergedRefs, `${session.goal}\n最新修改:${instruction}`);
|
|
||||||
if (!submitted) throw new Error("视频剪辑任务提交失败,可重试");
|
|
||||||
updateSession({ ...pending, pendingStage: undefined, runSubmitted: true, groupId: submitted, messages: [
|
|
||||||
...(pending.messages || []), message("assistant", "已按新要求重新分析片段并提交成片。"),
|
|
||||||
] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const prompt = buildTVstarRevisionPrompt(pending, instruction);
|
|
||||||
if (session.mode === "brainstorm") {
|
|
||||||
const data = await runAnalysis("agent-plan", prompt, mergedRefs, mergedContexts);
|
|
||||||
const plan: Plan = {
|
|
||||||
summary: String(data.summary || ""),
|
|
||||||
agentPlan: Array.isArray(data.agentPlan) ? data.agentPlan.map(String) : [],
|
|
||||||
deliverables: Array.isArray(data.deliverables) ? data.deliverables.map(String) : [],
|
|
||||||
recommendedNodes: Array.isArray(data.recommendedNodes) ? data.recommendedNodes.map(String) : [],
|
|
||||||
};
|
|
||||||
updateSession({ ...pending, plan, messages: [...(pending.messages || []), message("assistant", plan.summary || "已更新规划。")] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await runAnalysis("story-script", prompt, mergedRefs, mergedContexts);
|
|
||||||
const shots = productionShots(data.analysisRows);
|
|
||||||
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试修改要求");
|
|
||||||
let updated: Session = { ...pending, shots, runSubmitted: false, pendingStage: session.mode === "full" && allowAutoRun ? "submit" : undefined,
|
|
||||||
messages: [...(pending.messages || []), message("assistant", `已按新要求更新 ${shots.length} 个分镜。`)] };
|
|
||||||
updateSession(updated);
|
|
||||||
if (!allowAutoRun && tvstarWantsDraftNodes(instruction)) {
|
|
||||||
const prepared = await onPrepareRef.current(shots, `${session.goal}\n最新修改:${instruction}`, mergedRefs, pending);
|
|
||||||
if (!prepared) throw new Error("修订工作流创建失败,分镜已保留,可重试");
|
|
||||||
updated = { ...updated, groupId: prepared, preparedOnly: true, messages: [
|
|
||||||
...(updated.messages || []), message("assistant", "已在画布中创建一版待运行的修订工作流,没有提交生成任务。"),
|
|
||||||
] };
|
|
||||||
updateSession(updated);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (session.mode === "full" && allowAutoRun) {
|
|
||||||
setBusy("submit");
|
|
||||||
const submitted = await onApplyRef.current(shots, `${session.goal}\n最新修改:${instruction}`, mergedRefs, pending);
|
|
||||||
if (!submitted) throw new Error("新版视频任务提交失败,更新后的分镜已保留,可重试");
|
|
||||||
updated = { ...updated, pendingStage: undefined, runSubmitted: true, preparedOnly: false, groupId: submitted, messages: [
|
|
||||||
...(updated.messages || []), message("assistant", "已按新要求将视频和成片任务提交到画布。"),
|
|
||||||
] };
|
|
||||||
updateSession(updated);
|
|
||||||
}
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : "修改创作方案失败");
|
|
||||||
} finally { setBusy(null); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateShots = async () => {
|
|
||||||
if (!active || busy) return;
|
|
||||||
setError("");
|
|
||||||
setBusy("shots");
|
|
||||||
try {
|
|
||||||
const data = await runAnalysis("story-script", [active.goal, active.plan?.summary,
|
|
||||||
active.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), active.references || [], active.nodeContexts || []);
|
|
||||||
const shots = productionShots(data.analysisRows);
|
|
||||||
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试");
|
|
||||||
const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal;
|
|
||||||
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(latest);
|
|
||||||
const updated = { ...active, shots, pendingStage: active.mode === "full" && allowAutoRun ? "submit" as const : undefined };
|
|
||||||
saveSessions(sessions.map(item => item.id === active.id ? updated : item));
|
|
||||||
if (!allowAutoRun && tvstarWantsDraftNodes(latest)) {
|
|
||||||
const prepared = await onPrepareRef.current(shots, active.goal, active.references || [], active);
|
|
||||||
if (!prepared) throw new Error("待运行工作流创建失败,分镜已保留,可重试");
|
|
||||||
saveSessions(sessions.map(item => item.id === active.id ? { ...updated, groupId: prepared, preparedOnly: true } : item));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (active.mode === "full" && allowAutoRun) {
|
|
||||||
setBusy("submit");
|
|
||||||
const submitted = await onApplyRef.current(shots, active.goal, active.references || [], active);
|
|
||||||
if (!submitted) throw new Error("视频任务提交失败,分镜已保留,可再次制作");
|
|
||||||
saveSessions(sessions.map(item => item.id === active.id ? { ...updated, pendingStage: undefined, runSubmitted: true, preparedOnly: false, groupId: submitted } : item));
|
|
||||||
}
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : "分镜生成失败");
|
|
||||||
} finally { setBusy(null); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const newSession = () => { selectSession(null); setGoal(""); setSelectedRefs([]); setSelectedContextNodes([]); setError(""); setHistoryOpen(false); };
|
|
||||||
|
|
||||||
const submitExisting = async () => {
|
|
||||||
if (!active?.shots?.length || busy) return;
|
|
||||||
setError(""); setBusy("submit");
|
|
||||||
try {
|
|
||||||
const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal;
|
|
||||||
const submitted = active.preparedOnly && active.groupId
|
|
||||||
? await onRunPreparedRef.current(active.groupId)
|
|
||||||
: await onApplyRef.current(active.shots,
|
|
||||||
latest === active.goal ? active.goal : `${active.goal}\n最新修改:${latest}`,
|
|
||||||
active.references || [], active);
|
|
||||||
if (!submitted) throw new Error("视频任务提交失败,分镜已保留,可重试");
|
|
||||||
saveSessions(sessions.map(item => item.id === active.id ? { ...item, pendingStage: undefined, runSubmitted: true, preparedOnly: false, groupId: submitted } : item));
|
|
||||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "视频制作失败"); }
|
|
||||||
finally { setBusy(null); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitEditExisting = async () => {
|
|
||||||
if (!active?.references?.some(item => item.type === "video") || busy) return;
|
|
||||||
setError(""); setBusy("submit");
|
|
||||||
try {
|
|
||||||
const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal;
|
|
||||||
const submitted = await onEditRef.current(active.references,
|
|
||||||
latest === active.goal ? active.goal : `${active.goal}\n最新修改:${latest}`);
|
|
||||||
if (!submitted) throw new Error("视频剪辑任务提交失败,可重试");
|
|
||||||
updateSession({ ...active, runSubmitted: true, groupId: submitted, messages: [
|
|
||||||
...(active.messages || []), message("assistant", "已开始分析真实视频并合成成片。"),
|
|
||||||
] });
|
|
||||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "视频剪辑失败"); }
|
|
||||||
finally { setBusy(null); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const upload = async (file?: File) => {
|
|
||||||
if (!file) return;
|
|
||||||
setError(""); setBusy("upload");
|
|
||||||
try {
|
|
||||||
const result = await onUpload(file);
|
|
||||||
setSelectedRefs(current => [...current, { id: crypto.randomUUID(), title: file.name, url: result.url, type: result.type, duration: result.duration }].slice(0, 6));
|
|
||||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "上传失败"); }
|
|
||||||
finally { setBusy(null); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const chooseGenerationModel = (kind: "image" | "video", value: string) => {
|
|
||||||
if (kind === "image") setImageModelRef(value);
|
|
||||||
else setVideoModelRef(value);
|
|
||||||
if (active) updateSession({ ...active, [kind === "image" ? "imageModelRef" : "videoModelRef"]: value || undefined });
|
|
||||||
setModelOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const chooseGenerationPolicy = (value: boolean) => {
|
|
||||||
setAutoGenerate(value);
|
|
||||||
setPolicyOpen(false);
|
|
||||||
try { localStorage.setItem(`tvstar:auto:${canvasId}`, value ? "auto" : "manual"); } catch { /* optional */ }
|
|
||||||
if (active) updateSession({ ...active, autoGenerate: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyText = async (value: string, success: string) => {
|
|
||||||
try {
|
|
||||||
if (!navigator.clipboard?.writeText) throw new Error("当前浏览器不支持剪贴板");
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
setError("");
|
|
||||||
setHeaderMenu(null);
|
|
||||||
toast.success(success);
|
|
||||||
} catch {
|
|
||||||
setError("复制失败,请检查浏览器剪贴板权限");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportConversation = () => {
|
|
||||||
if (!active) return;
|
|
||||||
const content = [`# ${active.title}`, `创作目标:${active.goal}`, "", ...(active.messages || []).map(item => `${item.role === "user" ? "我" : "TVstar"}:${item.content}`)].join("\n\n");
|
|
||||||
const blobUrl = URL.createObjectURL(new Blob([content], { type: "text/plain;charset=utf-8" }));
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = `TVstar-${active.title.replace(/[\\/:*?"<>|]/g, "_").slice(0, 30) || "对话"}.txt`;
|
|
||||||
link.click();
|
|
||||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
|
||||||
setHeaderMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createShareLink = async () => {
|
|
||||||
if (!active || shareBusy) return;
|
|
||||||
setShareBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/canvas/${encodeURIComponent(canvasId)}/tvstar-sessions/share`, {
|
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ session: { ...active, updatedAt: active.updatedAt || active.createdAt } }),
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok || typeof result.token !== "string") throw new Error(result.error || "生成分享链接失败");
|
|
||||||
updateSession({ ...active, shareToken: result.token });
|
|
||||||
setShareConfirm(false);
|
|
||||||
setError("");
|
|
||||||
toast.success("只读分享链接已生成");
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : "生成分享链接失败");
|
|
||||||
} finally { setShareBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const revokeShareLink = async () => {
|
|
||||||
if (!active || shareBusy) return;
|
|
||||||
setShareBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/canvas/${encodeURIComponent(canvasId)}/tvstar-sessions/share?sessionId=${encodeURIComponent(active.id)}`, { method: "DELETE" });
|
|
||||||
if (!response.ok) throw new Error("关闭分享失败");
|
|
||||||
updateSession({ ...active, shareToken: undefined });
|
|
||||||
toast.success("分享链接已失效");
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : "关闭分享失败");
|
|
||||||
} finally { setShareBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDock = () => {
|
|
||||||
onDockChange(!docked);
|
|
||||||
setHeaderMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const startDockResize = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
const onMove = (moveEvent: PointerEvent) => {
|
|
||||||
onDockWidthChange(Math.round(Math.min(560, Math.max(320, window.innerWidth - moveEvent.clientX))));
|
|
||||||
};
|
|
||||||
const onEnd = () => {
|
|
||||||
window.removeEventListener("pointermove", onMove);
|
|
||||||
window.removeEventListener("pointerup", onEnd);
|
|
||||||
};
|
|
||||||
window.addEventListener("pointermove", onMove);
|
|
||||||
window.addEventListener("pointerup", onEnd, { once: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleNotifications = async () => {
|
|
||||||
if (notificationsEnabled) {
|
|
||||||
setNotificationsEnabled(false);
|
|
||||||
setNotificationSound(false);
|
|
||||||
try { localStorage.setItem(`tvstar:notifications:${canvasId}`, "0"); } catch { /* optional */ }
|
|
||||||
try { localStorage.setItem(`tvstar:notification-sound:${canvasId}`, "0"); } catch { /* optional */ }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof Notification === "undefined") { setError("当前浏览器不支持桌面通知"); return; }
|
|
||||||
const permission = await Notification.requestPermission();
|
|
||||||
if (permission !== "granted") { setError("浏览器通知未获授权"); return; }
|
|
||||||
setNotificationsEnabled(true);
|
|
||||||
try { localStorage.setItem(`tvstar:notifications:${canvasId}`, "1"); } catch { /* optional */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleNotificationSound = async () => {
|
|
||||||
if (!notificationsEnabled) return;
|
|
||||||
const next = !notificationSound;
|
|
||||||
if (next) {
|
|
||||||
try {
|
|
||||||
const context = audioContextRef.current || new AudioContext();
|
|
||||||
audioContextRef.current = context;
|
|
||||||
await context.resume();
|
|
||||||
} catch { setError("浏览器未允许播放通知声音"); return; }
|
|
||||||
}
|
|
||||||
setNotificationSound(next);
|
|
||||||
try { localStorage.setItem(`tvstar:notification-sound:${canvasId}`, next ? "1" : "0"); } catch { /* optional */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-label="TVstar 创作助手" className={`fixed z-[100] flex flex-col overflow-hidden border border-white/10 bg-[#1c1c1c] text-white shadow-[0_24px_90px_rgba(0,0,0,.65)] ${docked ? "bottom-0 right-0 top-0 h-screen max-w-[calc(100vw-60px)] rounded-none" : "bottom-4 right-4 h-[min(870px,calc(100vh-40px))] w-[min(540px,calc(100vw-24px))] rounded-[22px]"}`} data-canvas-overlay="true" style={docked ? { width: dockWidth } : undefined}>
|
|
||||||
{docked ? <div aria-label="调整助手宽度" className="absolute bottom-0 left-0 top-0 z-30 w-1 cursor-ew-resize hover:bg-cyan-300/50" onPointerDown={startDockResize} role="separator" /> : null}
|
|
||||||
<header className="flex h-12 shrink-0 items-center justify-between px-4">
|
|
||||||
<span className="min-w-0 truncate text-[13px] font-medium">{active ? active.title : "新对话"}</span>
|
|
||||||
<div className="ml-2 flex shrink-0 items-center gap-0.5 text-white/55">
|
|
||||||
<button aria-label="新对话" className="rounded-lg p-1.5 hover:bg-white/10 disabled:opacity-40" disabled={!active || Boolean(busy)} onClick={newSession} title="新对话"><Plus className="size-4" /></button>
|
|
||||||
<button aria-label="历史对话" aria-expanded={historyOpen} className="rounded-lg p-1.5 hover:bg-white/10 disabled:opacity-40" disabled={Boolean(busy)} onClick={() => { setHeaderMenu(null); setHistoryOpen(value => !value); }} title="历史对话"><Clock3 className="size-4" /></button>
|
|
||||||
<button aria-label={active ? "分享" : "新对话无法分享"} aria-expanded={headerMenu === "share"} className="rounded-lg p-1.5 hover:bg-white/10 disabled:opacity-35" disabled={!active} onClick={() => { setHistoryOpen(false); setHeaderMenu(value => value === "share" ? null : "share"); }} title={active ? "分享" : "新对话无法分享"}><Share2 className="size-4" /></button>
|
|
||||||
<button aria-label="TVstar 全局设置" aria-expanded={headerMenu === "settings"} className="rounded-lg p-1.5 hover:bg-white/10" onClick={() => { setHistoryOpen(false); setHeaderMenu(value => value === "settings" ? null : "settings"); }} title="TVstar 全局设置"><SlidersHorizontal className="size-4" /></button>
|
|
||||||
<button aria-label="TVstar Plugin" className="rounded-lg p-1.5 hover:bg-white/10" onClick={onOpenPlugin} title="TVstar Plugin"><Plug className="size-4" /></button>
|
|
||||||
<button aria-label={docked ? "恢复浮窗" : "停靠到右侧"} className="rounded-lg p-1.5 hover:bg-white/10" onClick={toggleDock} title={docked ? "恢复浮窗" : "停靠到右侧"}><PanelRight className="size-4" /></button>
|
|
||||||
<button aria-label="收起 TVstar" className="rounded-lg p-1.5 hover:bg-white/10" onClick={onClose} title="收起 TVstar"><Minus className="size-4" /></button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{headerMenu === "share" ? <div aria-label="TVstar 分享菜单" className="absolute right-3 top-12 z-20 w-64 rounded-xl border border-white/10 bg-[#292929] p-2 shadow-xl">
|
|
||||||
<p className="px-2 py-1 text-xs text-white/50">导出与分享</p>
|
|
||||||
{active?.shareToken ? <>
|
|
||||||
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => void copyText(new URL(`/tvstar/share/${active.shareToken}`, window.location.origin).toString(), "只读分享链接已复制")}><Share2 className="size-4" />复制只读分享链接</button>
|
|
||||||
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs text-rose-300 hover:bg-white/10 disabled:opacity-40" disabled={shareBusy} onClick={() => void revokeShareLink()}>关闭公开分享</button>
|
|
||||||
</> : shareConfirm ? <div className="rounded-lg border border-amber-400/20 bg-amber-400/5 p-2 text-[11px] leading-5 text-amber-100/80">
|
|
||||||
<p>任何获得链接的人都能阅读当前对话快照、目标和分镜。正文中的链接也会显示;画布编辑权限不会开放。</p>
|
|
||||||
<div className="mt-2 flex justify-end gap-2"><button className="rounded px-2 py-1 hover:bg-white/10" onClick={() => setShareConfirm(false)}>取消</button><button className="rounded bg-white px-2 py-1 text-black disabled:opacity-50" disabled={shareBusy} onClick={() => void createShareLink()}>{shareBusy ? "生成中…" : "确认生成"}</button></div>
|
|
||||||
</div> : <button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => setShareConfirm(true)}><Share2 className="size-4" />生成只读分享链接</button>}
|
|
||||||
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10 disabled:opacity-40" disabled={!active} onClick={() => void copyText((active?.messages || []).map(item => `${item.role === "user" ? "我" : "TVstar"}:${item.content}`).join("\n\n"), "对话记录已复制") }><Copy className="size-4" />复制对话记录</button>
|
|
||||||
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10 disabled:opacity-40" disabled={!active} onClick={exportConversation}><Download className="size-4" />下载对话记录</button>
|
|
||||||
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => void copyText(window.location.href, "画布地址已复制")}><Share2 className="size-4" />复制画布地址</button>
|
|
||||||
<p className="px-2 py-1 text-[11px] leading-4 text-white/35">分享链接可公开阅读创建时的快照;画布地址仍需访问权限。</p>
|
|
||||||
</div> : null}
|
|
||||||
|
|
||||||
{headerMenu === "settings" && typeof document !== "undefined" ? createPortal(<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/65 p-4" onClick={() => setHeaderMenu(null)}>
|
|
||||||
<div aria-label="TVstar 全局设置" aria-modal="true" className="w-full max-w-[460px] rounded-2xl border border-white/15 bg-[#252525] p-5 shadow-[0_30px_100px_rgba(0,0,0,.65)]" onClick={event => event.stopPropagation()} role="dialog">
|
|
||||||
<div className="mb-4 flex items-center justify-between"><h2 className="text-base font-medium">TVstar 全局设置</h2><button aria-label="关闭设置" className="rounded-lg px-2 py-1 text-lg text-white/50 hover:bg-white/10" onClick={() => setHeaderMenu(null)}>×</button></div>
|
|
||||||
<button aria-pressed={autoGenerate} className="flex w-full items-center justify-between rounded-xl px-2 py-3 text-left text-sm hover:bg-white/10" onClick={() => chooseGenerationPolicy(!autoGenerate)}><span>自动执行视频工作流<br /><span className="text-xs text-white/40">根据创作目标提交已配置的图片与视频模型任务</span></span><span className={autoGenerate ? "text-cyan-200" : "text-white/40"}>{autoGenerate ? "已开启" : "已关闭"}</span></button>
|
|
||||||
<button aria-pressed={notificationsEnabled} className="flex w-full items-center justify-between rounded-xl px-2 py-3 text-left text-sm hover:bg-white/10" onClick={() => void toggleNotifications()}><span>成片完成通知<br /><span className="text-xs text-white/40">浏览器授权后发送桌面通知</span></span><span className={notificationsEnabled ? "text-cyan-200" : "text-white/40"}>{notificationsEnabled ? "已开启" : "已关闭"}</span></button>
|
|
||||||
<button aria-pressed={notificationSound} className="flex w-full items-center justify-between rounded-xl px-2 py-3 text-left text-sm hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-35" disabled={!notificationsEnabled} onClick={() => void toggleNotificationSound()}><span>通知声音<br /><span className="text-xs text-white/40">成片完成时播放提示音</span></span><span className={notificationSound ? "text-cyan-200" : "text-white/40"}>{notificationSound ? "已开启" : "已关闭"}</span></button>
|
|
||||||
<p className="mt-4 border-t border-white/10 pt-4 text-xs leading-5 text-white/40">项目积分用量提醒需要逐项目的准确结算数据,当前尚不可用。</p>
|
|
||||||
</div>
|
|
||||||
</div>, document.body) : null}
|
|
||||||
|
|
||||||
{historyOpen ? <div className="absolute inset-x-3 top-12 z-10 max-h-72 overflow-y-auto rounded-xl border border-white/10 bg-[#292929] p-2 shadow-xl">
|
|
||||||
{sessions.length ? sessions.map(item => <button className="block w-full rounded-lg px-3 py-2 text-left text-[13px] hover:bg-white/10" key={item.id} onClick={() => { selectSession(item.id); setHistoryOpen(false); }}><span className="block truncate">{item.title}</span><span className="text-[11px] text-white/40">{new Date(item.createdAt).toLocaleString("zh-CN")}</span></button>) : <p className="p-3 text-xs text-white/45">暂无历史对话</p>}
|
|
||||||
{syncError ? <p className="p-2 text-[11px] text-amber-200/70">云端同步暂不可用;当前对话仍保存在此浏览器。</p> : null}
|
|
||||||
</div> : null}
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
|
|
||||||
{!active ? <div className="flex min-h-full flex-col justify-center pb-3">
|
|
||||||
<div className="mb-6 text-center"><div className="mx-auto mb-4 size-12 rounded-full border border-cyan-100/60 bg-[radial-gradient(circle_at_35%_32%,#8df9f2_0%,#39dce9_24%,#579cff_53%,#7259d6_78%,#2a647c_100%)] shadow-[0_0_24px_rgba(90,206,245,.6)]" /><h2 className="text-[16px] font-medium">让 TVstar 辅助你的无限创意</h2><p className="mt-2 text-xs text-white/40">从创意规划到自动生成可播放视频</p></div>
|
|
||||||
<div className="space-y-1">{[
|
|
||||||
["选择 Skill 开始创作", () => setTemplatesOpen(true)],
|
|
||||||
["从爆款预设开始剧本原创", () => { setMode("script"); setTemplatesOpen(true); }],
|
|
||||||
["上传故事来改编", () => { setMode("script"); fileRef.current?.click(); }],
|
|
||||||
["批量创建分镜", () => setMode("storyboard")],
|
|
||||||
["从已有视频选片剪辑", () => { setMode("edit"); setReferencesOpen(true); }],
|
|
||||||
].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">
|
|
||||||
{(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}
|
|
||||||
<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.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.plan || active.pendingStage) && !active.shots && active.mode !== "brainstorm" && active.mode !== "edit" ? <button className="w-full rounded-xl bg-white px-4 py-2.5 text-[13px] font-medium text-[#1c1c1c] disabled:opacity-50" disabled={Boolean(busy)} onClick={() => void generateShots()}>{busy === "shots" ? "正在生成分镜…" : "继续生成分镜"}</button> : null}
|
|
||||||
{active.shots?.length ? <button className="w-full rounded-xl bg-white px-4 py-2.5 text-[13px] font-medium text-[#1c1c1c] disabled:opacity-50" disabled={Boolean(busy)} onClick={() => void submitExisting()}>{busy === "submit" ? "正在提交视频制作…" : active.preparedOnly ? "运行画布中的待制作工作流" : active.runSubmitted ? "再次制作视频" : "生成视频并合成成片"}</button> : null}
|
|
||||||
{active.mode === "edit" && active.references?.some(item => item.type === "video") ? <button className="w-full rounded-xl bg-white px-4 py-2.5 text-[13px] font-medium text-[#1c1c1c] disabled:opacity-50" disabled={Boolean(busy)} onClick={() => void submitEditExisting()}>{active.runSubmitted ? "再次剪辑已有视频" : "开始剪辑已有视频"}</button> : null}
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-white/10 p-3">
|
|
||||||
{busy && busy !== "upload" ? <p className="mb-2 px-2 text-xs text-cyan-200" role="status">{{ plan: "正在理解目标并规划制作", shots: "正在生成可执行分镜", submit: "正在提交画布视频任务" }[busy]}</p> : null}
|
|
||||||
{error ? <p className="mb-2 rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-200" role="alert">{error}</p> : null}
|
|
||||||
{templatesOpen ? <div aria-label="TVstar Skill" className="mb-2 rounded-xl border border-white/10 bg-[#292929] p-2"><input aria-label="搜索 Skill" className="mb-2 w-full rounded-lg border border-white/10 bg-[#202020] px-2 py-1.5 text-xs outline-none" onChange={event => setSkillSearch(event.target.value)} placeholder="搜索商业广告、剧情或素材改编" value={skillSearch} /><div className="max-h-48 space-y-0.5 overflow-y-auto">{templates.filter(item => [item.name, item.category, item.description].join(" ").toLowerCase().includes(skillSearch.trim().toLowerCase())).map(item => <button className="block w-full rounded-lg px-2 py-2 text-left hover:bg-white/10" key={item.name} onClick={() => { if (active) selectSession(null); setGoal(item.prompt); setTemplatesOpen(false); setSkillSearch(""); }}><span className="block text-xs text-white/85">{item.name} <span className="text-[10px] text-white/35">· {item.category}</span></span><span className="text-[10px] text-white/45">{item.description}</span></button>)}</div></div> : null}
|
|
||||||
{referencesOpen ? <div className="mb-2 max-h-36 overflow-y-auto rounded-xl border border-white/10 bg-[#292929] p-2"><p className="px-2 pb-1 text-[11px] text-white/40">引用画布素材</p>{references.length ? references.map(item => <button className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => { setSelectedRefs(current => current.some(ref => ref.id === item.id) ? current.filter(ref => ref.id !== item.id) : [...current, item].slice(0, 6)); }}><span className="w-4">{selectedRefs.some(ref => ref.id === item.id) ? <Check className="size-3.5" /> : null}</span><span className="truncate">{item.title}</span></button>) : <p className="px-2 py-1 text-xs text-white/40">画布上暂无可引用的素材</p>}</div> : null}
|
|
||||||
{mention !== undefined ? <div aria-label="@ 引用画布节点、素材和模型" className="mb-2 max-h-48 overflow-y-auto rounded-xl border border-white/10 bg-[#292929] p-2">{mentionNodeMatches.length ? <><p className="px-2 py-1 text-[10px] text-white/35">画布节点</p>{mentionNodeMatches.map(item => <button className="block w-full truncate rounded-lg px-2 py-1.5 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => { setSelectedContextNodes(current => [...current, item].slice(0, 6)); const media = references.find(ref => ref.id === item.id); if (media) setSelectedRefs(current => current.some(ref => ref.id === media.id) ? current : [...current, media].slice(0, 6)); setGoal(current => current.replace(/(^|\s)@[^@\s]*$/, "$1")); }}>{item.type} · {item.title}</button>)}</> : null}{mentionMatches.length ? <><p className="px-2 py-1 text-[10px] text-white/35">素材</p>{mentionMatches.map(item => <button className="block w-full truncate rounded-lg px-2 py-1.5 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => { setSelectedRefs(current => [...current, item].slice(0, 6)); setGoal(current => current.replace(/(^|\s)@[^@\s]*$/, "$1")); }}>{item.type === "video" ? "视频" : "图片"} · {item.title}</button>)}</> : null}{mentionModelMatches.length ? <><p className="px-2 py-1 text-[10px] text-white/35">模型</p>{mentionModelMatches.map(item => <button className="block w-full truncate rounded-lg px-2 py-1.5 text-left text-xs hover:bg-white/10" key={`${item.kind}-${item.id}`} onClick={() => { chooseGenerationModel(item.kind, item.id); setGoal(current => current.replace(/(^|\s)@[^@\s]*$/, "$1")); }}>{item.kind === "video" ? "视频" : "图片"} · {item.label}</button>)}</> : null}{!mentionNodeMatches.length && !mentionMatches.length && !mentionModelMatches.length ? <p className="px-2 py-1 text-xs text-white/40">没有匹配的节点、素材或模型</p> : null}</div> : null}
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-[#242424] p-2">
|
|
||||||
<textarea aria-label="TVstar 创作目标" className="min-h-[66px] w-full resize-none bg-transparent px-1.5 py-1 text-[13px] text-white outline-none placeholder:text-white/35" disabled={Boolean(busy)} onChange={event => setGoal(event.target.value)} onKeyDown={event => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); void createSession(); } }} placeholder={active ? "继续提要求,例如修改镜头、节奏或风格;输入 @ 引用素材" : "开始你的创作,或者输入 @ 引用画布素材"} value={goal} />
|
|
||||||
{selectedRefs.some(ref => !selectedContextNodes.some(node => node.id === ref.id)) ? <div className="mb-2 flex flex-wrap gap-1">{selectedRefs.filter(ref => !selectedContextNodes.some(node => node.id === ref.id)).map(item => <button className="max-w-36 truncate rounded-md bg-white/10 px-2 py-1 text-[10px] text-white/65" key={item.id} onClick={() => setSelectedRefs(current => current.filter(ref => ref.id !== item.id))}>{item.title} ×</button>)}</div> : null}
|
|
||||||
{selectedContextNodes.length ? <div className="mb-2 flex flex-wrap gap-1">{selectedContextNodes.map(item => <button className="max-w-36 truncate rounded-md bg-white/10 px-2 py-1 text-[10px] text-white/65" key={item.id} onClick={() => { setSelectedContextNodes(current => current.filter(node => node.id !== item.id)); setSelectedRefs(current => current.filter(ref => ref.id !== item.id)); }}>{item.title} ×</button>)}</div> : null}
|
|
||||||
{imageModelRef || videoModelRef ? <div className="mb-2 flex flex-wrap gap-1">{([ ["image", imageModelRef, "图片"], ["video", videoModelRef, "视频"] ] as const).filter(([, ref]) => ref).map(([kind, ref, label]) => <button className="max-w-40 truncate rounded-md bg-cyan-500/15 px-2 py-1 text-[10px] text-cyan-100" key={kind} onClick={() => chooseGenerationModel(kind, "")}>{label} · {generationModels.find(item => item.id === ref)?.label || ref} ×</button>)}</div> : null}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input accept="image/*,video/*,.txt,.md" className="hidden" onChange={event => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; if (file.type.startsWith("text/") || /\.(txt|md)$/i.test(file.name)) { void file.text().then(text => setGoal(current => `${current}${current ? "\n" : ""}${text.slice(0, 12000)}`)); } else { void upload(file); } }} ref={fileRef} type="file" />
|
|
||||||
<button aria-label="上传故事或素材" className="rounded-lg p-1.5 text-white/60 hover:bg-white/10" disabled={Boolean(busy)} onClick={() => fileRef.current?.click()}><Paperclip className="size-4" /></button>
|
|
||||||
<button aria-label="引用画布素材" className="rounded-lg p-1.5 text-white/60 hover:bg-white/10" onClick={() => setReferencesOpen(value => !value)}><FilePlus2 className="size-4" /></button>
|
|
||||||
<div className="relative"><button aria-label="选择创作模式" className="flex items-center gap-1 rounded-lg px-2 py-1.5 text-xs text-white/70 hover:bg-white/10" onClick={() => setModeOpen(value => !value)}>{modes.find(item => item.value === mode)?.label}<ChevronDown className="size-3" /></button>{modeOpen ? <div className="absolute bottom-9 left-0 z-20 w-52 rounded-xl border border-white/10 bg-[#303030] p-1 shadow-xl">{modes.map(item => <button className="block w-full rounded-lg px-2 py-2 text-left hover:bg-white/10" key={item.value} onClick={() => { setMode(item.value); setModeOpen(false); }}><span className="block text-xs">{item.label}</span><span className="text-[10px] text-white/40">{item.description}</span></button>)}</div> : null}</div>
|
|
||||||
<div className="relative"><button aria-label="选择生成模型" className="max-w-20 truncate rounded-lg px-2 py-1.5 text-xs text-white/60 hover:bg-white/10" onClick={() => setModelOpen(value => !value)}>选择模型</button>{modelOpen ? <div aria-label="TVstar 模型选择" className="absolute bottom-9 right-0 z-20 w-64 rounded-xl border border-white/10 bg-[#303030] p-2 shadow-xl"><div className="mb-1 flex gap-1">{([ ["image", "图片"], ["video", "视频"], ["planning", "规划"] ] as const).map(([tab, label]) => <button className={`rounded-md px-2 py-1 text-xs ${modelTab === tab ? "bg-white/15 text-white" : "text-white/50"}`} key={tab} onClick={() => setModelTab(tab)}>{label}</button>)}</div><div className="max-h-52 overflow-y-auto"><button className="block w-full rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => { if (modelTab === "planning") { setModelRef(""); setModelOpen(false); } else chooseGenerationModel(modelTab, ""); }}>自动选择</button>{modelTab === "planning" ? models.filter(item => !selectedRefs.length || item.supportsVision).map(item => <button className="block w-full truncate rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => { setModelRef(item.id); setModelOpen(false); }}>{item.label}</button>) : generationModels.filter(item => item.kind === modelTab && (modelTab !== "image" || !(selectedRefs.length || active?.references?.length) || item.supportsReference !== false)).map(item => <button className="block w-full truncate rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => chooseGenerationModel(modelTab, item.id)}>{item.label}</button>)}</div></div> : null}</div>
|
|
||||||
<div className="relative"><button aria-label="选择制作策略" className="whitespace-nowrap rounded-lg px-1.5 py-1.5 text-[11px] text-white/60 hover:bg-white/10" onClick={() => setPolicyOpen(value => !value)}>{autoGenerate ? "自动制作" : "先确认"}</button>{policyOpen ? <div className="absolute bottom-9 right-0 z-20 w-52 rounded-xl border border-white/10 bg-[#303030] p-1 shadow-xl"><button className="block w-full rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => chooseGenerationPolicy(true)}>自动制作 · 分镜完成后提交任务</button><button className="block w-full rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => chooseGenerationPolicy(false)}>先确认 · 查看分镜后再制作</button></div> : null}</div>
|
|
||||||
<button className="ml-auto flex size-8 items-center justify-center rounded-full bg-white text-black disabled:opacity-40" disabled={!goal.trim() || Boolean(busy)} onClick={() => void createSession()} title="发送创作目标">{busy ? <Loader2 className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
export type CreativeAnalysisModel = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
modelId: string;
|
|
||||||
providerType: string;
|
|
||||||
supportsVision: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CreativeAnalysisRequest = {
|
|
||||||
mode: "agent-plan" | "story-script" | "character-scene-extraction";
|
|
||||||
prompt: string;
|
|
||||||
nodeTitle: string;
|
|
||||||
models: CreativeAnalysisModel[];
|
|
||||||
preferredModelRef?: string;
|
|
||||||
imageUrls?: string[];
|
|
||||||
videoUrls?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function modelPriority(model: CreativeAnalysisModel) {
|
|
||||||
if (model.providerType === "alibaba" && model.modelId === "qwen3.8-max") return 0;
|
|
||||||
if (model.providerType === "alibaba" && model.modelId === "qwen3.8-omni-flash") return 1;
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestCreativeAnalysis(input: CreativeAnalysisRequest): Promise<Record<string, unknown>> {
|
|
||||||
const hasVisualInput = Boolean(input.imageUrls?.length || input.videoUrls?.length);
|
|
||||||
const compatible = input.models.filter(model => !hasVisualInput || model.supportsVision);
|
|
||||||
const selected = input.preferredModelRef
|
|
||||||
? compatible.find(model => model.id === input.preferredModelRef || model.modelId === input.preferredModelRef)
|
|
||||||
: undefined;
|
|
||||||
if (input.preferredModelRef && !selected) throw new Error("所选模型不支持当前素材,请重新选择模型");
|
|
||||||
const candidates = selected ? [selected] : [...compatible].sort((left, right) => modelPriority(left) - modelPriority(right));
|
|
||||||
const attempts: (CreativeAnalysisModel | undefined)[] = candidates.length ? candidates : [undefined];
|
|
||||||
let lastError = "分析失败,请稍后重试";
|
|
||||||
|
|
||||||
for (const [index, model] of attempts.entries()) {
|
|
||||||
const response = await fetch("/api/canvas/workflow/analyze", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
mode: input.mode,
|
|
||||||
prompt: input.prompt,
|
|
||||||
nodeTitle: input.nodeTitle,
|
|
||||||
preferredModelRef: model?.id,
|
|
||||||
imageUrls: input.imageUrls?.slice(0, 4) || [],
|
|
||||||
videoUrls: input.videoUrls?.slice(0, 2) || [],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const result = await response.json().catch(() => ({}));
|
|
||||||
if (response.ok && result.success) return result.data as Record<string, unknown>;
|
|
||||||
lastError = String(result.error || "分析失败,请稍后重试");
|
|
||||||
const authenticationFailure = /invalid authentication|invalid token|invalid api.?key|authentication failed|鉴权失败|认证失败/i.test(lastError);
|
|
||||||
if (selected || !authenticationFailure || index === attempts.length - 1) break;
|
|
||||||
}
|
|
||||||
throw new Error(lastError);
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
type Reference = { title: string; url: string; type: "image" | "video" };
|
|
||||||
|
|
||||||
export async function resolveTVstarGenerationReferences(
|
|
||||||
references: Reference[],
|
|
||||||
fetcher: typeof fetch = fetch,
|
|
||||||
) {
|
|
||||||
const images = references.filter(item => item.type === "image").map(item => item.url).slice(0, 4);
|
|
||||||
// The video processing endpoint accepts one active job per user, so extract sequentially.
|
|
||||||
for (const item of references.filter(item => item.type === "video").slice(0, 2)) {
|
|
||||||
const response = await fetcher("/api/canvas/video/process", {
|
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ operation: "frame", url: item.url, kind: "first", time: 0 }),
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok || !result.success || !result.data?.url)
|
|
||||||
throw new Error(`${item.title}:${result.error || "参考视频首帧提取失败"}`);
|
|
||||||
images.push(String(result.data.url));
|
|
||||||
}
|
|
||||||
return [...new Set(images)].slice(0, 6);
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
export type TVstarMessage = {
|
|
||||||
id: string;
|
|
||||||
role: "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
createdAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function tvstarAllowsGeneration(instruction: string) {
|
|
||||||
const text = instruction.replace(/\s+/g, "");
|
|
||||||
return !/(?:只|仅)(?:做|要|进行|需要)?(?:分析|规划|出方案|写方案|写分镜|列步骤)|(?:不要|先不|暂不|无需|别|不)(?:提交|启动|运行|执行)(?:任何|所有)?(?:生成|制作|剪辑)?任务|(?:不要|先不|暂不|无需|别|不)(?:执行|运行|启动)(?:任何|所有)?(?:图片|视频|音频|媒体|成片|内容|、|和|或|及)*(?:生成|制作|剪辑)|(?:不要|先不|暂不|无需|别)(?:生成|制作|渲染|剪辑)(?:图片|视频|媒体|成片|内容)?|(?:不|不要)扣费|待运行|未运行/.test(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tvstarWantsDraftNodes(instruction: string) {
|
|
||||||
const text = instruction.replace(/\s+/g, "");
|
|
||||||
if (/(?:不要|无需|别|不)(?:创建|建立|搭建|添加)(?:任何|所有)?(?:画布|节点|工作流)/.test(text)) return false;
|
|
||||||
return /(?:创建|建立|搭建|添加|放入)/.test(text) && /(?:画布|节点|工作流)/.test(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tvstarRequestsRunExisting(instruction: string) {
|
|
||||||
const text = instruction.replace(/\s+/g, "");
|
|
||||||
return text.length <= 36 && tvstarAllowsGeneration(instruction) &&
|
|
||||||
!/(?:修改|改成|增加|删除|替换|换成|重做|重新|调整|优化|不要|先不)/.test(text) &&
|
|
||||||
/(?:开始|执行|运行|提交)(?:生成|制作|运行|执行|画布|分镜|工作流|节点|视频|成片|任务|这些|现有|吧|了|。|,)*$/.test(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
type RevisionContext = {
|
|
||||||
goal: string;
|
|
||||||
plan?: { summary: string };
|
|
||||||
shots?: Array<{
|
|
||||||
shot: string;
|
|
||||||
duration: string;
|
|
||||||
sceneDescription: string;
|
|
||||||
cameraMovement: string;
|
|
||||||
}>;
|
|
||||||
messages?: TVstarMessage[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildTVstarRevisionPrompt(session: RevisionContext, instruction: string) {
|
|
||||||
const priorShots = (session.shots || []).map((shot) =>
|
|
||||||
`${shot.shot}(${shot.duration}):${shot.sceneDescription};运镜:${shot.cameraMovement}`,
|
|
||||||
);
|
|
||||||
const recentTurns = (session.messages || []).slice(-8).map((message) =>
|
|
||||||
`${message.role === "user" ? "用户" : "TVstar"}:${message.content.slice(0, 1200)}`,
|
|
||||||
);
|
|
||||||
return [
|
|
||||||
"这是同一个视频画布中的连续对话。请依据最新指令修改现有方案,不要把它当作全新项目。",
|
|
||||||
`最初目标:${session.goal}`,
|
|
||||||
session.plan?.summary ? `当前规划:${session.plan.summary}` : "",
|
|
||||||
priorShots.length ? `当前分镜:\n${priorShots.join("\n")}` : "",
|
|
||||||
recentTurns.length ? `最近对话:\n${recentTurns.join("\n")}` : "",
|
|
||||||
`最新用户指令:${instruction.trim()}`,
|
|
||||||
"如果最新指令没有要求修改的部分,请保持原有镜头、人物、风格、时长和画面比例。输出更新后的完整方案。",
|
|
||||||
].filter(Boolean).join("\n\n").slice(0, 16000);
|
|
||||||
}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,321 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { PlaitElement } from "@plait/core";
|
|
||||||
import {
|
|
||||||
ExternalLink,
|
|
||||||
FileDown,
|
|
||||||
Link2,
|
|
||||||
StickyNote,
|
|
||||||
Tag,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
getSafeTopicHref,
|
|
||||||
getTopicDetails,
|
|
||||||
getTopicTitle,
|
|
||||||
type TopicDetails,
|
|
||||||
type TopicEntry,
|
|
||||||
} from "@/lib/flow-board/topic-details";
|
|
||||||
|
|
||||||
import { getNoteDocument } from "@/lib/flow-board/topic-notes";
|
|
||||||
import { XmindNotesEditor } from "./xmind-notes-editor";
|
|
||||||
|
|
||||||
export type TopicDetailSection = "notes" | "links" | "labels";
|
|
||||||
|
|
||||||
export function XmindTopicPanel({
|
|
||||||
topic,
|
|
||||||
topics,
|
|
||||||
section,
|
|
||||||
onPatch,
|
|
||||||
onJump,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
topic: PlaitElement | null;
|
|
||||||
topics: TopicEntry[];
|
|
||||||
section: TopicDetailSection;
|
|
||||||
onPatch: (patch: Partial<TopicDetails>) => void;
|
|
||||||
onJump: (id: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const details = getTopicDetails(topic);
|
|
||||||
const [labelDraft, setLabelDraft] = useState("");
|
|
||||||
const [linkDraft, setLinkDraft] = useState(details.href);
|
|
||||||
const [attachmentError, setAttachmentError] = useState("");
|
|
||||||
const [isReadingAttachment, setIsReadingAttachment] = useState(false);
|
|
||||||
const attachmentRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const linkRef = useRef<HTMLInputElement>(null);
|
|
||||||
const labelRef = useRef<HTMLInputElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
setLinkDraft(details.href);
|
|
||||||
}, [topic?.id, details.href]);
|
|
||||||
useEffect(() => {
|
|
||||||
setLabelDraft("");
|
|
||||||
setAttachmentError("");
|
|
||||||
}, [topic?.id]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (section === "links") linkRef.current?.focus();
|
|
||||||
if (section === "labels") labelRef.current?.focus();
|
|
||||||
}, [section]);
|
|
||||||
const safeHref = getSafeTopicHref(linkDraft);
|
|
||||||
const invalidHref = Boolean(linkDraft.trim()) && !safeHref;
|
|
||||||
const addLabel = () => {
|
|
||||||
const value = labelDraft.trim();
|
|
||||||
if (value && !details.labels.includes(value))
|
|
||||||
onPatch({ labels: [...details.labels, value] });
|
|
||||||
setLabelDraft("");
|
|
||||||
};
|
|
||||||
const addAttachment = async (file: File) => {
|
|
||||||
setAttachmentError("");
|
|
||||||
if (file.size > 8 * 1024 * 1024) {
|
|
||||||
setAttachmentError("附件不能超过 8 MB。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsReadingAttachment(true);
|
|
||||||
try {
|
|
||||||
const url = await new Promise<string>((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onerror = () => reject(new Error("读取失败"));
|
|
||||||
reader.onload = () => resolve(String(reader.result));
|
|
||||||
// Always download attachments as files, independent of their source type.
|
|
||||||
reader.readAsDataURL(
|
|
||||||
new Blob([file], { type: "application/octet-stream" })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
onPatch({
|
|
||||||
attachment: {
|
|
||||||
name: file.name,
|
|
||||||
url,
|
|
||||||
mimeType: "application/octet-stream",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setAttachmentError("无法读取附件,请重新选择。");
|
|
||||||
} finally {
|
|
||||||
setIsReadingAttachment(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
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">主题内容</h2>
|
|
||||||
<button type="button" aria-label="关闭主题内容" onClick={onClose}>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{!topic ? (
|
|
||||||
<p className="p-4 text-xs text-[#8b919c]">
|
|
||||||
选择一个主题,编辑它的备注、链接和标签。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6 p-4 text-xs">
|
|
||||||
<p className="truncate font-medium" title={getTopicTitle(topic)}>
|
|
||||||
{getTopicTitle(topic)}
|
|
||||||
</p>
|
|
||||||
<section className="space-y-2">
|
|
||||||
<h3 className="flex items-center gap-2 font-medium"><StickyNote size={14} />笔记</h3>
|
|
||||||
<XmindNotesEditor key={topic.id} value={getNoteDocument(topic)} onChange={(notesDocument) => onPatch({notesDocument})} />
|
|
||||||
</section>
|
|
||||||
<section className="space-y-2">
|
|
||||||
<h3 className="flex items-center gap-2 font-medium">
|
|
||||||
<Tag size={14} />
|
|
||||||
标签
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{details.labels.map((label) => (
|
|
||||||
<span
|
|
||||||
key={label}
|
|
||||||
className="flex items-center gap-1 rounded bg-[#edf3ff] px-2 py-1 text-[#3569e8]"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`移除标签 ${label}`}
|
|
||||||
onClick={() =>
|
|
||||||
onPatch({
|
|
||||||
labels: details.labels.filter(
|
|
||||||
(value) => value !== label
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
aria-label="新增主题标签"
|
|
||||||
ref={labelRef}
|
|
||||||
className="h-8 min-w-0 flex-1 rounded border border-[#dfe2e7] px-2"
|
|
||||||
placeholder="输入标签后回车"
|
|
||||||
value={labelDraft}
|
|
||||||
onChange={(event) => setLabelDraft(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
|
|
||||||
event.preventDefault();
|
|
||||||
addLabel();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-[#dfe2e7] px-2"
|
|
||||||
onClick={addLabel}
|
|
||||||
>
|
|
||||||
添加
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="space-y-2">
|
|
||||||
<h3 className="flex items-center gap-2 font-medium">
|
|
||||||
<Link2 size={14} />
|
|
||||||
网页或邮件链接
|
|
||||||
</h3>
|
|
||||||
<input
|
|
||||||
aria-label="主题外部链接"
|
|
||||||
ref={linkRef}
|
|
||||||
aria-invalid={invalidHref}
|
|
||||||
className="h-8 w-full rounded border border-[#dfe2e7] px-2"
|
|
||||||
placeholder="https://…"
|
|
||||||
value={linkDraft}
|
|
||||||
onChange={(event) => setLinkDraft(event.target.value)}
|
|
||||||
onBlur={() => {
|
|
||||||
if (!invalidHref) onPatch({ href: linkDraft.trim() });
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" && !invalidHref) {
|
|
||||||
event.preventDefault();
|
|
||||||
onPatch({ href: linkDraft.trim() });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{invalidHref ? (
|
|
||||||
<p role="alert" className="text-red-600">
|
|
||||||
请输入完整的 http、https、mailto 或 tel 链接。
|
|
||||||
</p>
|
|
||||||
) : safeHref ? (
|
|
||||||
<a
|
|
||||||
className="inline-flex items-center gap-1 text-[#3569e8]"
|
|
||||||
href={safeHref}
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<ExternalLink size={12} />
|
|
||||||
打开链接
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
{details.href ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ml-3 text-[#8b919c]"
|
|
||||||
onClick={() => {
|
|
||||||
setLinkDraft("");
|
|
||||||
onPatch({ href: "" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
移除外部链接
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
<section className="space-y-2">
|
|
||||||
<h3 className="flex items-center gap-2 font-medium">
|
|
||||||
<FileDown size={14} />
|
|
||||||
附件
|
|
||||||
</h3>
|
|
||||||
{details.attachment ? (
|
|
||||||
<>
|
|
||||||
<a
|
|
||||||
className="block break-all text-[#3569e8]"
|
|
||||||
href={details.attachment.url}
|
|
||||||
download={details.attachment.name}
|
|
||||||
>
|
|
||||||
下载 {details.attachment.name}
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-[#8b919c]"
|
|
||||||
onClick={() => onPatch({ attachment: null })}
|
|
||||||
>
|
|
||||||
移除附件
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<input
|
|
||||||
ref={attachmentRef}
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
aria-label="上传主题附件"
|
|
||||||
onChange={(event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = "";
|
|
||||||
if (file) void addAttachment(file);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="block text-[#3569e8] disabled:text-gray-400"
|
|
||||||
disabled={isReadingAttachment}
|
|
||||||
onClick={() => attachmentRef.current?.click()}
|
|
||||||
>
|
|
||||||
{isReadingAttachment
|
|
||||||
? "正在读取附件…"
|
|
||||||
: details.attachment
|
|
||||||
? "替换附件"
|
|
||||||
: "添加附件"}
|
|
||||||
</button>
|
|
||||||
{attachmentError ? (
|
|
||||||
<p role="alert" className="text-red-600">
|
|
||||||
{attachmentError}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
<label className="block space-y-2">
|
|
||||||
<span className="font-medium">链接到当前画布的主题</span>
|
|
||||||
<select
|
|
||||||
aria-label="主题链接目标"
|
|
||||||
className="h-8 w-full rounded border border-[#dfe2e7] px-2"
|
|
||||||
value={details.topicLinkId}
|
|
||||||
onChange={(event) => onPatch({ topicLinkId: event.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">无主题链接</option>
|
|
||||||
{details.topicLinkId &&
|
|
||||||
!topics.some(
|
|
||||||
({ element }) => element.id === details.topicLinkId
|
|
||||||
) ? (
|
|
||||||
<option value={details.topicLinkId}>目标主题已不存在</option>
|
|
||||||
) : null}
|
|
||||||
{topics
|
|
||||||
.filter(({ element }) => element.id !== topic.id)
|
|
||||||
.map(({ element, title, depth }) => (
|
|
||||||
<option key={element.id} value={element.id}>
|
|
||||||
{" ".repeat(Math.min(depth, 5))}
|
|
||||||
{title}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{details.topicLinkId ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={
|
|
||||||
!topics.some(
|
|
||||||
({ element }) => element.id === details.topicLinkId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="text-[#3569e8] disabled:text-gray-400"
|
|
||||||
onClick={() => onJump(details.topicLinkId)}
|
|
||||||
>
|
|
||||||
定位目标主题
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,9 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ReferenceIcon, type ReferenceIconName } from "@/components/lovart/reference-icon";
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -71,6 +69,7 @@ import {
|
|||||||
normalizeLovartHomeSidebarConfig,
|
normalizeLovartHomeSidebarConfig,
|
||||||
type LovartHomeSidebarItemKey,
|
type LovartHomeSidebarItemKey,
|
||||||
} from "@/lib/home/lovart-sidebar-config";
|
} from "@/lib/home/lovart-sidebar-config";
|
||||||
|
import { BrandTopBar } from "@/components/layout/brand-top-bar";
|
||||||
import { LovartFeaturedGallery } from "@/components/home/lovart/lovart-featured-gallery";
|
import { LovartFeaturedGallery } from "@/components/home/lovart/lovart-featured-gallery";
|
||||||
import { LovartHeroComposer } from "@/components/home/lovart/lovart-hero-composer";
|
import { LovartHeroComposer } from "@/components/home/lovart/lovart-hero-composer";
|
||||||
import { LovartProjectsSection } from "@/components/home/lovart/lovart-projects-section";
|
import { LovartProjectsSection } from "@/components/home/lovart/lovart-projects-section";
|
||||||
@ -421,15 +420,16 @@ function AccountMenuItem({
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<button
|
||||||
onSelect={onClick}
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
className="flex w-full items-center gap-3 rounded-2xl px-2.5 py-1.5 text-left text-[13px] font-medium text-black/80 hover:bg-black/5 dark:text-white/85 dark:hover:bg-white/6"
|
className="flex w-full items-center gap-3 rounded-2xl px-2.5 py-1.5 text-left text-[13px] font-medium text-black/80 hover:bg-black/5 dark:text-white/85 dark:hover:bg-white/6"
|
||||||
>
|
>
|
||||||
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-black/5 text-black/70 dark:bg-white/8 dark:text-white/70">
|
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-black/5 text-black/70 dark:bg-white/8 dark:text-white/70">
|
||||||
<Icon className="h-[16px] w-[16px]" />
|
<Icon className="h-[16px] w-[16px]" />
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1">{label}</span>
|
<span className="flex-1">{label}</span>
|
||||||
</DropdownMenuItem>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1637,31 +1637,7 @@ export function LovartHome({
|
|||||||
) => {
|
) => {
|
||||||
const item = getLovartSidebarItem(key);
|
const item = getLovartSidebarItem(key);
|
||||||
const FallbackIcon = LOVART_HOME_SIDEBAR_FALLBACK_ICONS[key];
|
const FallbackIcon = LOVART_HOME_SIDEBAR_FALLBACK_ICONS[key];
|
||||||
const sizeClassName = options?.sizeClassName || "h-[20px] w-[20px]";
|
const sizeClassName = options?.sizeClassName || "h-[18px] w-[18px]";
|
||||||
|
|
||||||
const referenceIcons: Record<LovartHomeSidebarItemKey, ReferenceIconName> = {
|
|
||||||
newProject: "plus",
|
|
||||||
home: "home",
|
|
||||||
projects: "folder",
|
|
||||||
profile: "profile",
|
|
||||||
gallery: "brand",
|
|
||||||
tutorial: "tutorial",
|
|
||||||
imageEditor: "image-local-edit",
|
|
||||||
aiCad: "model-auto",
|
|
||||||
model3d: "model-auto",
|
|
||||||
scene: "layers",
|
|
||||||
agent: "skill",
|
|
||||||
videoCanvas: "video",
|
|
||||||
flowBoard: "connections",
|
|
||||||
productPlanning: "page",
|
|
||||||
toolbox1: "toolbox",
|
|
||||||
toolbox2: "toolbox",
|
|
||||||
settings: "settings",
|
|
||||||
};
|
|
||||||
const reference = referenceIcons[key];
|
|
||||||
if (reference && !item.iconUrl) {
|
|
||||||
return <ReferenceIcon name={reference} size={20} className={options?.filled ? "text-white" : undefined} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.filled) {
|
if (options?.filled) {
|
||||||
return (
|
return (
|
||||||
@ -1727,84 +1703,47 @@ export function LovartHome({
|
|||||||
THEME_MODE_OPTIONS[0];
|
THEME_MODE_OPTIONS[0];
|
||||||
const SelectedThemeIcon = selectedThemeOption.icon;
|
const SelectedThemeIcon = selectedThemeOption.icon;
|
||||||
|
|
||||||
const homeThemeControl = () => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[11px] border border-black/10 bg-white text-black/70 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
|
|
||||||
title={`主题模式:${selectedThemeOption.label}`}
|
|
||||||
aria-label={`主题模式:${selectedThemeOption.label}`}
|
|
||||||
>
|
|
||||||
<SelectedThemeIcon className="h-[15px] w-[15px]" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
sideOffset={10}
|
|
||||||
className="z-[160] !min-w-0 w-auto rounded-[18px] border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{THEME_MODE_OPTIONS.map((option) => {
|
|
||||||
const OptionIcon = option.icon;
|
|
||||||
const isSelected = selectedThemeMode === option.value;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<div className="min-h-svh overflow-x-hidden bg-white text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
|
||||||
key={option.value}
|
{shouldShowHomeBanner ? (
|
||||||
aria-label={option.label}
|
<LovartPromoBanner
|
||||||
className={cn(
|
actionText={homeBannerActionText}
|
||||||
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[10px] p-0 focus:bg-black/5 dark:focus:bg-white/8",
|
link={homeBannerLink}
|
||||||
isSelected
|
onAction={openHomeBannerLink}
|
||||||
? "bg-black text-white focus:bg-black focus:text-white dark:bg-white dark:text-black dark:focus:bg-white dark:focus:text-black"
|
onDismiss={dismissHomeBanner}
|
||||||
: "text-black/70 dark:text-white/70"
|
text={homeBannerText}
|
||||||
)}
|
/>
|
||||||
onSelect={() => setTheme(option.value)}
|
) : null}
|
||||||
title={option.label}
|
<BrandTopBar
|
||||||
>
|
contentClassName="pl-[80px]"
|
||||||
<OptionIcon className="h-[15px] w-[15px]" />
|
logo={displayLogo}
|
||||||
</DropdownMenuItem>
|
offsetTop={shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}
|
||||||
);
|
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
})}
|
showBrand={false}
|
||||||
</div>
|
siteName={siteName || "Lovart"}
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
const homeUtilityControls = () => (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="通知"
|
|
||||||
title="通知"
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10 dark:bg-white/6 dark:text-white/70 dark:hover:bg-white/10"
|
|
||||||
>
|
>
|
||||||
|
<button className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10 dark:bg-white/6 dark:text-white/70 dark:hover:bg-white/10">
|
||||||
<Bell className="h-3.5 w-3.5" />
|
<Bell className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
{homeThemeControl()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
const homeAccountControls = () => (
|
|
||||||
<div className="flex w-full flex-col items-center gap-[12px]">
|
|
||||||
{isMembershipEnabled ? (
|
{isMembershipEnabled ? (
|
||||||
<HoverCard openDelay={100} closeDelay={120}>
|
<HoverCard openDelay={100} closeDelay={120}>
|
||||||
<HoverCardTrigger asChild>
|
<HoverCardTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setUpgradeDialogOpen(true)}
|
onClick={() => setUpgradeDialogOpen(true)}
|
||||||
aria-label="积分与会员"
|
className="h-8 rounded-[14px] border border-black/10 bg-white px-2 text-[14px] text-black/72 shadow-sm hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
|
||||||
className="h-auto w-[36px] rounded-[8px] px-0 py-2 text-[#333] hover:bg-black/5 dark:text-white/75 dark:hover:bg-white/10"
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-[4px] text-[10px] leading-none">
|
<div className="inline-flex items-center gap-1 rounded-[9px] px-1.5 py-0.5 text-xs">
|
||||||
<Zap className="h-[20px] w-[20px]" />
|
<Zap className="h-3 w-3 text-amber-500" />
|
||||||
{membershipData?.summary?.currentCredits ?? 0}
|
{membershipData?.summary?.currentCredits ?? 0}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent
|
<HoverCardContent
|
||||||
side="right"
|
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={12}
|
sideOffset={10}
|
||||||
className="z-[150] w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
className="w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||||
>
|
>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="mb-4 flex items-start justify-between">
|
<div className="mb-4 flex items-start justify-between">
|
||||||
@ -1850,23 +1789,63 @@ export function LovartHome({
|
|||||||
</HoverCard>
|
</HoverCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-full bg-[#d4551f] text-[11px] font-semibold text-white"
|
className="inline-flex h-8 w-8 items-center justify-center rounded-[11px] border border-black/10 bg-white text-black/70 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
|
||||||
aria-label="账号菜单"
|
title={`主题模式:${selectedThemeOption.label}`}
|
||||||
title="账号菜单"
|
aria-label={`主题模式:${selectedThemeOption.label}`}
|
||||||
>
|
>
|
||||||
{userInitials}
|
<SelectedThemeIcon className="h-[15px] w-[15px]" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
side="right"
|
align="end"
|
||||||
|
sideOffset={10}
|
||||||
|
className="!min-w-0 w-auto rounded-[18px] border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{THEME_MODE_OPTIONS.map((option) => {
|
||||||
|
const OptionIcon = option.icon;
|
||||||
|
const isSelected = selectedThemeMode === option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option.value}
|
||||||
|
aria-label={option.label}
|
||||||
|
className={cn(
|
||||||
|
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[10px] p-0 focus:bg-black/5 dark:focus:bg-white/8",
|
||||||
|
isSelected
|
||||||
|
? "bg-black text-white focus:bg-black focus:text-white dark:bg-white dark:text-black dark:focus:bg-white dark:focus:text-black"
|
||||||
|
: "text-black/70 dark:text-white/70"
|
||||||
|
)}
|
||||||
|
onSelect={() => setTheme(option.value)}
|
||||||
|
title={option.label}
|
||||||
|
>
|
||||||
|
<OptionIcon className="h-[15px] w-[15px]" />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<HoverCard openDelay={80} closeDelay={120}>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white"
|
||||||
|
aria-label="Account menu"
|
||||||
|
>
|
||||||
|
{userInitials}
|
||||||
|
</button>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={12}
|
sideOffset={12}
|
||||||
className="z-[150] max-h-[calc(100svh-24px)] w-[300px] overflow-y-auto rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
className="w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||||
>
|
>
|
||||||
<div className="p-3.5">
|
<div className="p-3.5">
|
||||||
<div className="rounded-3xl border border-black/10 bg-white p-3.5 dark:border-white/10 dark:bg-[#19191d]">
|
<div className="rounded-3xl border border-black/10 bg-white p-3.5 dark:border-white/10 dark:bg-[#19191d]">
|
||||||
@ -1886,7 +1865,6 @@ export function LovartHome({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isMembershipEnabled ? (
|
{isMembershipEnabled ? (
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
|
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
|
||||||
@ -1894,12 +1872,10 @@ export function LovartHome({
|
|||||||
>
|
>
|
||||||
升级
|
升级
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 border-t border-black/10 pt-3 dark:border-white/10">
|
<div className="mt-4 border-t border-black/10 pt-3 dark:border-white/10">
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between rounded-2xl px-1 py-1 text-[13px] font-semibold text-black/80 hover:bg-black/5 dark:text-white/80 dark:hover:bg-white/6"
|
className="flex w-full items-center justify-between rounded-2xl px-1 py-1 text-[13px] font-semibold text-black/80 hover:bg-black/5 dark:text-white/80 dark:hover:bg-white/6"
|
||||||
@ -1915,7 +1891,6 @@ export function LovartHome({
|
|||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuItem>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1946,14 +1921,6 @@ export function LovartHome({
|
|||||||
label="联系我们"
|
label="联系我们"
|
||||||
onClick={() => setContactDialogOpen(true)}
|
onClick={() => setContactDialogOpen(true)}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between border-t border-black/10 px-2 py-2 dark:border-white/10">
|
|
||||||
<span className="text-[13px] text-black/65 dark:text-white/65">
|
|
||||||
通知与主题
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{homeUtilityControls()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AccountMenuItem
|
<AccountMenuItem
|
||||||
icon={LogOut}
|
icon={LogOut}
|
||||||
label="退出登录"
|
label="退出登录"
|
||||||
@ -1963,40 +1930,20 @@ export function LovartHome({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuContent>
|
</HoverCardContent>
|
||||||
</DropdownMenu>
|
</HoverCard>
|
||||||
) : (
|
) : (
|
||||||
<>
|
|
||||||
{homeThemeControl()}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex h-[32px] w-[36px] items-center justify-center rounded-full border border-black/15 bg-white text-[11px] font-medium text-black/80 hover:bg-black/5 dark:border-white/12 dark:bg-[#161618] dark:text-white/80 dark:hover:bg-white/8"
|
className="inline-flex h-8 items-center justify-center rounded-full border border-black/15 bg-white px-3 text-xs font-medium text-black/80 hover:bg-black/5 dark:border-white/12 dark:bg-[#161618] dark:text-white/80 dark:hover:bg-white/8"
|
||||||
onClick={openLoginDialog}
|
onClick={openLoginDialog}
|
||||||
>
|
>
|
||||||
登录
|
登录
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</BrandTopBar>
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="lovart-home-surface min-h-svh overflow-x-hidden bg-white text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
|
|
||||||
{shouldShowHomeBanner ? (
|
|
||||||
<LovartPromoBanner
|
|
||||||
actionText={homeBannerActionText}
|
|
||||||
link={homeBannerLink}
|
|
||||||
onAction={openHomeBannerLink}
|
|
||||||
onDismiss={dismissHomeBanner}
|
|
||||||
text={homeBannerText}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div className="fixed bottom-5 left-4 z-[100] flex w-[44px] items-center rounded-full bg-[#f5f5f5] py-2 shadow-sm lg:hidden dark:bg-[#141416]">
|
|
||||||
{homeAccountControls()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LovartSidebar
|
<LovartSidebar
|
||||||
accountControls={homeAccountControls()}
|
|
||||||
agentEnabled={siteSettingsData?.agentEnabled !== false}
|
agentEnabled={siteSettingsData?.agentEnabled !== false}
|
||||||
getItem={getLovartSidebarItem}
|
getItem={getLovartSidebarItem}
|
||||||
isEnabled={isLovartSidebarItemEnabled}
|
isEnabled={isLovartSidebarItemEnabled}
|
||||||
@ -2035,12 +1982,9 @@ export function LovartHome({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
style={
|
|
||||||
{ "--home-banner-offset": `${shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}px` } as CSSProperties
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-[120px] mr-[60px] w-auto max-w-none pb-16 max-lg:mx-5",
|
"ml-[92px] mr-10 w-auto max-w-none pb-16 max-lg:mx-5",
|
||||||
shouldShowHomeBanner ? "pt-[124px]" : "pt-[76px]"
|
shouldShowHomeBanner ? "pt-[113px]" : "pt-[68px]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isProjectsOnlyView && (
|
{!isProjectsOnlyView && (
|
||||||
@ -2069,27 +2013,9 @@ export function LovartHome({
|
|||||||
setPrompt={setPrompt}
|
setPrompt={setPrompt}
|
||||||
siteName={siteName}
|
siteName={siteName}
|
||||||
selectedModelId={selectedComposerModel.id}
|
selectedModelId={selectedComposerModel.id}
|
||||||
onOpenGallery={() => featuredSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{!isProjectsOnlyView && (
|
|
||||||
<LovartFeaturedGallery
|
|
||||||
onOpenProjects={openAllProjectsInPage}
|
|
||||||
onOpenMaterials={handleOpenImageEditor}
|
|
||||||
activeCategory={activeCategory}
|
|
||||||
categories={discoveryTabs}
|
|
||||||
featuredItems={featuredItems}
|
|
||||||
featuredLoadMoreRef={featuredLoadMoreRef}
|
|
||||||
featuredSectionRef={featuredSectionRef}
|
|
||||||
isLoadingFeatured={isLoadingFeatured}
|
|
||||||
isLoadingMoreFeatured={isLoadingMoreFeatured}
|
|
||||||
setActiveCategory={setActiveCategory}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(isProjectsOnlyView || recentProjects.length > 0) && (
|
|
||||||
<LovartProjectsSection
|
<LovartProjectsSection
|
||||||
deletingProjectId={deletingProjectId}
|
deletingProjectId={deletingProjectId}
|
||||||
hasMoreProjects={hasMoreProjects}
|
hasMoreProjects={hasMoreProjects}
|
||||||
@ -2103,6 +2029,18 @@ export function LovartHome({
|
|||||||
projects={recentProjects}
|
projects={recentProjects}
|
||||||
projectSectionRef={projectSectionRef}
|
projectSectionRef={projectSectionRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!isProjectsOnlyView && (
|
||||||
|
<LovartFeaturedGallery
|
||||||
|
activeCategory={activeCategory}
|
||||||
|
categories={discoveryTabs}
|
||||||
|
featuredItems={featuredItems}
|
||||||
|
featuredLoadMoreRef={featuredLoadMoreRef}
|
||||||
|
featuredSectionRef={featuredSectionRef}
|
||||||
|
isLoadingFeatured={isLoadingFeatured}
|
||||||
|
isLoadingMoreFeatured={isLoadingMoreFeatured}
|
||||||
|
setActiveCategory={setActiveCategory}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@ -2194,7 +2132,7 @@ export function LovartHome({
|
|||||||
|
|
||||||
{loginDialogOpen ? (
|
{loginDialogOpen ? (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/85 p-4 backdrop-blur-[1px]"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4 backdrop-blur-[1px]"
|
||||||
onClick={() => setLoginDialogOpen(false)}
|
onClick={() => setLoginDialogOpen(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
|
||||||
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
|
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
|
||||||
import type { Ref } from "react";
|
import type { Ref } from "react";
|
||||||
import type { FeaturedImage } from "./types";
|
import type { FeaturedImage } from "./types";
|
||||||
|
|
||||||
type LovartFeaturedGalleryProps = {
|
type LovartFeaturedGalleryProps = {
|
||||||
onOpenProjects: () => void;
|
|
||||||
onOpenMaterials: () => void;
|
|
||||||
activeCategory: string;
|
activeCategory: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
featuredItems: FeaturedImage[];
|
featuredItems: FeaturedImage[];
|
||||||
@ -19,11 +16,7 @@ type LovartFeaturedGalleryProps = {
|
|||||||
function getFeaturedPreviewUrl(item: FeaturedImage) {
|
function getFeaturedPreviewUrl(item: FeaturedImage) {
|
||||||
const source = item.thumbnailUrl || item.originalUrl;
|
const source = item.thumbnailUrl || item.originalUrl;
|
||||||
|
|
||||||
if (
|
if (!source || !/^https:\/\/yybb\.top\//i.test(source) || source.includes("?")) {
|
||||||
!source ||
|
|
||||||
!/^https:\/\/yybb\.top\//i.test(source) ||
|
|
||||||
source.includes("?")
|
|
||||||
) {
|
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,8 +24,6 @@ function getFeaturedPreviewUrl(item: FeaturedImage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LovartFeaturedGallery({
|
export function LovartFeaturedGallery({
|
||||||
onOpenProjects,
|
|
||||||
onOpenMaterials,
|
|
||||||
activeCategory,
|
activeCategory,
|
||||||
categories,
|
categories,
|
||||||
featuredItems,
|
featuredItems,
|
||||||
@ -43,44 +34,21 @@ export function LovartFeaturedGallery({
|
|||||||
setActiveCategory,
|
setActiveCategory,
|
||||||
}: LovartFeaturedGalleryProps) {
|
}: LovartFeaturedGalleryProps) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section ref={featuredSectionRef} className="mt-14">
|
||||||
ref={featuredSectionRef}
|
<h2 className="text-[16px] font-semibold leading-none text-[#1f1f1f] dark:text-white">
|
||||||
className="lovart-discovery mt-16 scroll-mt-28"
|
|
||||||
aria-label="灵感发现"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-2 text-[13px] text-[#858585] dark:text-white/50">
|
|
||||||
<span className="inline-flex h-7 shrink-0 items-center gap-2 rounded-[8px] bg-[#ededed] px-3 text-[#252525] dark:bg-white/10 dark:text-white">
|
|
||||||
<ReferenceIcon name="skill" size={16} />
|
|
||||||
灵感发现
|
灵感发现
|
||||||
</span>
|
</h2>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenProjects}
|
|
||||||
className="inline-flex h-7 shrink-0 items-center gap-2 rounded-[8px] px-3 hover:bg-black/5 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<ReferenceIcon name="folder" size={16} />
|
|
||||||
我的项目
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenMaterials}
|
|
||||||
className="inline-flex h-7 shrink-0 items-center gap-2 rounded-[8px] px-3 hover:bg-black/5 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<ReferenceIcon name="brand" size={16} />
|
|
||||||
我的素材
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-x-5 overflow-x-auto border-b border-[#ededed] px-3 dark:border-white/10">
|
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<button
|
<button
|
||||||
key={category}
|
key={category}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveCategory(category)}
|
onClick={() => setActiveCategory(category)}
|
||||||
className={`relative h-9 shrink-0 border-b px-0 text-[13px] font-normal leading-none transition-colors duration-150 ${
|
className={`h-7 rounded-[7px] px-3 text-[12px] font-normal leading-none transition-colors duration-150 ${
|
||||||
activeCategory === category
|
activeCategory === category
|
||||||
? "border-[#303030] text-[#202020] dark:border-white dark:text-white"
|
? "bg-[#f2f2f2] text-[#202020] dark:bg-[#24242a] dark:text-white"
|
||||||
: "border-transparent text-[#858585] hover:text-[#202020] dark:text-white/55 dark:hover:text-white"
|
: "text-[#5f5f5f] hover:bg-[#f7f7f7] hover:text-[#202020] dark:text-white/55 dark:hover:bg-[#17171b]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{category}
|
{category}
|
||||||
@ -90,19 +58,15 @@ export function LovartFeaturedGallery({
|
|||||||
|
|
||||||
{featuredItems.length === 0 ? (
|
{featuredItems.length === 0 ? (
|
||||||
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
|
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
|
||||||
{isLoadingFeatured
|
{isLoadingFeatured ? "灵感内容加载中..." : "当前没有可展示的灵感数据。"}
|
||||||
? "灵感内容加载中..."
|
|
||||||
: "当前没有可展示的灵感数据。"}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
|
<div className="mt-[14px] columns-1 gap-[10px] md:columns-2 lg:columns-5">
|
||||||
{featuredItems.map((item, index) => (
|
{featuredItems.map((item, index) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => window.open(item.originalUrl || item.thumbnailUrl, "_blank")}
|
||||||
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
|
|
||||||
}
|
|
||||||
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
|
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@ -114,17 +78,14 @@ export function LovartFeaturedGallery({
|
|||||||
const image = event.currentTarget;
|
const image = event.currentTarget;
|
||||||
const fallbackUrl = item.originalUrl || item.thumbnailUrl;
|
const fallbackUrl = item.originalUrl || item.thumbnailUrl;
|
||||||
|
|
||||||
if (
|
if (!fallbackUrl || image.dataset.fallbackApplied === "true") {
|
||||||
!fallbackUrl ||
|
|
||||||
image.dataset.fallbackApplied === "true"
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
image.dataset.fallbackApplied = "true";
|
image.dataset.fallbackApplied = "true";
|
||||||
image.src = fallbackUrl;
|
image.src = fallbackUrl;
|
||||||
}}
|
}}
|
||||||
className="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
className="block h-auto w-full rounded-[6px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
||||||
/>
|
/>
|
||||||
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
|
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
|
||||||
<div className="flex min-w-0 items-center gap-[6px] pr-2">
|
<div className="flex min-w-0 items-center gap-[6px] pr-2">
|
||||||
@ -151,10 +112,7 @@ export function LovartFeaturedGallery({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div ref={featuredLoadMoreRef} className="mt-3 flex h-10 items-center justify-center">
|
||||||
ref={featuredLoadMoreRef}
|
|
||||||
className="mt-3 flex h-10 items-center justify-center"
|
|
||||||
>
|
|
||||||
{isLoadingMoreFeatured ? (
|
{isLoadingMoreFeatured ? (
|
||||||
<div className="inline-flex items-center gap-2 text-sm text-black/45 dark:text-white/45">
|
<div className="inline-flex items-center gap-2 text-sm text-black/45 dark:text-white/45">
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
import { ArrowUp, Box, Lightbulb, Loader2, Plus, Sparkles, X } from "lucide-react";
|
||||||
import { ModelIdentityIcon } from "@/components/lovart/model-identity-icon";
|
import type { ChangeEvent } from "react";
|
||||||
import { Lightbulb, Loader2, X } from "lucide-react";
|
|
||||||
import { useRef, type ChangeEvent } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@ -54,19 +52,23 @@ type LovartHeroComposerProps = {
|
|||||||
onModelChange: (modelId: string) => void;
|
onModelChange: (modelId: string) => void;
|
||||||
onStartAgentMode: () => void;
|
onStartAgentMode: () => void;
|
||||||
selectedModelId: string;
|
selectedModelId: string;
|
||||||
onOpenGallery: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LovartHeroComposer({
|
export function LovartHeroComposer({
|
||||||
composerPlaceholder,
|
composerPlaceholder,
|
||||||
|
displayLogo,
|
||||||
files,
|
files,
|
||||||
handleAddFiles,
|
handleAddFiles,
|
||||||
|
heroPromoActionText,
|
||||||
|
heroPromoText,
|
||||||
heroSubtitleText,
|
heroSubtitleText,
|
||||||
heroTitleText,
|
heroTitleText,
|
||||||
homeAnimationData,
|
homeAnimationData,
|
||||||
isAgentModeActive,
|
isAgentModeActive,
|
||||||
|
isMembershipEnabled,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
openUpgradeDialog,
|
||||||
prompt,
|
prompt,
|
||||||
removeFile,
|
removeFile,
|
||||||
setPrompt,
|
setPrompt,
|
||||||
@ -75,71 +77,63 @@ export function LovartHeroComposer({
|
|||||||
onModelChange,
|
onModelChange,
|
||||||
onStartAgentMode,
|
onStartAgentMode,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
onOpenGallery,
|
|
||||||
}: LovartHeroComposerProps) {
|
}: LovartHeroComposerProps) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const uploadRef = useRef<HTMLInputElement>(null);
|
|
||||||
const suggestions = [
|
|
||||||
{
|
|
||||||
label: "电商",
|
|
||||||
icon: "commerce" as const,
|
|
||||||
prompt: "为我的产品设计一张高转化的电商主图,突出产品卖点。",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "社交媒体",
|
|
||||||
icon: "social" as const,
|
|
||||||
prompt: "设计一张适合社交媒体发布的视觉海报。",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "品牌",
|
|
||||||
icon: "branding" as const,
|
|
||||||
prompt: "为我的品牌设计一套统一的视觉形象。",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "创意",
|
|
||||||
icon: "creativity" as const,
|
|
||||||
prompt: "创作一张富有想象力和视觉冲击力的创意作品。",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "网站",
|
|
||||||
icon: "website" as const,
|
|
||||||
prompt: "设计一个简洁、现代的网站首页视觉稿。",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="lovart-hero mt-6 text-center" aria-label="开始设计">
|
<section className="mt-6 text-center">
|
||||||
<h1 className="text-[20px] font-medium leading-7 tracking-[-0.02em] dark:text-white">
|
{isMembershipEnabled ? (
|
||||||
{heroTitleText || "你想设计什么?"}
|
<button
|
||||||
</h1>
|
type="button"
|
||||||
{heroSubtitleText ? (
|
onClick={openUpgradeDialog}
|
||||||
<p className="mt-2 text-[13px] text-black/45 dark:text-white/45">
|
className="inline-flex w-full max-w-[620px] items-center gap-1.5 rounded-[12px] bg-[#fde8bf] px-3 py-1.5 text-[11px] leading-5 text-black/80 transition-colors duration-150 hover:bg-[#f9dfac] sm:w-auto sm:rounded-full sm:text-[12px] dark:bg-[#3f3013] dark:text-[#f6dfaa] dark:hover:bg-[#493819]"
|
||||||
{heroSubtitleText}
|
>
|
||||||
</p>
|
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
|
||||||
) : null}
|
<span className="line-clamp-2 min-w-0 flex-1 text-left sm:line-clamp-1">
|
||||||
|
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[#ff7a00]">{heroPromoActionText || "立即升级"}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex w-full max-w-[620px] items-center gap-1.5 rounded-[12px] bg-[#fde8bf] px-3 py-1.5 text-[11px] leading-5 text-black/80 sm:w-auto sm:rounded-full sm:text-[12px] dark:bg-[#3f3013] dark:text-[#f6dfaa]">
|
||||||
|
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
|
||||||
|
<span className="line-clamp-2 min-w-0 flex-1 text-left sm:line-clamp-1">
|
||||||
|
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="lovart-home-composer mx-auto mt-7 w-full max-w-[760px] rounded-[24px] border border-[#e5e5e5] bg-white px-[16px] pb-[8px] pt-[14px] shadow-[0_2px_4px_rgba(0,0,0,0.04),0_8px_20px_rgba(0,0,0,0.025)] transition-shadow duration-150 focus-within:shadow-[0_3px_8px_rgba(0,0,0,0.07)] dark:border-white/12 dark:bg-[#141416]">
|
<div className="mt-7 flex items-center justify-center gap-1.5">
|
||||||
|
{displayLogo ? (
|
||||||
|
<img
|
||||||
|
src={displayLogo}
|
||||||
|
alt={siteName}
|
||||||
|
className="h-8 w-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h1 className="text-[28px] font-semibold leading-tight sm:text-[30px] dark:text-white">
|
||||||
|
{heroTitleText || `${siteName || "Lovart"} 让设计更简单`}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[12px] text-black/38 dark:text-white/48">
|
||||||
|
{heroSubtitleText || "懂你的设计代理,帮你搞定一切"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mx-auto mt-7 w-full max-w-[760px] rounded-[16px] border border-[#e1e4e8] bg-white px-[18px] pb-[10px] pt-[14px] shadow-[0_8px_22px_rgba(0,0,0,0.055)] transition-shadow duration-150 focus-within:shadow-[0_10px_26px_rgba(0,0,0,0.075)] dark:border-white/10 dark:bg-[#141416] dark:shadow-[0_14px_38px_rgba(0,0,0,0.32)]">
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
|
||||||
aria-label="描述你的设计需求"
|
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(event) => setPrompt(event.target.value)}
|
onChange={(event) => setPrompt(event.target.value)}
|
||||||
placeholder={composerPlaceholder}
|
placeholder={composerPlaceholder}
|
||||||
className="min-h-[54px] resize-none border-0 bg-transparent px-0 !text-[16px] leading-6 text-[#20242a] shadow-none placeholder:text-[#b7b7b7] focus-visible:ring-0 md:!text-[14px] dark:text-white dark:placeholder:text-white/35"
|
className="min-h-[60px] resize-none border-0 bg-transparent px-0 !text-[16px] leading-6 text-[#20242a] shadow-none placeholder:text-[#aeb3bb] focus-visible:ring-0 md:!text-[14px] dark:text-white dark:placeholder:text-white/35"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="mb-3 flex flex-wrap gap-2 px-1">
|
<div className="mb-3 flex flex-wrap gap-2 px-1">
|
||||||
{files.map((item) => (
|
{files.map((item) => (
|
||||||
<div
|
<div key={item.id} className="group relative h-11 w-11 overflow-hidden rounded-md border border-black/10 dark:border-white/10">
|
||||||
key={item.id}
|
<img src={item.previewUrl} alt={item.file.name} className="h-full w-full object-cover" />
|
||||||
className="group relative h-11 w-11 overflow-hidden rounded-md border border-black/10 dark:border-white/10"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={item.previewUrl}
|
|
||||||
alt={item.file.name}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute right-0 top-0 hidden h-5 w-5 items-center justify-center rounded-bl bg-black/60 text-white group-hover:flex"
|
className="absolute right-0 top-0 hidden h-5 w-5 items-center justify-center rounded-bl bg-black/60 text-white group-hover:flex"
|
||||||
@ -156,10 +150,8 @@ export function LovartHeroComposer({
|
|||||||
<div className="flex min-h-[44px] items-center justify-between pt-2">
|
<div className="flex min-h-[44px] items-center justify-between pt-2">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-4">
|
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||||
<label className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-[#20242a] transition-colors duration-150 hover:bg-black/5 dark:text-white/72 dark:hover:bg-white/8">
|
<label className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-[#20242a] transition-colors duration-150 hover:bg-black/5 dark:text-white/72 dark:hover:bg-white/8">
|
||||||
<ReferenceIcon name="plus" size={18} />
|
<Plus className="h-4 w-4" strokeWidth={1.9} />
|
||||||
<input
|
<input
|
||||||
ref={uploadRef}
|
|
||||||
aria-label="上传参考图"
|
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
multiple
|
multiple
|
||||||
@ -194,17 +186,7 @@ export function LovartHeroComposer({
|
|||||||
title="选择模型"
|
title="选择模型"
|
||||||
>
|
>
|
||||||
<div className="flex h-8 w-8 items-center justify-center">
|
<div className="flex h-8 w-8 items-center justify-center">
|
||||||
<ModelIdentityIcon
|
<Box className="block h-4 w-4" strokeWidth={1.9} />
|
||||||
name={
|
|
||||||
modelOptions.find((model) => model.id === selectedModelId)
|
|
||||||
?.displayName || "auto"
|
|
||||||
}
|
|
||||||
avatarUrl={
|
|
||||||
modelOptions.find((model) => model.id === selectedModelId)
|
|
||||||
?.avatarUrl
|
|
||||||
}
|
|
||||||
size={18}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
@ -219,15 +201,18 @@ export function LovartHeroComposer({
|
|||||||
className="h-8 rounded-[7px] text-[12px] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full"
|
className="h-8 rounded-[7px] text-[12px] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full"
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<ModelIdentityIcon
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full border border-black/10 bg-white text-[10px] font-medium text-[#6b7280] dark:border-white/15 dark:bg-white/8 dark:text-white/70">
|
||||||
name={model.displayName}
|
{model.avatarUrl ? (
|
||||||
identity={`${model.modelId || model.id} ${model.providerType || ""}`}
|
<img
|
||||||
avatarUrl={model.avatarUrl}
|
alt=""
|
||||||
size={18}
|
className="h-full w-full object-cover"
|
||||||
|
src={model.avatarUrl}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1 truncate">
|
) : (
|
||||||
{model.displayName}
|
(model.displayName || "A").slice(0, 1)
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{model.displayName}</span>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@ -235,63 +220,19 @@ export function LovartHeroComposer({
|
|||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
aria-label="开始生成"
|
|
||||||
disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
|
disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
|
||||||
className="h-9 w-9 rounded-full bg-[#eeeeef] p-0 text-[#c2c7cf] transition-colors duration-150 hover:bg-[#e5e6e8] hover:text-[#7f8794] dark:bg-white/10 dark:text-white/40 dark:hover:bg-white/16"
|
className="h-9 w-9 rounded-full bg-[#eeeeef] p-0 text-[#c2c7cf] transition-colors duration-150 hover:bg-[#e5e6e8] hover:text-[#7f8794] dark:bg-white/10 dark:text-white/40 dark:hover:bg-white/16"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<Loader2 className="h-4.5 w-4.5 animate-spin" />
|
<Loader2 className="h-4.5 w-4.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<ReferenceIcon name="arrow-up" size={18} />
|
<ArrowUp className="h-4 w-4" strokeWidth={1.8} />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto mt-4 w-full max-w-[760px] space-y-3 px-4 text-left">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="mr-1 w-[96px] shrink-0 text-[13px] text-[#8b8b8b] dark:text-white/45">
|
|
||||||
{siteName} 帮你做:
|
|
||||||
</span>
|
|
||||||
{suggestions.map(({ label, icon, prompt: suggestion }) => (
|
|
||||||
<button
|
|
||||||
key={label}
|
|
||||||
type="button"
|
|
||||||
className="lovart-suggestion"
|
|
||||||
onClick={() => {
|
|
||||||
setPrompt(suggestion);
|
|
||||||
textareaRef.current?.focus();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ReferenceIcon name={icon} size={18} />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="mr-1 w-[96px] shrink-0 text-[13px] text-[#8b8b8b] dark:text-white/45">
|
|
||||||
连接/收集灵感:
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="lovart-suggestion"
|
|
||||||
onClick={() => uploadRef.current?.click()}
|
|
||||||
>
|
|
||||||
<ReferenceIcon name="connections" size={18} />
|
|
||||||
上传我的参考素材
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="lovart-suggestion"
|
|
||||||
onClick={onOpenGallery}
|
|
||||||
>
|
|
||||||
<ReferenceIcon name="clipper" size={18} />
|
|
||||||
浏览灵感,发现更多创意
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{homeAnimationData?.enabled && homeAnimationData?.htmlContent ? (
|
{homeAnimationData?.enabled && homeAnimationData?.htmlContent ? (
|
||||||
<div className="mt-6 w-full">
|
<div className="mt-6 w-full">
|
||||||
<HomeAnimationEmbed htmlContent={homeAnimationData.htmlContent} />
|
<HomeAnimationEmbed htmlContent={homeAnimationData.htmlContent} />
|
||||||
|
|||||||
@ -8,7 +8,7 @@ type LovartPromoBannerProps = {
|
|||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LOVART_PROMO_BANNER_HEIGHT = 48;
|
export const LOVART_PROMO_BANNER_HEIGHT = 45;
|
||||||
|
|
||||||
export function LovartPromoBanner({
|
export function LovartPromoBanner({
|
||||||
actionText,
|
actionText,
|
||||||
@ -20,7 +20,7 @@ export function LovartPromoBanner({
|
|||||||
const showAction = Boolean(link?.trim() && actionText?.trim());
|
const showAction = Boolean(link?.trim() && actionText?.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-x-0 top-0 z-[120] h-[48px] bg-[#e4ff58] text-[#11130d]">
|
<div className="fixed inset-x-0 top-0 z-[120] h-[45px] bg-[#ddff58] text-[#11130d]">
|
||||||
<div className="mx-auto flex h-full max-w-[1440px] items-center justify-center gap-3 px-11 sm:px-12">
|
<div className="mx-auto flex h-full max-w-[1440px] items-center justify-center gap-3 px-11 sm:px-12">
|
||||||
<Sparkles className="hidden h-4 w-4 shrink-0 sm:block" />
|
<Sparkles className="hidden h-4 w-4 shrink-0 sm:block" />
|
||||||
<p className="min-w-0 truncate text-center text-[13px] font-normal sm:text-[15px]">
|
<p className="min-w-0 truncate text-center text-[13px] font-normal sm:text-[15px]">
|
||||||
|
|||||||
@ -20,7 +20,6 @@ type SidebarItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type LovartSidebarProps = {
|
type LovartSidebarProps = {
|
||||||
accountControls?: ReactNode;
|
|
||||||
agentEnabled: boolean;
|
agentEnabled: boolean;
|
||||||
getItem: (key: LovartHomeSidebarItemKey) => SidebarItem;
|
getItem: (key: LovartHomeSidebarItemKey) => SidebarItem;
|
||||||
isEnabled: (key: LovartHomeSidebarItemKey) => boolean;
|
isEnabled: (key: LovartHomeSidebarItemKey) => boolean;
|
||||||
@ -57,7 +56,6 @@ type LovartSidebarProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function LovartSidebar({
|
export function LovartSidebar({
|
||||||
accountControls,
|
|
||||||
agentEnabled,
|
agentEnabled,
|
||||||
getItem,
|
getItem,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
@ -90,9 +88,9 @@ export function LovartSidebar({
|
|||||||
"rounded-[12px] border border-black/8 bg-white/96 p-2 text-[#111827] shadow-[0_18px_44px_rgba(15,23,42,0.12)] backdrop-blur-xl dark:border-white/10 dark:bg-[#141416]/96 dark:text-white dark:shadow-[0_18px_44px_rgba(0,0,0,0.4)]";
|
"rounded-[12px] border border-black/8 bg-white/96 p-2 text-[#111827] shadow-[0_18px_44px_rgba(15,23,42,0.12)] backdrop-blur-xl dark:border-white/10 dark:bg-[#141416]/96 dark:text-white dark:shadow-[0_18px_44px_rgba(0,0,0,0.4)]";
|
||||||
const sidebarButtonClass = (active = false) =>
|
const sidebarButtonClass = (active = false) =>
|
||||||
[
|
[
|
||||||
"group flex h-[36px] w-[36px] shrink-0 items-center justify-center rounded-[8px] text-[#202020] transition-colors duration-150 hover:bg-[#f5f5f5] dark:text-white dark:hover:bg-white/10",
|
"group flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[#202020] transition-colors duration-150 hover:bg-[#f5f5f5] dark:text-white dark:hover:bg-white/10",
|
||||||
active
|
active
|
||||||
? "bg-[#e5e5e5] text-[#202020] dark:bg-white/10 dark:text-white"
|
? "bg-[#f5f5f5] text-[#202020] dark:bg-white/10 dark:text-white"
|
||||||
: "",
|
: "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@ -114,7 +112,7 @@ export function LovartSidebar({
|
|||||||
>
|
>
|
||||||
{renderIcon(key, {
|
{renderIcon(key, {
|
||||||
active: options?.active,
|
active: options?.active,
|
||||||
sizeClassName: "h-[20px] w-[20px]",
|
sizeClassName: "h-4 w-4",
|
||||||
})}
|
})}
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -135,7 +133,7 @@ export function LovartSidebar({
|
|||||||
className={sidebarButtonClass()}
|
className={sidebarButtonClass()}
|
||||||
title={getItem(key).label}
|
title={getItem(key).label}
|
||||||
>
|
>
|
||||||
{renderIcon(key, { sizeClassName: "h-[20px] w-[20px]" })}
|
{renderIcon(key, { sizeClassName: "h-4 w-4" })}
|
||||||
</button>
|
</button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent
|
<HoverCardContent
|
||||||
@ -166,15 +164,15 @@ export function LovartSidebar({
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={[
|
className={[
|
||||||
"fixed bottom-0 left-0 top-0 z-[100] hidden w-[60px] flex-col items-center border-r lg:flex",
|
"fixed bottom-0 left-0 top-0 z-[100] hidden w-[56px] flex-col items-center border-r lg:flex",
|
||||||
isDark
|
isDark
|
||||||
? "border-white/10 bg-black"
|
? "border-white/10 bg-black"
|
||||||
: "border-[#ededed] bg-[#f5f5f5]",
|
: "border-[#e9e9e9] bg-white",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
style={{ top: topOffset }}
|
style={{ top: topOffset }}
|
||||||
>
|
>
|
||||||
<TooltipProvider delayDuration={120}>
|
<TooltipProvider delayDuration={120}>
|
||||||
<div className="flex min-h-0 w-full flex-1 flex-col items-center overflow-y-auto overflow-x-hidden px-2 pt-[16px] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
<div className="flex h-full w-full flex-col items-center overflow-y-auto overflow-x-hidden px-2 pb-3 pt-3 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@ -199,18 +197,18 @@ export function LovartSidebar({
|
|||||||
<TooltipContent side="right">{siteName}</TooltipContent>
|
<TooltipContent side="right">{siteName}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div className="mt-[22px] flex w-full flex-col items-center gap-[2px]">
|
<div className="mt-5 flex w-full flex-col items-center gap-3.5">
|
||||||
{isEnabled("newProject") ? (
|
{isEnabled("newProject") ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNewCanvas}
|
onClick={onNewCanvas}
|
||||||
className={`${sidebarButtonClass()} mb-[14px] border border-[#e5e5e5] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:bg-white/5`}
|
className={sidebarButtonClass()}
|
||||||
title={getItem("newProject").label}
|
title={getItem("newProject").label}
|
||||||
>
|
>
|
||||||
{renderIcon("newProject", {
|
{renderIcon("newProject", {
|
||||||
sizeClassName: "h-[20px] w-[20px]",
|
sizeClassName: "h-4 w-4",
|
||||||
})}
|
})}
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -230,7 +228,7 @@ export function LovartSidebar({
|
|||||||
className={sidebarButtonClass()}
|
className={sidebarButtonClass()}
|
||||||
title={getItem("tutorial").label}
|
title={getItem("tutorial").label}
|
||||||
>
|
>
|
||||||
{renderIcon("tutorial", { sizeClassName: "h-[20px] w-[20px]" })}
|
{renderIcon("tutorial", { sizeClassName: "h-4 w-4" })}
|
||||||
</button>
|
</button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent
|
<HoverCardContent
|
||||||
@ -283,6 +281,9 @@ export function LovartSidebar({
|
|||||||
</HoverCard>
|
</HoverCard>
|
||||||
) : null}
|
) : null}
|
||||||
{tooltipButton("imageEditor", onImageEditor)}
|
{tooltipButton("imageEditor", onImageEditor)}
|
||||||
|
{tooltipButton("aiCad", () => onRoute("/ai-cad"))}
|
||||||
|
{tooltipButton("model3d", () => onRoute("/model3d"))}
|
||||||
|
{tooltipButton("scene", () => onRoute("/pascal-editor"))}
|
||||||
{agentEnabled ? tooltipButton("agent", () => onRoute("/canvas?mode=agent")) : null}
|
{agentEnabled ? tooltipButton("agent", () => onRoute("/canvas?mode=agent")) : null}
|
||||||
{videoCanvasEnabled ? tooltipButton("videoCanvas", () => onRoute("/video-canvas")) : null}
|
{videoCanvasEnabled ? tooltipButton("videoCanvas", () => onRoute("/video-canvas")) : null}
|
||||||
{tooltipButton("flowBoard", () => onRoute("/flow-board"))}
|
{tooltipButton("flowBoard", () => onRoute("/flow-board"))}
|
||||||
@ -293,10 +294,9 @@ export function LovartSidebar({
|
|||||||
{toolbox("toolbox2", toolbox2Links, toolbox2Enabled)}
|
{toolbox("toolbox2", toolbox2Links, toolbox2Enabled)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
<div className="mt-auto flex w-full flex-col items-center gap-1 pt-4">
|
||||||
<div className="flex w-full shrink-0 flex-col items-center gap-[10px] px-2 pb-[22px] pt-[12px]">
|
|
||||||
{settingsHref ? tooltipButton("settings", () => onRoute(settingsHref)) : null}
|
{settingsHref ? tooltipButton("settings", () => onRoute(settingsHref)) : null}
|
||||||
{accountControls}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ChevronDown, ChevronUp, Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
|
import { ChevronDown, ChevronUp, Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
|
||||||
@ -38,7 +37,6 @@ interface ImageParamsBarProps {
|
|||||||
hideImageCount?: boolean; // 隐藏图片数量按钮(仅个别页面需要)
|
hideImageCount?: boolean; // 隐藏图片数量按钮(仅个别页面需要)
|
||||||
hideAdvancedOptions?: boolean; // 隐藏高级设置按钮
|
hideAdvancedOptions?: boolean; // 隐藏高级设置按钮
|
||||||
inlineTheme?: "light" | "dark" | "minimal";
|
inlineTheme?: "light" | "dark" | "minimal";
|
||||||
referencePresentation?: boolean;
|
|
||||||
showInlineDivider?: boolean;
|
showInlineDivider?: boolean;
|
||||||
// 🆕 图片生成模型特性配置(从模型配置中读取)
|
// 🆕 图片生成模型特性配置(从模型配置中读取)
|
||||||
imageGenConfig?: {
|
imageGenConfig?: {
|
||||||
@ -137,7 +135,6 @@ export function ImageParamsBar({
|
|||||||
hideImageCount = false,
|
hideImageCount = false,
|
||||||
hideAdvancedOptions = false,
|
hideAdvancedOptions = false,
|
||||||
inlineTheme = "light",
|
inlineTheme = "light",
|
||||||
referencePresentation = false,
|
|
||||||
showInlineDivider = true,
|
showInlineDivider = true,
|
||||||
imageGenConfig,
|
imageGenConfig,
|
||||||
}: ImageParamsBarProps) {
|
}: ImageParamsBarProps) {
|
||||||
@ -496,13 +493,12 @@ export function ImageParamsBar({
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn("h-8 shrink-0 rounded-full bg-[#f1f1f2] px-2.5 text-[13px] font-normal text-[#20242a] hover:bg-[#e9eaec] focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]", referencePresentation && "lovart-image-params")}
|
className="h-8 shrink-0 rounded-full bg-[#f1f1f2] px-2.5 text-[13px] font-normal text-[#20242a] hover:bg-[#e9eaec] focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]"
|
||||||
title="生成参数"
|
title="生成参数"
|
||||||
aria-label={`比例:${params.aspectRatio},分辨率:${params.imageSize}`}
|
aria-label={`比例:${params.aspectRatio},分辨率:${params.imageSize}`}
|
||||||
>
|
>
|
||||||
{referencePresentation ? <SlidersHorizontal className="lovart-image-params-icon h-4 w-4 shrink-0" aria-hidden="true" /> : null}
|
{qualityLabel} · {selectedAspectLabel} · {currentImageSize} · {selectedImageCount} img
|
||||||
<span className={referencePresentation ? "lovart-image-params-label min-w-0 truncate text-[13px]" : undefined}>{qualityLabel} · {selectedAspectLabel} · {currentImageSize} · {selectedImageCount} {referencePresentation ? "张" : "img"}</span>
|
{minimalParamsOpen ? (
|
||||||
{referencePresentation ? <ReferenceIcon name="chevron-down" size={18} className={minimalParamsOpen ? "rotate-180" : undefined} /> : minimalParamsOpen ? (
|
|
||||||
<ChevronUp className="ml-1 h-3 w-3" />
|
<ChevronUp className="ml-1 h-3 w-3" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronDown className="ml-1 h-3 w-3" />
|
<ChevronDown className="ml-1 h-3 w-3" />
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { getReferenceModelIcon, ReferenceIcon } from "./reference-icon";
|
|
||||||
|
|
||||||
type ModelIdentityIconProps = {
|
|
||||||
name: string;
|
|
||||||
identity?: string;
|
|
||||||
avatarUrl?: string | null;
|
|
||||||
size?: number;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ModelIdentityIcon({
|
|
||||||
name,
|
|
||||||
identity,
|
|
||||||
avatarUrl,
|
|
||||||
size = 18,
|
|
||||||
className,
|
|
||||||
}: ModelIdentityIconProps) {
|
|
||||||
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
|
||||||
const reference = getReferenceModelIcon(`${name} ${identity || ""}`);
|
|
||||||
if (reference || !avatarUrl || failedUrl === avatarUrl) {
|
|
||||||
return (
|
|
||||||
<ReferenceIcon
|
|
||||||
name={reference || "model-auto"}
|
|
||||||
size={size}
|
|
||||||
className={className}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className={className}
|
|
||||||
src={avatarUrl}
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
style={{ width: size, height: size, objectFit: "contain", flexShrink: 0 }}
|
|
||||||
onError={() => setFailedUrl(avatarUrl)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
import type { CSSProperties } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export type ReferenceIconName =
|
|
||||||
| "home"
|
|
||||||
| "folder"
|
|
||||||
| "skill"
|
|
||||||
| "brand"
|
|
||||||
| "plus"
|
|
||||||
| "model-auto"
|
|
||||||
| "arrow-up"
|
|
||||||
| "commerce"
|
|
||||||
| "social"
|
|
||||||
| "branding"
|
|
||||||
| "creativity"
|
|
||||||
| "website"
|
|
||||||
| "connections"
|
|
||||||
| "clipper"
|
|
||||||
| "cursor"
|
|
||||||
| "pan"
|
|
||||||
| "text"
|
|
||||||
| "sparkles"
|
|
||||||
| "style-controls"
|
|
||||||
| "image-enhance"
|
|
||||||
| "tutorial"
|
|
||||||
| "video"
|
|
||||||
| "toolbox"
|
|
||||||
| "settings"
|
|
||||||
| "profile"
|
|
||||||
| "comment"
|
|
||||||
| "draw"
|
|
||||||
| "image"
|
|
||||||
| "layers"
|
|
||||||
| "history"
|
|
||||||
| "expand"
|
|
||||||
| "collapse"
|
|
||||||
| "reference"
|
|
||||||
| "skill-filled"
|
|
||||||
| "brand-filled"
|
|
||||||
| "pinterest"
|
|
||||||
| "close"
|
|
||||||
| "reference-image"
|
|
||||||
| "chevron-down"
|
|
||||||
| "points"
|
|
||||||
| "page"
|
|
||||||
| "model-banana"
|
|
||||||
| "model-gpt"
|
|
||||||
| "model-seedream"
|
|
||||||
| "model-luma"
|
|
||||||
| "model-flux"
|
|
||||||
| "model-midjourney"
|
|
||||||
| "model-ideogram"
|
|
||||||
| "model-recraft"
|
|
||||||
| "image-local-edit"
|
|
||||||
| "image-layout-replace"
|
|
||||||
| "image-mark"
|
|
||||||
| "image-upscale"
|
|
||||||
| "image-remove-background"
|
|
||||||
| "image-erase"
|
|
||||||
| "image-extract-layer"
|
|
||||||
| "image-edit-text"
|
|
||||||
| "image-angle"
|
|
||||||
| "image-motion"
|
|
||||||
| "image-more"
|
|
||||||
| "image-download"
|
|
||||||
| "image-move-object"
|
|
||||||
| "image-mockup"
|
|
||||||
| "image-outpaint"
|
|
||||||
| "image-adjust"
|
|
||||||
| "image-crop"
|
|
||||||
| "image-vector"
|
|
||||||
| "image-flip-rotate"
|
|
||||||
| "toolbar-pin-outline"
|
|
||||||
| "toolbar-pin-filled"
|
|
||||||
| "toolbar-reset"
|
|
||||||
| "toolbar-close";
|
|
||||||
|
|
||||||
type ReferenceIconProps = {
|
|
||||||
name: ReferenceIconName;
|
|
||||||
size?: number;
|
|
||||||
className?: string;
|
|
||||||
style?: CSSProperties;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ReferenceIcon({
|
|
||||||
name,
|
|
||||||
size = 18,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
}: ReferenceIconProps) {
|
|
||||||
const source = `url("/icons/lovart/${name}.svg")`;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
data-reference-icon={name}
|
|
||||||
className={cn("inline-block shrink-0 align-middle", className)}
|
|
||||||
style={{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
backgroundColor: "currentColor",
|
|
||||||
WebkitMaskImage: source,
|
|
||||||
maskImage: source,
|
|
||||||
WebkitMaskRepeat: "no-repeat",
|
|
||||||
maskRepeat: "no-repeat",
|
|
||||||
WebkitMaskPosition: "center",
|
|
||||||
maskPosition: "center",
|
|
||||||
WebkitMaskSize: "contain",
|
|
||||||
maskSize: "contain",
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getReferenceModelIcon(name: string): ReferenceIconName | null {
|
|
||||||
if (/nano[\s-]*banana|gemini|imagen/i.test(name)) return "model-banana";
|
|
||||||
if (/gpt|openai|dall[\s-]*e/i.test(name)) return "model-gpt";
|
|
||||||
if (/seedream|doubao/i.test(name)) return "model-seedream";
|
|
||||||
if (/flux/i.test(name)) return "model-flux";
|
|
||||||
if (/luma|uni[\s-]*1/i.test(name)) return "model-luma";
|
|
||||||
if (/midjourney/i.test(name)) return "model-midjourney";
|
|
||||||
if (/ideogram|p[\s-]*image/i.test(name)) return "model-ideogram";
|
|
||||||
if (/recraft/i.test(name)) return "model-recraft";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
114
deploy.sh
114
deploy.sh
@ -1,6 +1,108 @@
|
|||||||
#!/usr/bin/env bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
|
||||||
printf '%s\n' 'Legacy deployment entry point disabled: it targets an obsolete host/runtime.' >&2
|
# AI Chatbot 部署脚本
|
||||||
printf '%s\n' 'Use: python scripts/production48.py status' >&2
|
# 使用方法: ./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"
|
||||||
|
LOCAL_DIR="."
|
||||||
|
|
||||||
|
# 颜色输出
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${GREEN}开始部署到服务器...${NC}"
|
||||||
|
|
||||||
|
# 检查本地是否有未提交的更改
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo -e "${YELLOW}警告: 有未提交的更改${NC}"
|
||||||
|
read -p "是否继续? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. 准备部署文件(跳过本地构建,在服务器上构建)
|
||||||
|
echo -e "${GREEN}步骤 1: 准备部署文件...${NC}"
|
||||||
|
tar --exclude='node_modules' \
|
||||||
|
--exclude='.next' \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.env.local' \
|
||||||
|
--exclude='.env.production' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
--exclude='deploy.tar.gz' \
|
||||||
|
-czf deploy.tar.gz .
|
||||||
|
|
||||||
|
# 2. 上传到服务器
|
||||||
|
echo -e "${GREEN}步骤 2: 上传文件到服务器...${NC}"
|
||||||
|
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no -P $PORT deploy.tar.gz $USER@$SERVER:/tmp/
|
||||||
|
|
||||||
|
# 3. 在服务器上执行部署
|
||||||
|
echo -e "${GREEN}步骤 3: 在服务器上部署...${NC}"
|
||||||
|
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -p $PORT $USER@$SERVER << 'ENDSSH'
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REMOTE_DIR="/var/www/ai-chatbot"
|
||||||
|
BACKUP_DIR="/root/backups/ai-chatbot"
|
||||||
|
|
||||||
|
# 创建目录
|
||||||
|
mkdir -p $REMOTE_DIR
|
||||||
|
mkdir -p $BACKUP_DIR
|
||||||
|
|
||||||
|
# 备份现有部署(如果存在)
|
||||||
|
if [ -d "$REMOTE_DIR/.next" ]; then
|
||||||
|
echo "备份现有部署..."
|
||||||
|
tar -czf $BACKUP_DIR/backup_$(date +%Y%m%d_%H%M%S).tar.gz -C $REMOTE_DIR .
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 解压新文件
|
||||||
|
echo "解压新文件..."
|
||||||
|
cd $REMOTE_DIR
|
||||||
|
tar -xzf /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 "安装依赖..."
|
||||||
|
pnpm install --production=false
|
||||||
|
|
||||||
|
# 运行数据库迁移
|
||||||
|
echo "运行数据库迁移..."
|
||||||
|
pnpm db:migrate || echo "数据库迁移失败,请检查配置"
|
||||||
|
|
||||||
|
# 构建项目
|
||||||
|
echo "构建项目..."
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# 重启应用
|
||||||
|
echo "重启应用..."
|
||||||
|
pm2 restart ai-chatbot || pm2 start ecosystem.config.js
|
||||||
|
|
||||||
|
# 清理
|
||||||
|
rm /tmp/deploy.tar.gz
|
||||||
|
|
||||||
|
echo "部署完成!"
|
||||||
|
ENDSSH
|
||||||
|
|
||||||
|
# 4. 清理本地临时文件
|
||||||
|
echo -e "${GREEN}步骤 4: 清理临时文件...${NC}"
|
||||||
|
rm deploy.tar.gz
|
||||||
|
|
||||||
|
echo -e "${GREEN}部署完成!${NC}"
|
||||||
|
echo -e "${YELLOW}请检查应用状态: ssh -p $PORT $USER@$SERVER 'pm2 status'${NC}"
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
# 本机 Xmind 客户端源码与界面对照
|
|
||||||
|
|
||||||
本轮修改位于用户指定的“旧电脑桌面/第四部分/开发项目/ai-chatbot-main”。本机 `/Applications/Xmind.app` 为 26.04.01337,与此前的 26.05 参考包分别记录。
|
|
||||||
|
|
||||||
- 读取 `Contents/Resources/app.asar`,核对 `renderer/editor-frame.js`、`renderer/editor.js`、Vanakit 样式及浮动工具栏/工作表栏样式。完整第三方代码只放在临时目录。
|
|
||||||
- 浮动工具栏按客户端顶部 16px、组间距 12px、圆角 24px 调整。主题/子主题、联系、概要、外框、插入、导出、大纲、ZEN、演示、标记和格式入口接入现有画布。菜单支持方向键和 Escape。
|
|
||||||
- 格式侧栏包含样式/画布分类及可展开结构选择。底部胶囊工作表支持重命名取消及缩放菜单。图标、NeverMind 字体来源记录在各自 public 子目录。
|
|
||||||
- 修正全局等宽字体覆盖、字体测量与实际渲染不一致引发的标题裁切。导入保留无填充、零边框、分支配色,透明填充随 XMind 导出。
|
|
||||||
- 查找支持折叠后代、当前/全部工作表、跨工作表定位。跨富文本叶替换保持文字标记,替换中的 `$&` 按字面写入;批量替换可撤销/重做。
|
|
||||||
- 30 项数据测试、18 项浏览器测试、类型检查及新增组件/工具的 Biome 检查通过。浏览器测试覆盖安装包 9 个基础样例,并检查主主题字体和文字不裁切。
|
|
||||||
|
|
||||||
此轮完成上述基础能力和界面调整,尚未达到全功能及逐像素一致。格式控件、节点间距、左侧分支顺序、完整 Pitch、全部主题、任务/甘特图、云协作等仍有差距,不能用测试数量认定与原生完全对等。完整验收清单见 [功能对照](./XMind-复刻功能对照.md)。
|
|
||||||
|
|
||||||
## 2026-10-04 实机操作与项目 UI 调整
|
|
||||||
|
|
||||||
- 本机客户端实际打开网页导出的 XMind 文件、显示备注和标签、将主主题字号改为 24pt,并保存;网页再次导入后,字号、两行纯文本备注和附件字节保留。该指定样例通过,不代表所有样式和内容无损兼容。
|
|
||||||
- 实际打开原生查找、标记和甘特窗口。任务编辑及完整演示遇到授权界面;云端服务、所有导出类型、全部布局变体仍未完成逐项实机覆盖。
|
|
||||||
- 修改前的 17 个网页功能组操作中 14 个通过、3 个文字格式组异常。进一步读取可见文字片段,确认选中整个主题修改字号、文字颜色或标记时,格式落入空文字片段,已有标题未更新。该问题仍待修复。
|
|
||||||
- 相同窗口 1480×873、DPR=2、100% 下,原生样例主主题色块约 137×41,网页约 124×37;同侧分支间距约 130 与 63,左侧分支顺序不同。测量的是色块区域,不能视为逐像素一致。
|
|
||||||
- 根据用户最新要求,界面改为项目首页使用的 Arial/PingFang 字体与紧凑控件:取消模拟窗口按钮,工具栏圆角改为 8px,侧栏从 264px 改为 240px,减少预览卡片和分组留白。画布主题字体和实际缩放由文档保留。检查显示页面 CSS zoom=1、transform=none,新建画板 100%。
|
|
||||||
- 增加常驻选择/平移按钮、V/H 快捷键与当前模式反馈,初始化和工作表切换默认选择模式;切换选择/平移时重置创建模式。文字输入框的 V/H 不被快捷键拦截。
|
|
||||||
- 框选两分支、批量删除和撤销、平移后再次框选、按钮/快捷键切换和文本输入均通过浏览器验证;查找侧栏遮挡文件菜单的问题已修正。20 项浏览器用例全部通过(首轮 18 项通过,两个既有测试因新增工具占据原点击坐标而失败,调整为空白坐标后复测通过);TypeScript 检查通过。800px 窗口无页面横向溢出。
|
|
||||||
- 开发预览点击保存后刷新回空白,不是正式账号云端保存的验收结果。详细截图和 46 项能力对照保存在本机 `.local/xmind-audit-2026-10-04/index.html`,未修改用户原始 Xmind 文档。
|
|
||||||
|
|
||||||
## 2026-10-04 框选线可见性修复
|
|
||||||
|
|
||||||
- 原有框选数量测试未覆盖实际显示。实测发现预览页没有应用画板基础样式,活动 SVG 的 position=static,区域顶边位于屏幕 y=1309,因此选中数量变化但框线不可见。
|
|
||||||
- 将画板基础 CSS 移到共享客户端组件中打包加载,替代只在正式路由加载的外部样式链接。修复后活动 SVG 为 absolute,覆盖画布可视区域且 pointer-events=none,不拦截编辑操作。
|
|
||||||
- 框选边线为 2px 蓝色,拖动区域有浅蓝填充;松开后去掉拖动填充,保留被选主题和多选范围描边。
|
|
||||||
- 框选回归新增可视坐标、边框颜色/宽度、填充及松开后高亮检查,覆盖平移后再框选。实际截图保存在 `.local/xmind-audit-2026-10-04/web/22-marquee-drag-after.png`。
|
|
||||||
|
|
||||||
## 2026-10-05 主题图片粘贴修复
|
|
||||||
|
|
||||||
- 修复主题文字编辑时跳过图片粘贴的问题:选中一个主题或正在编辑该主题时,剪贴板图片作为主题图片插入,保留标题;同一主题再次插入替换原图。插入菜单的图片入口采用相同行为,空白画布保留独立图片插入。
|
|
||||||
- 图片按比例缩放,主题图片宽度上限 240px。异步读取/上传后重新查找目标主题,切换画布或目标已删除时不插入其他位置。文字、标题、备注等文本粘贴保留各自原有处理。
|
|
||||||
- 开发预览直接保存图片数据,避免依赖登录上传;正式页面继续使用原有上传接口。支持 JPEG、PNG、WebP,保留原有大小限制。
|
|
||||||
- 4 项浏览器用例通过,使用实际剪贴板和粘贴快捷键验证编辑状态、选中状态、空白画布及文字粘贴;同时覆盖图片撤销/重做、XMind 导出嵌入资源和标题/备注不拦截。TypeScript 与本轮文件 Biome 检查通过。正式账号上传接口未在此轮实测。
|
|
||||||
|
|
||||||
## 2026-10-05 原生标记面板与文件往返
|
|
||||||
|
|
||||||
- 原先采用少量系统 Emoji 代替原生标记,大部分标记不能导出;旧面板在 240px 侧栏中使用固定宽度网格,会溢出。现使用安装包的标记目录、原生 SVG/PNG 和标记入口图标,默认显示标签、优先级、任务、旗帜、星星、人像、符号 7 组 58 个标记。
|
|
||||||
- 提取客户端目录中的 219 份标记资源,包含 197 个不同 ID;共享 ID 按客户端目录顺序解析。搜索可查找隐藏标记,“更多标记”展示完整目录,并用于旧文档导入显示。资源来源和哈希记录在 `public/icons/xmind/markers/`。
|
|
||||||
- 选中主题后添加标记,同组替换,再次点击移除;多选显示混合状态,点击后为全部选中主题应用同一值。批量修改和清空各作为一次撤销操作。清空同时移除保留的未知原生标记,撤销可恢复;普通自定义 Emoji 不受影响。
|
|
||||||
- 标记在主题上以客户端图标呈现,支持 XMind 导入导出。未识别的 ID 继续作为元数据保留,不能宣称所有第三方自定义标记都可显示。贴纸、插画、自定义标记库及图例交互仍未实现;原先禁用的占位页签已移除。
|
|
||||||
- 3 项标记浏览器测试覆盖面板尺寸、实际图标加载、增删替换、清空、批量撤销/重做、搜索和更多标记;2 项数据测试覆盖全部目录资源存在、197 个 ID 文件往返及旧 Emoji 兼容。TypeScript 与本轮文件的 Biome 检查通过。
|
|
||||||
- 连续操作发现成功提示会盖住顶部插入菜单;将白板内的提示移至底部,并取消清空标记后的遮挡提示。图片粘贴用例等待画布字体加载后再操作,避免加载中的坐标变化影响双击。
|
|
||||||
|
|
||||||
|
|
||||||
## 2026-10-06 笔记与甘特图
|
|
||||||
|
|
||||||
已加入富文本笔记、主题笔记/任务提示、任务信息窗口和甘特图。甘特图参考客户端左右表格/时间网格、橙色胶囊时间条、任务名称与负责人提示、选中行高亮、圆角容器、缩放和今天线,按项目现有控件尺寸实现。日级任务可编辑日期/工期、进度、优先级、负责人及四种前置关系;拖动和拉伸支持联动排期及批次撤销。
|
|
||||||
|
|
||||||
文件格式按安装包核对:最新笔记使用 `realHTML.content`,任务使用 `org.xmind.ui.task` 扩展、0–1 进度和毫秒工期。笔记默认在主题旁的浮窗编辑,支持展开主题详情侧栏;甘特图高级工作日日历、小时级及里程碑、团队成员服务和原生全部导出方式尚未对等。
|
|
||||||
|
|
||||||
本机 XMind 已实际打开本轮导出文件,任务卡片和原生甘特图正确显示 2026 年 10 月 5–6 日(2 天)与 7–9 日(3 天)、负责人“小明”;原生甘特图的前置关系箭头正常显示;笔记浮窗显示“1. 交付说明”、加粗及蓝色链接。指定样例的原生读取已验证,不能据此认定全部复杂笔记和任务设置均无损。
|
|
||||||
|
|
||||||
本轮 9 项数据检查、15 项浏览器检查(分批运行)、TypeScript 和相关代码检查通过。浏览器覆盖笔记与甘特图,并回归标记、框选、图片粘贴、主题内容及文件往返;首次空白画布导出超时后的复测已通过。快捷键不再把笔记全选变成画布多选。
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
# XMind 26.05 安装包对照记录
|
|
||||||
|
|
||||||
用户提供:`/Users/a134/Downloads/Xmind-for-macOS-26.05.01107-202609082317.dmg`。
|
|
||||||
|
|
||||||
2026-09-18 只读挂载至 `/Volumes/Xmind`。从 Info.plist 实际读取版本 **26.05.01107**、构建 **2026090823170**、Bundle ID `net.xmind.vana.app`。本机已有版本为 26.04.01337。签名元数据标识为 XMind Ltd.,Team ID `4WV38P2X5K`。
|
|
||||||
|
|
||||||
## 可复用的核验入口
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 scripts/flow-board/inspect-xmind-package.py /Volumes/Xmind/Xmind.app --samples-dir /tmp/xmind-26.05-reference
|
|
||||||
XMIND_NATIVE_FIXTURES=/tmp/xmind-26.05-reference pnpm exec playwright test --config playwright.flow-board-ui.config.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本读取安装包的版本和内置样例,输出文件大小、SHA-256、根布局类型。样例仅提取到本机临时目录用于兼容性验证,不作为项目的内置模板提交。
|
|
||||||
|
|
||||||
## 样例与布局
|
|
||||||
|
|
||||||
安装包含 36 个 `.xmind` 样例,其中基础样例 9 个;这 9 个文件的 SHA-256 与本机 26.04 同名文件相同。
|
|
||||||
|
|
||||||
| 样例 | 文件内结构类型 | 当前项目 |
|
|
||||||
|---|---|---|
|
|
||||||
| mind_map.xmind / mind_map_legacy.xmind | org.xmind.ui.map.clockwise | 基础思维图 |
|
|
||||||
| logic_chart.xmind | org.xmind.ui.logic.right | 向右逻辑 |
|
|
||||||
| org_chart.xmind | org.xmind.ui.org-chart.down | 向下组织 |
|
|
||||||
| brace_map.xmind | org.xmind.ui.brace.right | 基础括号布局,导出保留结构类型 |
|
|
||||||
| fishbone.xmind | org.xmind.ui.fishbone.leftHeaded | 基础鱼骨布局,导出保留结构类型 |
|
|
||||||
| timeline.xmind | org.xmind.ui.timeline.sided.horizontal | 基础水平时间轴,导出保留结构类型 |
|
|
||||||
| tree_chart.xmind | org.xmind.ui.timeline.vertical | 基础垂直时间轴,导出保留结构类型 |
|
|
||||||
| tree_table.xmind | org.xmind.ui.treetable.toptitle | 基础顶标题树形表格,导出保留结构类型 |
|
|
||||||
| coordinating_upcoming_release_1.0.xmind | org.xmind.ui.treetable | 基础侧标题树形表格,导出保留结构类型 |
|
|
||||||
| mood_tracker.xmind | org.xmind.ui.spreadsheet | 基础矩阵;读取并导出列扩展和单元格标签 |
|
|
||||||
|
|
||||||
实际浏览器测试导入这 9 个样例,随后导出,逐工作表比对所有主题标题和数量,全部通过。鱼骨图、水平/垂直时间轴、括号图和树形表格另检查实际特殊结构渲染及导出结构类型。测试不证明所有细节已与原生像素一致,也不证明原生应用打开兼容性。
|
|
||||||
|
|
||||||
2026-09-24 追加从安装包提取的结构样例核验:`mood_tracker.xmind`、`coordinating_upcoming_release_1.0.xmind`、`tree_table.xmind`、`film_production.xmind` 均通过导入后再导出的主题标题/数量比对。矩阵的列扩展和标签映射、侧标题表格的根表头与跨行几何另有合成文件浏览器测试。
|
|
||||||
|
|
||||||
## 新版功能线索
|
|
||||||
|
|
||||||
中文文案字典从 3096 项变为 3167 项:新增 84 项、移除 13 项、修改 1 项。新增内容涉及浮动工具栏显示策略、主题添加按钮、编辑器偏好、默认中日韩字体、默认缩放、一周起始日、快捷输入启动设置、专区和打印选项。
|
|
||||||
|
|
||||||
这些是安装包中的功能线索,仍需原生交互核对后才能认定具体行为或版本差异。原生应用界面连接当前超时,未完成界面实测;不以文案存在代替功能验收。
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
# XMind 复刻功能对照
|
|
||||||
|
|
||||||
目标:以用户提供的 XMind 26.05.01107 macOS 安装包为最新参照,结合此前 XMind 26.04 桌面体验,完成全部功能能力的逐项实现与验证。保留项目自有品牌。不以界面相似或少量测试通过宣称完整复刻。安装包核验见 [26.05 对照记录](./XMind-26.05-安装包核验.md)。
|
|
||||||
|
|
||||||
参考入口:[官方功能矩阵](https://xmind.app/pricing/_feature_matrix/)、[功能总览](https://xmind.com/features)、[结构](https://xmind.com/structure)、[文件管理](https://xmind.com/user-guide/working-with-files)、[大纲](https://xmind.com/user-guide/outliner-new)。在线版本会变化;原生 XMind 行为和实际文件仍需逐项对照。
|
|
||||||
|
|
||||||
状态:基础 = 已有可用实现但未达到完整对等;进行中 = 正在实现;待实现/核验 = 不得视作支持。
|
|
||||||
|
|
||||||
| 能力 | 当前状态 | 剩余验收 |
|
|
||||||
|---|---|---|
|
|
||||||
| 编辑器布局、结构图标、工作表栏 | 基础 | 逐屏视觉对照、窄屏/高 DPI/暗色模式 |
|
|
||||||
| 主题/子主题/同级主题、键盘编辑 | 基础 | 完整快捷键矩阵、混合选区、批量操作 |
|
|
||||||
| 选择、拖动、重排、自由主题 | 底层已有,部分核验 | 跨分支拖拽、拆分合并、对齐、吸附 |
|
|
||||||
| 撤销/重做 | 基础 | 所有新命令均需单独验证历史边界 |
|
|
||||||
| 思维图、左右逻辑、上下组织、目录 | 基础 | 子树混合布局、手动布局行为对照 |
|
|
||||||
| 鱼骨图、时间轴、树形表格、矩阵、括号图 | 部分基础 | 已有左头鱼骨图、双侧水平时间轴、垂直时间轴、顶标题/侧标题树形表格、矩阵和向右括号图;待补方向变体、穿越式时间轴、子树混合布局和更精细的原生视觉对照 |
|
|
||||||
| 分支/节点/文字格式、主题配色 | 基础 | 全形状、线型、彩虹、字体、富文本、全主题 |
|
|
||||||
| 联系线 | 基础 | 控制点编辑、箭头、虚线、复杂布局、双向联系 |
|
|
||||||
| 外框 | 基础 | 标题、全形状、嵌套、填充、范围调整 |
|
|
||||||
| 概要 | 基础 | 范围调整、多重概要、完整样式 |
|
|
||||||
| 图标/标记、贴纸 | 原生标记可用,贴纸/插画待实现 | 自定义标记、图例交互及贴纸/插画库 |
|
|
||||||
| 备注、外链、主题链接、标签 | 基础 | 已增加富文本笔记(加粗、斜体、下划线、删除线、列表、缩进、链接),写入客户端最新 realHTML 和 plain 格式并保留旧段落格式;支持笔记提示、撤销和项目内往返。标签和外链编辑、工作表切换已验证;待补备注图片、跨画布链接和真实云端保存重载 |
|
|
||||||
| 图片、附件、音频、公式 | 部分支持 | 新旧 XMind 内嵌 PNG/JPEG/GIF/WebP 导入;附件新增/替换/下载/移除/撤销。待补 SVG、音频、公式、资源导出和真实样本覆盖 |
|
|
||||||
| 任务、待办、进度、日期、负责人、甘特图 | 任务及甘特图基础已实现 | 任务开始/截止日期、日级工期、进度、优先级、多负责人、四种依赖;甘特图拖动/拉伸、联动排期、批次撤销、汇总任务、折叠、筛选、缩放、今天定位、CSV 和浏览器打印;原生任务扩展导入导出。待办模型、工作日日历、小时级/里程碑、原生全部导出与团队成员服务待补 |
|
|
||||||
| 查找替换、过滤、聚焦、导航 | 查找替换基础 | 已验证折叠后代、当前/全部画布范围、跨工作表定位、替换批量撤销/重做和跨富文本叶替换;过滤、分支聚焦待补 |
|
|
||||||
| 大纲视图 | 基础 | 已验证画布双向改名、新增/删除、缩进/提升、拖动重排、展开折叠、缩放和撤销;待补混合富文本、批量选区、分支聚焦、大纲专用导出和原生视觉对照 |
|
|
||||||
| 浮动工具栏、主题添加按钮 | 待新版对照 | 26.05 安装包有新增编辑体验文案,需按原生行为补齐 |
|
|
||||||
| 编辑器偏好设置 | 待实现 | 默认缩放、默认中日韩字体、一周起始日、快捷输入启动偏好 |
|
|
||||||
| ZEN、演示/Pitch、分步展示 | ZEN / 演示基础 | 已有隐藏编辑工具、Escape 恢复和主题演示前后页;完整 Pitch 模板、分步动画、演示导出待补 |
|
|
||||||
| 多工作表新建/切换/重命名/复制/删除 | 基础 | 跨工作表移动/链接、合并 XMind 文件 |
|
|
||||||
| 云文件、自动保存 | 基础 | 离线恢复、版本历史、异常恢复与冲突 |
|
|
||||||
| XMind 新版 JSON / 旧版 XML 导入 | 基础 | 资源、全部样式、结构和扩展内容;真实样本库 |
|
|
||||||
| MD、OPML、FreeMind 导入 | 基础 | 更多格式与大文件性能 |
|
|
||||||
| XMind 导出与往返兼容 | 基础,指定原生往返样例已核验 | 本机 26.04 实际打开网页导出文件、修改字号并保存回传,指定主题的字号/纯文本备注/附件字节保留;全结构、复杂样式与混合富文本的原生无损往返仍待核验 |
|
|
||||||
| PNG/SVG/PDF、Markdown/OPML、Office 导出 | PNG 已支持;其余待核验 | 当前工作表全图 PNG,1/2/3 倍清晰度、画布背景或透明背景;选区、多页和其他格式待补 |
|
|
||||||
| 打印、密码、缓存、文件恢复 | 待实现 | 不得以浏览器存储代替完整文件保护 |
|
|
||||||
| 模板、主题库、手绘风格 | 待实现/部分底层能力 | 内置模板、自定义、导入与复用 |
|
|
||||||
| 协作、评论、分享、权限 | 待实现 | 项目已有登录不等于实时协作完成 |
|
|
||||||
| AI 生成、总结、重组、扩写 | 待实现 | 接入项目模型能力、编辑历史、可靠保存 |
|
|
||||||
| 无障碍、多语言、性能 | 待专项核验 | 键盘/读屏、长文、1 万主题、资源上限 |
|
|
||||||
|
|
||||||
## 已有验证入口
|
|
||||||
|
|
||||||
- `pnpm exec playwright test --config playwright.flow-board.config.ts`:快照兼容、工作表隔离、关系/外框几何与选区规则。
|
|
||||||
- `pnpm exec playwright test --config playwright.flow-board-ui.config.ts`:真实浏览器导入、编辑、撤销、工作表切换。
|
|
||||||
- `pnpm exec tsc --noEmit --pretty false`:类型检查。
|
|
||||||
- 开发预览:`/dev/flow-board-preview`,只在开发环境开放。预览内容不等同于云端持久化。
|
|
||||||
|
|
||||||
## 2026-09-18 主题内容验收
|
|
||||||
|
|
||||||
- 11 项数据测试、7 项浏览器测试、TypeScript 检查通过。
|
|
||||||
- 新版 JSON 和旧版 XML 的备注、标签、外链、内嵌图片已验证;附件下载内容与原文件一致。
|
|
||||||
- 修复折叠后代主题没有渲染路径导致链接跳转异常的问题:按数据树路径展开祖先后定位。
|
|
||||||
- 资源限制:XMind 文件 50 MB、单资源/新增附件 8 MB、导入内嵌资源累计 24 MB;缺失或不支持的图片会给出提示并保留主题。外部图片不自动下载。
|
|
||||||
- 当前回归使用生成的测试文件。原生 XMind 实际文件库、真实账号云端保存重载和导出往返仍待核验,不能据此认定完全兼容。
|
|
||||||
|
|
||||||
## 验收约束
|
|
||||||
|
|
||||||
每项至少核验:创建、编辑、删除、保存重载、撤销重做、工作表切换、导入导出兼容;适用时增加原生 XMind 对照。未提供证据的项保持未完成。所有目前实现仍是部分能力,完整目标持续有效。
|
|
||||||
|
|
||||||
## 2026-09-18 XMind 导出验收
|
|
||||||
|
|
||||||
- 工具栏新增“导出 XMind”。按现代 XMind 文件包写入 content.json、metadata.json、manifest.json 和内嵌资源;文件包结构参考官方模型以及本机 XMind 自带 mind_map.xmind 样本。
|
|
||||||
- 14 项数据测试、8 项浏览器测试、TypeScript 和本轮新增文件 lint 检查通过。项目内往返覆盖多工作表、当前编辑内容、显式字号/粗体、备注/标签、主题链接、概要、外框、联系线、自由主题、图片和附件。图片与附件字节一致。
|
|
||||||
- 导出会重新分配主题 ID 并同步引用,避免重复工作表产生 ID 冲突。当前工作表以实时画布内容为准。
|
|
||||||
- 尚未达到原生无损往返:原生界面连接超时,未完成 XMind 打开/再次保存的实机验证;自动主题配色、混合富文本、部分标记、特殊布局、任意画布元素和联系线控制点仍有差距。同一主题同时含附件和链接时,目前优先附件并明确提示。
|
|
||||||
- 不支持的内容会显示导出提示;无法读取的资源、没有主题的工作表会明确报错,不生成看似成功的空文件。
|
|
||||||
|
|
||||||
## 2026-09-18 大纲与原生样例验收
|
|
||||||
|
|
||||||
- 19 项数据测试、10 项浏览器测试通过(浏览器运行设置 `XMIND_NATIVE_FIXTURES=/tmp/xmind-26.05-reference`),另验证大纲缩放、空画布创建中心主题和 Escape 取消改名。类型检查通过。
|
|
||||||
- 大纲与画布共享主题、备注、历史和工作表数据;支持 Enter 新增同级、Shift+Enter 新增子主题、Tab/Shift+Tab 层级调整、Alt+上下移动,以及拖动到主题前/后/内部。
|
|
||||||
- 重排保留 ID、备注、图片和联系线引用;概要随保留的主题调整范围。会拆散概要范围的移动暂时明确拒绝,待补原生范围重排行为。
|
|
||||||
- 用户安装包中的 9 个基础样例均完成“导入 → 导出”主题标题/数量比对,没有缺少主题;此项不证明布局和样式相同。此阶段鱼骨图、括号图、时间轴、树形表格尚未完成;后续进展见下文。
|
|
||||||
- 原生界面连接两次超时,原生视觉与导出文件打开验证仍未通过;不影响继续开发其他差距。
|
|
||||||
|
|
||||||
## 2026-09-18 特殊布局验收
|
|
||||||
|
|
||||||
- 新增左头鱼骨图、双侧水平时间轴、向右括号图;结构面板使用独立 SVG 图标。整棵分支参与布局计算,为长标题和后代预留空间,根概要放在分支列之外。
|
|
||||||
- 新版 XMind 导入识别这三种根结构,导出保留对应 `structureClass`。通过 Drawnix 初始插件入口安装自定义渲染组件,导入首次显示即使用对应布局。
|
|
||||||
- 23 项数据测试、11 项浏览器测试全部通过,包括安装包 9 个基础样例;TypeScript、五个本轮新文件的 Biome 检查通过。特殊布局测试覆盖实际坐标、连接线、改名、新增子主题、联系线保留、切换、撤销/重做和导出结构。
|
|
||||||
- 修复新增主题未自动获得键盘焦点的问题,以及括号图切回普通结构时深层连线残留的问题。
|
|
||||||
- 仍是基础实现:方向变体、垂直时间轴、树形表格、矩阵、子树混合结构、复杂联系线避障和原生视觉精确对照待补;原生应用打开导出文件仍未验证。真实账号保存重载也需另行验收。
|
|
||||||
|
|
||||||
## 2026-09-24 垂直时间轴与树形表格验收
|
|
||||||
|
|
||||||
- 新增垂直时间轴:根轴向下延伸,一级主题左右交替,整棵子树随所属方向镜像;新增主题、结构切换、撤销、工作表切换和 XMind 往返均有浏览器测试。
|
|
||||||
- 新增顶标题树形表格:中心主题跨列表头,层级按列排列,父主题纵向跨越全部后代行;表格单元格位于主题内容下层,不拦截选择和编辑。原生 `tree_table.xmind` 的主题配色、表头和网格视觉已用于对照。
|
|
||||||
- 新版导入和导出保留 `org.xmind.ui.timeline.vertical` 与 `org.xmind.ui.treetable.toptitle`;结构面板新增对应 SVG 图标。
|
|
||||||
- 25 项数据测试通过;13 项浏览器用例中新增树形表格用例,并再次通过安装包 9 个基础样例。TypeScript 和本轮文件 Biome 检查通过。浏览器预览测试改用 Webpack,避开 Next 16 Turbopack 在迁移后的中文目录中出现的包解析崩溃。
|
|
||||||
- 该阶段尚未完成侧标题树形表格和矩阵;后续进展见下文。时间轴方向变体、子树混合结构、复杂主题样式和原生应用打开导出文件的实机验证仍待补齐。
|
|
||||||
|
|
||||||
## 2026-09-24 侧标题树形表格与矩阵验收
|
|
||||||
|
|
||||||
- 新增侧标题树形表格:中心主题成为跨全部叶子行的左侧表头,后续层级按列展开,父主题跨越其后代行。导入、导出保留 `org.xmind.ui.treetable`。
|
|
||||||
- 新增矩阵:读取 `org.xmind.ui.spreadsheet` 根扩展中的列名,并按子主题 `labels` 放入对应单元格;同一单元格的多个主题纵向堆叠。矩阵列定义和主题标签可随 XMind 导出、再次导入。
|
|
||||||
- 结构面板增加两枚独立 SVG 图标;表格主题使用整格几何参与命中和画布适配。修复 8 位 XMind 色值、原生圆角主题边框残留和新增长文本裁切。
|
|
||||||
- 27 项数据测试、15 项浏览器测试和 TypeScript 检查通过;全量浏览器回归再次覆盖安装包的 9 个基础样例。另用安装包中的 `mood_tracker.xmind`、`coordinating_upcoming_release_1.0.xmind`、`tree_table.xmind`、`film_production.xmind` 完成主题完整性导入导出验证。
|
|
||||||
- 当前矩阵支持 XMind 的列扩展和标签映射;列增删/改名的专用交互、复杂嵌套子树、原生应用打开导出文件和逐像素多主题对照仍待实现。
|
|
||||||
|
|
||||||
|
|
||||||
## 2026-10-06 笔记与甘特图补齐
|
|
||||||
|
|
||||||
- 标记功能已先提交到本地 Git:`e6a68b34`。
|
|
||||||
- 富文本笔记使用现有 Slate 编辑能力,支持选中文字或继续输入时的格式、列表、缩进及安全链接;画布笔记提示可直接打开编辑。读取现代 `realHTML`、旧 `html.content.paragraphs` 与纯文本笔记,导出同时写入 `realHTML`/`plain` 和旧格式。格式依据已安装客户端 `6996.js` 的 Converter 核对。
|
|
||||||
- 任务写入原生 `org.xmind.ui.task` 扩展;日期使用本地日历日边界,进度存为 0–1,工期和依赖间隔存为毫秒。导入/导出重映射依赖主题 ID,保留其他原生扩展。
|
|
||||||
- 甘特图与画布共用主题数据及历史。可添加/编辑/移除任务、按前置关系排期、拒绝循环或父子依赖、拖动/拉伸时间条、按任务名称/负责人/进度筛选,支持汇总任务范围/进度、缩放、定位今天、CSV 导出和浏览器打印。移除任务时清理其他任务的引用,一次撤销恢复全部受影响任务。
|
|
||||||
- 本轮 9 项笔记/任务/导出数据检查、15 项浏览器检查(分批运行)、TypeScript 和相关文件 Biome 检查通过。浏览器覆盖富文本选区/列表/链接/撤销、任务日期/依赖/拖动/拉伸、校验/CSV/工作表隔离,以及原有图片粘贴、标记、框选、主题内容和导出。首次整批运行的空白画布导出用例超时,关闭追踪录制后单独复测通过;检查日志保留在本地验证目录。正式云账号保存重载没有在本轮验证。
|
|
||||||
- 已在安装的 XMind 26.04 中打开网页导出文件:笔记浮窗显示有序列表、加粗和蓝色链接,原生甘特图显示两条时间条、日期/工期/负责人和前置关系箭头。网页时间条改为客户端的橙色胶囊外观,显示任务名与负责人提示,选中行和时间条同步高亮。
|
|
||||||
- 修复笔记快捷键被画布拦截及快速选区格式不同步的问题;笔记、侧栏输入和任务窗口的键盘/剪贴板事件各自处理,编辑时保留当前主题。
|
|
||||||
- 当前是可用的富文本笔记和日级甘特图能力,未宣称与客户端全部功能或像素完全一致。高级工作日日历、小时/里程碑、原生全部导出格式、团队成员服务及笔记图片仍需继续补齐。
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# Canvas image downloads through Qiniu CDN
|
|
||||||
|
|
||||||
Generation still returns the provider preview first and archives the original asynchronously, without model admission limits. Archiving and downloads share a one-hour disk cache and a single in-flight source request. The cache hashes identifiers, isolates authenticated same-origin responses by cookie digest, uses private files, and is expendable.
|
|
||||||
|
|
||||||
After storing the original, ImageArchiveTask.metadata.downloadAsset queues PNG preparation independently from URL replacement. Compatible 8-bit RGB/RGBA PNG files are reused byte-for-byte; other images are normalized once without resizing. Failures retry independently with leases and do not revert the original. Older archived images are prepared on demand. The download endpoint remains authenticated and supports its original binary contract. Clients opt into a small JSON response for ready PNG objects from the configured Qiniu origin; the browser then downloads directly using Kodo's attname filename parameter. Unready files use the shared cache and binary fallback.
|
|
||||||
|
|
||||||
Production settings (no credentials):
|
|
||||||
|
|
||||||
- Qiniu bucket: gongxiangpan-sc
|
|
||||||
- StorageConfig.publicUrl: https://scqnycdn.jiansun.vip
|
|
||||||
- CANVAS_DOWNLOAD_CDN_ORIGIN=https://scqnycdn.jiansun.vip (download iframe CSP)
|
|
||||||
- NEXT_PUBLIC_CANVAS_CDN_ORIGIN=https://scqnycdn.jiansun.vip (canvas display delivery; requires build)
|
|
||||||
- CANVAS_QINIU_ORIGIN_URL=https://scqny.jiansun.vip (map older ready originals to the same bucket's CDN)
|
|
||||||
|
|
||||||
The CDN keeps query parameters in its cache key so image processing and attname filenames cannot collide. HTTPS, HTTP/2, byte ranges and CORS response headers are enabled. Existing source URLs are retained. Credentials stay in the configured storage/management credentials and must never be committed.
|
|
||||||
|
|
||||||
Batch ZIPs reuse prepared bytes, use STORE for compressed image/video files, and stream the archive. Task ZIP files expire and reserve at least 1 GiB of server disk space. Files remain available for authenticated task owners only.
|
|
||||||
|
|
||||||
Validation: pnpm exec playwright test -c tests/canvas-download-speed.config.ts and pnpm exec tsc --noEmit. Build off-server with webpack; deploy only against the current production source to retain flow-board updates. Rollback needs the previous build, environment file and StorageConfig.publicUrl.
|
|
||||||
@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
本文档用于生产或准生产环境部署。所有真实密钥只应配置在运行环境中,不要提交到 Git。
|
本文档用于生产或准生产环境部署。所有真实密钥只应配置在运行环境中,不要提交到 Git。
|
||||||
|
|
||||||
当前 `ai.yydd.ai` 的 48 / PM2 生产环境使用 [48 生产发布流程](./production-48.md)。
|
|
||||||
下方是通用安装说明,不能直接在当前生产目录执行迁移或构建。
|
|
||||||
|
|
||||||
## 运行环境
|
## 运行环境
|
||||||
|
|
||||||
- Node.js 20
|
- Node.js 20
|
||||||
|
|||||||
@ -1,65 +0,0 @@
|
|||||||
# 风格化“详情替换”长时间不出结果(2026-09-26)
|
|
||||||
|
|
||||||
## 现场证据
|
|
||||||
|
|
||||||
用户截图中的风格化项目已保存 33 张目标详情参考图,但输入图区为 0 张、结果图为 0 张。卡片上的 96% 不是服务商进度:`DetailResultProcessingScanOverlay` 按 240 秒把时间线性映射成百分比,并用 `Math.min(96, ...)` 封顶,所以只要没收到结果,它就会一直停在 96%。
|
|
||||||
|
|
||||||
生成请求走的是浏览器保持的对话流(`/api/chat` 的 SSE)。切换标签页、断网或连接中断后服务端结果无法回传,前端那条“处理中”的状态又存在浏览器里,重新打开页面会继续播放假进度,因此表现为“挂几个小时”。
|
|
||||||
|
|
||||||
另外确认了一个独立故障:详情替换专用模型绑定为 `gpt-image-2.5-flare`,该型号当时持续返回 HTTP 404;普通 `gpt-image-2.5` 有成功出图记录。2026-09-24 17:45 已把线上 `SystemSettings.detail_replacement_image_model_config.modelId` 热更新为 `gpt-image-2.5`。
|
|
||||||
|
|
||||||
上线前线上 `CanvasImageTask` 中 `taskKind = 'agent-detail-replacement-batch'` 的行数为 0,即任务从未进过服务端队列,符合“没有可恢复任务、只能挂假进度”的判断。
|
|
||||||
|
|
||||||
## 修复
|
|
||||||
|
|
||||||
- 详情替换改为服务器端后台批次:`POST /api/agents/detail-replacement/batches` 先落 `CanvasImageTask`,由 `lib/agents/detail-replacement-batch-runner.ts` 按参考图逐张生成,每张结果即时写回项目(`persistAgentDetailReplacementCard`)并更新任务进度。
|
|
||||||
- 页面不再依赖长连接:卡片状态来自任务查询(3 秒轮询 + 回到前台重新校验),刷新、切换标签页、换浏览器都能按任务状态恢复;没有后端批次时不再显示“处理中”。
|
|
||||||
- 删除按时间爬到 96% 的假进度动画,进度改为真实的排队 / 生成中 / 已完成 / 失败。
|
|
||||||
- 提交前校验:没有产品输入图直接拦截提示补图;参考图必须与项目已保存的一致;配额与积分不足在提交阶段返回明确错误。
|
|
||||||
- 服务重启恢复:`instrumentation.ts` 启动 5 秒后以及之后每 60 秒调用 `resumeDetailReplacementBatchTasks()`,把 `pending` 或心跳停滞(>90 秒无 `updatedAt`)的批次重新入队;运行期间每 30 秒写心跳。
|
|
||||||
- 长批次不再被清理规则误判:详情替换的运行超时单独放宽到 3 小时,`markStaleDatabaseRunningTasksFailed` 与 `getCanvasImageTaskForUser` 都排除该任务类型。
|
|
||||||
- 单张失败只落到该卡片并继续后续图片;已写入项目的卡片在恢复时按批次号 + 卡片号认领,不重复调用模型、不重复记账。
|
|
||||||
- 去重按“同一份提交内容”而不是“同一项目”:`submissionKey` 覆盖参考图集合、提示词、模型、尺寸等,配合局部唯一索引 `CanvasImageTask_detail_batch_submission_key` 拦住重复点击;单张重新生图因为卡片集合不同,不会被吞掉。
|
|
||||||
- 详情替换默认模型由 `gpt-image-2.5-flare` 改回 `gpt-image-2.5`(`lib/ai/detail-replacement-image-model-config.ts` + 迁移 `0101`),并保留前端传参路径不变。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
- 服务端链路端到端验证(生产库,纯沿用卡片,不调用模型、不消耗积分):直接插入 `pending` 批次后,恢复循环在 60 秒内接手,任务转为 `succeeded`,两张卡片写入项目 `replacementResultState.detail_replacement`,聊天记录写入 2 条对应结果消息;验证数据随后全部删除,线上无残留。
|
|
||||||
- 重启幂等验证:把同一任务改回 `pending`,恢复后 `attempts` 1 → 2,已完成卡片被跳过,结果卡片与消息条数不变,确认不会重复生成、重复记账。
|
|
||||||
- 本地 `tsc --noEmit`、Next.js 生产构建、`git diff --check` 通过;`tests/routes/detail-replacement-batch.test.ts` 与 `tests/routes/replacement-product-identity.test.ts` 共 12 项通过,覆盖批次识别、卡片消息 ID 稳定性、重启跳过已完成卡片、提交键去重与单张重跑区分。
|
|
||||||
|
|
||||||
## 发布
|
|
||||||
|
|
||||||
2026-09-26 02:45 发布至 45.192.99.48 / ai.yydd.ai。生产代码提交 `0907aaff`,构建目录 `.next-detail-batch-0907aaff-20260926T023939`(BUILD_ID `0wcSfql6ZB2g8EzNEP6Gy`)。切换 `.next` 符号链接后 `pm2 restart ai-chatbot`。
|
|
||||||
|
|
||||||
回滚点记录在服务器 `/root/detail-batch-rollback.txt`:旧提交 `1c2acee5`、旧构建 `.next-video-home-439af60c-20260924`。回滚只需把 `.next` 指回旧目录并重启。
|
|
||||||
|
|
||||||
服务器构建使用 `NEXT_IGNORE_BUILD_TYPECHECK=1`:该机器上遗留的 `backups/next-before-vip98k-0911/types/validator.ts` 会被 `tsconfig` 的 `**/*.ts` 扫到,它的相对导入在该目录下必然失效;同一提交已在本地跑通完整类型检查与构建。
|
|
||||||
|
|
||||||
线上检查:登录页 200、首页 200、未登录访问 `/api/agents/detail-replacement/batches` 返回 401;数据库去重索引已建,`CanvasImageTask` 无遗留批次。分支 `codex/detail-recovery-0924` 已推送 Git。
|
|
||||||
|
|
||||||
发布时经确认近 3 小时无任何生成任务、近 1 小时无新消息,重启未打断在途生成。
|
|
||||||
|
|
||||||
## 追加:上线后详情替换完全不可用(2026-09-27)
|
|
||||||
|
|
||||||
现场:用户点“参考图替换”弹出「任务编号或项目编号无效」,结果区一直是 0/33。nginx 日志显示同一个用户对 `/api/agents/detail-replacement/batches` 的请求全部 400——GET 返回 30 字节(`{"error":"项目编号无效"}`),POST 返回 45 字节(`{"error":"任务编号或项目编号无效"}`),且 GET 请求里的 projectId 是合法 UUID。
|
|
||||||
|
|
||||||
根因:新增批次路由自带的 UUID 正则漏写一组 `-{4}`,写成了 8-4-4-12,而标准 UUID 是 8-4-4-4-12。结果是**任何**真实编号都过不了校验,包括浏览器 `generateUUID()` 生成的 requestId,所以查询和提交在参数校验阶段就全部返回 400,页面自然拿不到任务状态。仓库里其他位置用的都是正确的 8-4-4-4-12,只有这个新文件写错。
|
|
||||||
|
|
||||||
为什么上一轮没发现:上一轮的端到端验证是直接往 `CanvasImageTask` 插入任务再观察恢复循环,绕过了 HTTP 层,而编号校验只存在于 HTTP 层。这是一次验证覆盖不足,不是环境问题。
|
|
||||||
|
|
||||||
修复:
|
|
||||||
|
|
||||||
- 校验抽成 `isDetailReplacementUuid` 并修正为规范的 8-4-4-4-12,路由不再自带正则。
|
|
||||||
- 回归测试覆盖真实线上项目编号、浏览器生成的请求编号,以及导致故障的 8-4-4-12 形状;另加源码守卫,禁止该路由再次出现自带的错误 UUID 正则。
|
|
||||||
- 前端在项目尚未保存时直接提示“请先保存项目后再执行详情替换”,不再把空编号丢给接口。
|
|
||||||
|
|
||||||
验证(这次走真实 HTTP,不再是绕过接口的数据库插桩):用一次性 QA 账号(`activeSessionKey` 与 JWT 会话)和一次性 agent 项目请求线上接口,验证后立即删除,不触碰任何真实账号数据。
|
|
||||||
|
|
||||||
- `GET ?projectId=<真实项目 UUID>` → 200 `{"task":null}`(修复前恒为 400,即用户遇到的现场)。
|
|
||||||
- `GET ?projectId=not-a-uuid` → 400「项目编号无效」,编号校验仍然生效。
|
|
||||||
- `POST` 合法 projectId/requestId → 通过编号校验并进入后续业务校验(返回“详情参考图尚未保存或已发生变化”),说明提交链路已恢复。
|
|
||||||
- `POST` 非法 requestId → 400「任务编号或项目编号无效」,非法输入仍被拒绝。
|
|
||||||
- QA 账号、项目、临时脚本全部删除,线上无残留。
|
|
||||||
|
|
||||||
发布:2026-09-27 19:16 发布至 45.192.99.48 / ai.yydd.ai,提交 `f75d99ba`,构建目录 `.next-detail-batch-f75d99ba-20260927T191533`(BUILD_ID `f1ghXv5zLVb8BQv8a-ifE`)。回滚点为上一构建 `.next-detail-batch-0907aaff-20260926T023939`。
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
# 48 生产发布
|
|
||||||
|
|
||||||
本流程由维护电脑发起,使用已有 SSH 密钥。生产只更新 `ai-chatbot`,不运行数据库迁移,
|
|
||||||
不更新 ZCode 原生运行时,不重启主机、Nginx 或其他 PM2 应用。
|
|
||||||
|
|
||||||
## 已核实的机器
|
|
||||||
|
|
||||||
| 用途 | SSH 入口 | 主机 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 构建机 | `root@192.168.102.146:22` | `dao02-Default-string`,Ubuntu 24.04,Node 20 |
|
|
||||||
| 同一构建机的公网入口 | `root@45.192.99.48:23322` | FRP 转发,不是生产宿主机 |
|
|
||||||
| 生产机 | `root@45.192.99.48:64192` | `SfSTE0ttmsycO`,CentOS Stream 9,Node 20 |
|
|
||||||
| 维护连接跳板 | `root@192.168.58.20:22` | 已信任的 NAS,仅转发 SSH |
|
|
||||||
|
|
||||||
公网页面 `https://ai.yydd.ai/` 由生产 Nginx 转发至 `127.0.0.1:3125` 的 PM2 应用。
|
|
||||||
脚本固定验证生产、构建机的 SSH 指纹和主机名;私钥留在维护电脑,不转发 SSH Agent。
|
|
||||||
构建机原有 `/var/www/ai-chatbot` 是旧实例,本流程不会使用或覆盖它。
|
|
||||||
|
|
||||||
## 操作入口
|
|
||||||
|
|
||||||
维护电脑需要 Python 3.9+、Paramiko 3.3+、Git,以及已信任的 SSH `known_hosts`。
|
|
||||||
默认使用 `~/.ssh/45.192.99.48.pem` 和跳板密钥 `~/.ssh/youyidian_prod_ed25519`。
|
|
||||||
可用 `--key`、`--jump-key`、`--known-hosts` 覆盖本机路径,不要把私钥提交到仓库。
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python scripts/production48.py status
|
|
||||||
python scripts/production48.py jobs
|
|
||||||
|
|
||||||
# 完成 PR 合并后,确认远端 main 的最新提交,工作区必须干净。
|
|
||||||
git fetch origin
|
|
||||||
git switch main
|
|
||||||
git merge --ff-only origin/main
|
|
||||||
python scripts/production48.py build
|
|
||||||
```
|
|
||||||
|
|
||||||
`build` 将确定的 `origin/main` 提交通过 `git archive` 送到构建机,
|
|
||||||
在 `/opt/ai-chatbot-builds/<时间-提交>/source` 安装锁定依赖并构建。
|
|
||||||
使用 pnpm 9.12.3,不改变全局 pnpm。安装时不运行依赖脚本,构建时禁用自动安装系统服务。
|
|
||||||
构建保持 TypeScript 检查,限制 Next 构建并行度和 Node 内存,并降低进程优先级。
|
|
||||||
|
|
||||||
构建机仅收到 `NEXT_PUBLIC_*` 等公开页面配置,以及指向本机关闭端口的占位数据库配置。
|
|
||||||
不会收到生产数据库密码、AI Key、认证密钥或存储凭据。随后在构建机回环地址启动隔离候选实例,
|
|
||||||
核对构建 ID 并执行公开路由烟测,测试结束自动关闭该候选实例。
|
|
||||||
|
|
||||||
成功后会输出发布 ID,例如 `20261030T120000Z-0123456789ab`。后续步骤使用这个完整 ID:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python scripts/production48.py stage --release <发布ID>
|
|
||||||
python scripts/production48.py activate --release <发布ID>
|
|
||||||
python scripts/production48.py status
|
|
||||||
|
|
||||||
# 仅对当前生效的这一版执行回滚:
|
|
||||||
python scripts/production48.py rollback --release <发布ID>
|
|
||||||
```
|
|
||||||
|
|
||||||
`stage` 校验传输 SHA256,在 `/var/www/ai-chatbot-releases/<发布ID>` 解包。
|
|
||||||
它检查压缩包路径、磁盘容量、锁文件、实际依赖文件/链接清单和 public 文件校验和。
|
|
||||||
完成解包后至少保留 1 GiB 空间;不足时停止,不自动删除历史构建。
|
|
||||||
当前和旧浏览器标签页所需的静态资源通过硬链接保留,不改动旧构建。
|
|
||||||
|
|
||||||
`activate` 会重新核对生产版本和任务状态,拒绝在最近一小时仍有未结束生成任务时切换。
|
|
||||||
更早的历史挂起记录只写入发布回执,不会自动修改数据库中的状态。
|
|
||||||
它保存旧 PM2 配置和启动快照,然后仅重建 `ai-chatbot` 这一项 PM2 定义,
|
|
||||||
让新版本的工作目录、可执行路径和环境变量全部生效,不改变其他应用,
|
|
||||||
因此会有短暂切换窗口。健康检查同时核对进程目录、构建 ID、首页和路由。
|
|
||||||
检查失败自动恢复旧配置并再次验活;通过后执行 `pm2 save`,让重启后使用已验收版本。
|
|
||||||
|
|
||||||
PM2 的普通 restart 会保留旧工作目录和程序路径,因此不能用它替代本流程的单应用定义重建。
|
|
||||||
若一次切换已经明确回滚并验证原版本健康,修复发布工具后,可保留已校验制品并显式重试:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python scripts/production48.py activate --release <发布ID> --retry-rolled-back
|
|
||||||
```
|
|
||||||
|
|
||||||
只有日志阶段为 `rolled-back` 且当前工作目录/构建目录仍匹配原版本才允许重试。
|
|
||||||
旧尝试日志另外保留。运行制品的 `sourceCommit` 始终指向当时实际构建的 main 提交,
|
|
||||||
后续工具修复不改写这个来源;`operatorCommit` 单独记录执行切换的维护工具版本。
|
|
||||||
|
|
||||||
## 运行资料与边界
|
|
||||||
|
|
||||||
每个发布目录包含构建机按锁文件安装的完整运行依赖和 public 源码资源。
|
|
||||||
节点之间不复用 node_modules 链接,避免服务器漏装依赖补丁却被误认为同一版本。
|
|
||||||
对内容完全相同的依赖文件,通过 XFS 的写入时复制创建独立 inode,复用底层数据块;
|
|
||||||
缺失或不同的文件从已校验制品写入。任何一份副本后续修改均不会改动另一份。
|
|
||||||
生产文件系统不支持该操作时停止,不自动回退成可能耗尽磁盘的完整复制。
|
|
||||||
public 先从当前运行版本创建写入时复制副本,保留未纳入 Git 的运行资源,再叠加本次源码资源。
|
|
||||||
完整文件和链接清单在切换前再次验证,Node ABI 必须一致,并在生产机测试 sharp 原生模块。
|
|
||||||
|
|
||||||
原目录 `/var/www/ai-chatbot` 中的 `.env.local`、`tmp` 和模型配置备份仍通过链接使用,
|
|
||||||
不会复制、删除或覆盖。新版本也不在生产机执行依赖安装或构建。
|
|
||||||
本流程适用于应用代码迭代;数据库、共享资源的删除/迁移和原生 Agent 升级各自单独发布。
|
|
||||||
|
|
||||||
原生产 checkout 的支线及未提交改动仍保留,不能据其 Git HEAD 判断已发布版本。
|
|
||||||
实际发布证据是:运行 cwd、`.next/BUILD_ID`、该目录 `release.json` 和 `activation.json`。
|
|
||||||
维护电脑的 `.git/releases/<发布ID>/` 保留提交、构建日志、烟测、传输校验和及操作回执。
|
|
||||||
服务器回滚配置可能含环境元数据,目录权限为 0700、文件为 0600,不放入公开目录或 Git。
|
|
||||||
|
|
||||||
仓库历史全量 lint 仍有已记录的问题,不能声称全部质量门禁通过。
|
|
||||||
每次 PR 须记录本次改动的验证、历史失败和未覆盖项目;当前流程不会自动触发生产发布。
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
# 源码基线与发布约定
|
|
||||||
|
|
||||||
## 本次生产源码归档
|
|
||||||
|
|
||||||
2026 年 10 月 30 日完成只读核对:
|
|
||||||
|
|
||||||
- 归档前的 `main`:`e2a8f2dda3f190f78529b4419f295a356476c74b`。
|
|
||||||
- 原主线回退标签:`main-before-production-sync-20261030`。
|
|
||||||
- 生产源码分支:`codex/canvas-image-429-deploy-1001`。
|
|
||||||
- 生产源码提交:`439fd56fe120fdb9f855b50fa4df9ce2e71bb72a`。
|
|
||||||
- 实际运行构建 ID:`QhoygcZ4-x42PwCgCEj1d`。
|
|
||||||
- 生产目录另有 19 个已跟踪文件的修改和 6 个新增源码文件。
|
|
||||||
|
|
||||||
这些源码改动按原始内容归档在标签 `production-snapshot-48-20261030`,
|
|
||||||
25 个文件均经过 SHA256 校验。原始补丁和文件副本还保存在独立的本地备份中。
|
|
||||||
环境文件、模型配置备份和历史构建目录未加入源码归档。
|
|
||||||
|
|
||||||
源码归档标签表示核对时的服务器源码状态。实际运行的构建单独用构建 ID 标识,
|
|
||||||
不能仅凭服务器分支名或提交号认定某份构建已经部署。
|
|
||||||
|
|
||||||
接手分支 `codex/production-48-20261030` 在归档后只清理构建目录引用和仓库检查兼容性,
|
|
||||||
通过 PR 将已审查的生产源码同步回 `main`。本次源码同步不包含服务器部署或重启。
|
|
||||||
|
|
||||||
## 后续开发与合并
|
|
||||||
|
|
||||||
1. 从最新 `main` 创建任务分支,使用 `codex/<任务>` 命名。
|
|
||||||
2. 每次提交只暂存本次任务明确涉及的文件,检查暂存区差异和凭据。
|
|
||||||
3. 通过 PR 合入 `main`,记录验证结果;未通过或未执行的检查须写明原因。
|
|
||||||
4. 历史生产、试验和备份分支先保留。确认改动是否已收录后,再单独评审归档。
|
|
||||||
5. 服务器上的临时修复应及时取回源码、审查、提交并合入主线,避免再次形成服务器独有状态。
|
|
||||||
|
|
||||||
源码换行由 `.gitattributes` 固定为 LF,Windows 批处理文件保留 CRLF。
|
|
||||||
这样源码断言和检查在 Windows、Linux 上读取到相同内容,避免仅因换行差异误报。
|
|
||||||
|
|
||||||
## 构建与发布
|
|
||||||
|
|
||||||
- 从 `main` 的确定提交或对应发布标签构建,保留提交号与构建 ID 的对应关系。
|
|
||||||
- 默认构建目录为 `.next`;独立发布可设置 `NEXT_DIST_DIR`。
|
|
||||||
- `next-env.d.ts` 和 `tsconfig.json` 中不要提交某台服务器历史构建目录的引用。
|
|
||||||
- `.next-*`、模型配置备份、临时目录和备份文件留在运行环境,不纳入版本控制。
|
|
||||||
- 发布前保存可回滚版本,发布后核对实际运行构建、页面和必要业务流程。
|
|
||||||
- 合并源码与部署是两个操作;部署需要单独明确目标和发布范围。
|
|
||||||
|
|
||||||
`scripts/storage/` 下的迁移、审计脚本作为维护源码保存。
|
|
||||||
执行前须检查参数、目标存储和数据库配置;本次归档没有执行任何数据迁移。
|
|
||||||
|
|
||||||
## 验证入口
|
|
||||||
|
|
||||||
项目固定使用 pnpm 9.12.3,依赖按锁文件安装。
|
|
||||||
常规检查入口为 `pnpm check:branch`、`pnpm check:quality` 和 `pnpm build`,
|
|
||||||
运行环境与路由验收按 PR 模板记录。
|
|
||||||
`pnpm lint` 直接使用锁文件中的 Biome 版本执行现有检查,避免包装命令再次调用 `npx`。
|
|
||||||
历史 lint 或格式诊断应在 PR 中如实记录,不通过放宽规则或跳过检查掩盖。
|
|
||||||
Playwright 入口通过 Node 设置测试环境变量,辅助测试额外启用 `react-server` 模块条件,
|
|
||||||
可在 Windows 和 Linux 上使用同一条命令。
|
|
||||||
构建后的公共 source map 清理由 Node 完成,只处理工作区内当前构建目录的 `static` 文件。
|
|
||||||
原生 Agent 沙箱测试另外需要对应的运行时和隔离环境,应单独记录是否具备这些条件。
|
|
||||||
|
|
||||||
仓库工作流目前均为手动触发。若以后增加自动发布,须明确发布分支、目标环境、
|
|
||||||
审批和回滚方式,避免普通推送意外触发生产变更。
|
|
||||||
|
|
||||||
本次归档保留现有生产行为,历史质量问题和未执行的验收项目在合并 PR 中单独列明。
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
# 图片与视频画布原生 ZCode Agent
|
|
||||||
|
|
||||||
## 目标与边界
|
|
||||||
|
|
||||||
Agent/Auto Agent(含预设任务)统一进入原生 zai-org/ZCode CLI 核心;Claw 保持原入口。禁止用自研循环加 ZCode 名称替代实际核心。固定上游提交、构建摘要和许可证,部署时校验原生 bundle。
|
|
||||||
|
|
||||||
每个 CanvasAgentRun 分配独立 OS 沙箱、工作区、home、临时目录和会话存储。Linux 使用 bubblewrap 的 mount/user/PID/network/IPC namespaces;macOS 开发验证使用 sandbox-exec。沙箱不可用时拒绝启动,不在宿主直接执行。全任务进程树可取消并受运行时长、内存、进程数、输出和磁盘预算限制。
|
|
||||||
|
|
||||||
## 所有者与执行链路
|
|
||||||
|
|
||||||
画布 Agent -> 已鉴权 API -> 用户/画布/任务租约
|
|
||||||
-> 单任务模型及工具网关
|
|
||||||
-> 独立沙箱里的原生 ZCode
|
|
||||||
原生 ZCode -> 带运行令牌的 MCP -> 先持久化 started -> 业务能力
|
|
||||||
-> 持久化结果 -> 原生 ZCode
|
|
||||||
API -> 节点变更与进度 -> 画布
|
|
||||||
|
|
||||||
宿主仅负责身份、业务工具、模型凭据、持久化、租约与 UI 回传;ZCode 独占规划、上下文整理、工具调度及核心会话。模型和工具通过每任务 Unix socket(macOS 本地受限端口)网关通信。提供临时运行令牌,不向沙箱传递模型/云存储/数据库密钥或宿主环境。
|
|
||||||
|
|
||||||
## 授权与工具契约
|
|
||||||
|
|
||||||
图片画布 capability registry、节点操作、视图、素材、下载和全部界面入口,以及视频画布 function config、媒体处理和节点工作流统一注册为 MCP 工具。权限与按钮隐藏独立;功能授权不虚构不存在的后台,也不绕过用户/项目所有权、管理员停用状态或具体删除/覆盖操作的授权。
|
|
||||||
|
|
||||||
MCP 工具注册使用现有 inputSchema 的 JSON Schema;执行仍进行运行时 schema 校验。调用必须携带工具 callId,started 先持久化,已结算结果可重放,不确定的收费调用禁止自动重做。大结果截断或归档;模型不能提供任意 userId/projectId/服务商凭据。
|
|
||||||
|
|
||||||
## 状态、恢复及错误
|
|
||||||
|
|
||||||
CanvasAgentRun 保存 ZCode sessionId、sandbox 类型/构建、调用记录、节点 ID、补充要求、原始终止原因和最终验收。浏览器断开不取消收费任务。取消先停止后续工具,再终止整个沙箱进程树;已完成结果保留。重启后带 started/uncertain 调用的任务进入 needs_review,不重复扣费。补充要求进入同一任务队列,在下一原生回合交付,不丢失。
|
|
||||||
|
|
||||||
## 验收
|
|
||||||
|
|
||||||
- 实际原生 CLI 用模拟 OpenAI 服务及真实 MCP 完成多步工具、取消、恢复、流式事件、上下文处理。
|
|
||||||
- 沙箱内不能读取项目 .env、宿主 SSH 文件、其他任务工作区,不能写宿主文件,不能直接访问外网或宿主服务。
|
|
||||||
- 错误/过期/跨任务令牌、重复/并发工具 ID、不确定收费、跨项目节点均被拒绝。
|
|
||||||
- 每项 registry/config 功能有对应可调用工具或明确真实交互入口,缺后端明确报错。
|
|
||||||
- 图片/视频 Agent 走新入口,Claw 路由不变;服务端阻止旧 Agent 请求静默回退自研循环。
|
|
||||||
- 先本地与 48 的私有测试实例验证,再切换生产;保留旧构建回滚。
|
|
||||||
|
|
||||||
Current authorization: video canvas and storyboard Agent access is paused by the user. Image canvas and non-video project tools remain authorized; the real project workspace is checked server-side, so forged client mode cannot enable video. Full per-entry audit: authorization-audit.json / authorization-audit.html.
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@ -1,66 +1,5 @@
|
|||||||
# 视频画布对照记录(2026-09-24)
|
# 视频画布对照记录(2026-09-24)
|
||||||
|
|
||||||
## TVstar 会话、分享与插件续做(2026-10-01)
|
|
||||||
|
|
||||||
本次新增 `TVstarSession` 项目级会话表,保留浏览器本地即时保存并在后台同步到账号名下的画布项目;刷新或换设备时,按更新时间合并本地与云端历史。标题栏“分享”现在可明确确认后创建只读公开链接;公开页面固定为创建时的文字快照,排除私有媒体引用和生成提示词。可复制链接或撤销,撤销后原链接不可访问。正文中用户输入的文字与链接会照常公开,因此确认界面已说明此范围。
|
|
||||||
|
|
||||||
Plugin 入口改为新标签页,提供当前 MCP 安装包下载、Access Key 创建/撤销、STDIO 配置示例。当前 MCP 只有身份核验、列出/读取画布和有版本保护的节点操作,**不能直接发起视频生成**;页面和旧密钥弹窗已明确此边界。参考站支持多个 Agent 的专用接入,本项目还没有逐个 Agent 验证,不能宣称完整对齐。
|
|
||||||
|
|
||||||
项目积分预算仍缺少按画布项目准确归因的结算数据,因此未虚构阈值或消耗值。参考站全局设置的自动生成分类、项目预算提示和首用确认尚未逐项复刻。完成共享会话后,还需验证真实用户登录态下的跨设备恢复、公开链接访问和撤销、模型生成到成片的全链路。
|
|
||||||
|
|
||||||
设置入口后续改为居中弹窗,加入浏览器通知授权后的提示音开关;成片任务成功时用 Web Audio 播放短提示音,关闭通知时同时关闭声音。自动执行仍按本站当前视频工作流的一个总开关控制,参考站按图片/视频/音频/脚本分别设置的能力尚未实现。
|
|
||||||
|
|
||||||
部署验收:迁移 `0103_add_tvstar_sessions` 成功,服务器独立生产构建通过,最终构建 ID `e3w8hM-iopM-ar_9Ml6BZ` 已运行。TVstar 相关 8 项测试、MCP 安装包 3 项测试和 TypeScript 检查通过。外部 `/ping` 为 200;无效分享链接 404;匿名插件页 307 跳转登录、下载与会话 API 均为 401。使用隔离的临时正式账号走真实公网登录、会话 PUT/GET、分享创建/公开页面、撤销、Access Key 创建/查询/撤销、插件 ZIP 下载:分别返回预期状态,分享页包含测试文字,撤销后原链接 404,ZIP 有正确文件头;临时账号和画布已级联删除。分享 API 曾返回服务器内部绝对地址,已改为站内相对路径并经这套流程复验。真实浏览器内的 TVstar 设置弹窗和跨设备界面恢复、付费视频生成仍未在本轮实测,不能声称完整功能对等。
|
|
||||||
|
|
||||||
## TV Director 标题栏七项核对(2026-10-01)
|
|
||||||
|
|
||||||
在已登录参考画布实际点击标题栏:`新对话`、`历史对话`、`分享`、`全局设置`、`LibTV Plugin`、`停靠到右侧`、`关闭`。分享需已有会话;新会话时按钮禁用。历史按钮打开标题栏左侧的对话列表浮层;全局设置打开居中弹窗;Plugin 在新标签页打开 `/plugin`;停靠将助手固定在右侧、画布缩窄并出现可拖动宽度分隔线;关闭后底部圆球仍可重新打开。分享弹层明确提示**公开浏览权限**,生成链接后持链接者可看对话、不可编辑;本次只查看弹层,未创建公开链接。
|
|
||||||
|
|
||||||
| 标题栏入口 | TVstar 当前实现 | 差距与验收边界 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 新对话 / 历史 | 可新建、切换,本地即时保存并同步到项目会话表 | 真实登录态跨设备续聊仍待浏览器验收;历史浮层位置与参考站不同 |
|
|
||||||
| 分享 | 可创建、复制、撤销公开只读快照链接,也可复制或下载会话文本 | 公网快照与撤销的隔离数据测试已通过;真实用户页面操作仍待验收 |
|
|
||||||
| 全局设置 | 居中弹窗;自动制作、浏览器通知和通知提示音开关已持久化 | 参考站还含项目积分预算提醒阈值、首用确认和按媒体类型分开的自动生成;本站缺逐项目结算数据,未提供会误导用户的阈值开关 |
|
|
||||||
| Plugin | 标题栏新开 `/tvstar/plugin`,提供当前 MCP 包、Access Key 管理和 STDIO 示例 | 参考站可选多个 Agent 与 Plugin/CLI;本站仅验证通用 MCP 包,并无直接发起视频生成的插件工具 |
|
|
||||||
| 停靠 / 收起 | 可切换右侧全高栏与浮窗;停靠后画布缩窄,分隔线可拖动调宽;减号收起后底部圆球可重新打开;布局和宽度保存在本地 | 仍需登录态浏览器对停靠、拖宽、再浮出及刷新后的布局做实测,不能仅据代码认定同等体验 |
|
|
||||||
|
|
||||||
此表是功能核对结论,不是“七项全部复刻通过”声明。参考站会话分享可能扩大访问范围,本轮未点击生成链接。TVstar 的新增入口尚需在登录态生产页面做逐项实测;类型、构建和回归测试结果另记。
|
|
||||||
|
|
||||||
本轮参考站浏览器实测了历史弹层、全局设置弹窗、Plugin 新标签页、停靠后画布缩窄与宽度分隔线、收起后圆球恢复入口、已有会话的分享弹层。TVstar 的制作策略、会话草稿恢复等相关回归测试 5 项通过;服务器独立生产构建与 TypeScript 阶段通过,内外 `/ping` 返回正常。生产画布在本轮浏览器跳转至登录页,故生产登录态的七个按钮和拖宽操作尚未通过浏览器端到端验收。浏览器操作未创建公开分享链接、未触发参考站收费生成。测试画布中的上轮待运行节点尚待用户决定是否清理。
|
|
||||||
|
|
||||||
## TV Director 与角色造型室追加核对(2026-10-01)
|
|
||||||
|
|
||||||
本轮在 [LibTV 画布](https://www.liblib.tv/canvas) 实际打开 TV Director、创作模式、模型菜单、Skill 入口、全局设置,以及角色造型室的创建、官方库和编辑页。另核对其[官方 Agent Skill](https://github.com/libtv-labs/libtv-skills/blob/main/skills/libtv-skill/SKILL.md?plain=1):同一 session 可续聊、上传参考媒体、轮询消息进展并提取结果。没有提交收费生成任务,因而对参考站生成质量、精确费用和内部模型编排算法没有实测结论。角色库当时出现“网络不稳定”,仅确认了可见入口和一个官方角色的编辑页,不能据此推断库的完整内容。
|
|
||||||
|
|
||||||
| 细节 | 参考站可见行为 | 本站目前状态 | 仍缺的验收或能力 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 会话与交付 | 右侧停靠对话、历史会话;Agent 可在同一 session 续聊并查询进展与结果 | TVstar 右侧停靠;消息先保存再规划;同一对话可修改分镜;编组任务和成片可回显;刷新可恢复本浏览器记录 | 目前使用浏览器本地存储,跨设备与服务端会话同步未实现;生成中断后自动续跑仍需完整故障注入测试 |
|
|
||||||
| 生成策略 | 手动/自动;全局设置另有项目积分用量提醒阈值与通知选项 | 自动制作和先确认可切换;默认自动以满足用户直接产出视频的需求 | 尚无按项目累计积分发送的阈值提醒;通知、声音和会话级消费审计未对齐。参考站阈值是提醒,不是自动阻断 |
|
|
||||||
| 模型与引用 | 图片/视频模型分开选;`@` 可引用工作流、节点、资源 | 图片/视频生成模型可选,图片参考能力校验;引用画布图片、视频及上传素材;视频参考先提取首帧,真实视频剪辑直接读原素材 | `@` 当前仅覆盖图片/视频素材;工作流、文本节点及结构化上下文引用未覆盖 |
|
|
||||||
| Skill 与编排 | 可搜索的 Skill 市场;自然语言可调用编辑、续写、MV、复杂短剧等流程 | 内置可搜索的 9 个创作模板;分镜→关键帧→视频→语义选高光→渲染,或直接从已有视频智能剪辑 | 不是参考站的可扩展 Skill 市场;局部画面修改、视频续写、音乐 MV 和逐镜头选择性重做仍无同等执行器 |
|
|
||||||
| 角色造型室 | 我的/官方角色库、筛选/搜索/多选、参考图创建;编辑页有脸部精修、脸和身体特征、身材塑形、服装造型、音色音调,以及半身/全身、撤销/恢复、保存/加画布 | 已有角色预设库和参考图/描述创建;角色分析后可选择立绘、脸部、表情、三视图;本轮补上实际图片生成任务提交 | 新生成角色还没有持久化的“我的角色库”;细部滑杆、换装复刻、场景妆造、音色与撤销历史没有等效实现;不能宣称角色造型室已追平参考站 |
|
|
||||||
| 成片时长 | 同一创作过程允许修改镜头 | 时长解析已区分局部镜头和整片修改;“镜头 2 改 3 秒”保留原 15 秒成片,“整片改 20 秒”更新总长 | 不同目标时长、模型生成片段长度和转场后的质量还需多素材实测 |
|
|
||||||
|
|
||||||
本轮生产构建、TypeScript、相关 Playwright 辅助测试和 `git diff --check` 均通过;相关测试中含编组依赖、已有视频直入智能剪辑、真实引用帧的顺序处理和时长修改回归。新版本已切换至生产服务器,内外 `/ping` 均返回 200。生产页面需要登录,本轮未用用户账号进行浏览器内逐项点击验收。此前真实多镜头生成与六秒合片的测试记录仍是现有端到端证据;本轮新增的角色出图入口没有再次发起付费生成,不能把编组测试当作角色成图质量验证。
|
|
||||||
|
|
||||||
### 继续交互核对(同日)
|
|
||||||
|
|
||||||
进一步在空画布中输入 `@`,参考站弹层实际将**画布节点**与**图片、视频模型**分组;点击模型会把它作为富文本引用插入输入框。关闭再打开 TV Director 后,未发送的输入和模型引用仍在。第一次点击发送会先出现“确认 TV Director 设置”,其中自动生成默认关闭;积分预算管理默认开启,1000 积分为**提醒阈值**,页面说明只承诺提醒,不承诺停机。测试时关闭该设置弹层后,已排队的无生成分析请求仍继续执行,因此后续得以观察真实消息流与无生成的画布操作;没有发起图片、视频或音频的收费生成。
|
|
||||||
|
|
||||||
角色造型室的官方角色可派生幼年、少年、青年、中年、老年版本;编辑区有“半身/全身”和皮肤、五官、身材、服装、音色等分组。服装造型分为预设造型、服装、AI 生成;AI 生成可输入描述或上传参考图,造型技能菜单显示“妆造复刻”和“人设定制造型”。音色页含音色库、我的音色、AI 生成与历史。未保存的角色编辑在关闭时会提示“关闭后仍会保留本地草稿”。这些是界面行为,未发起付费出图、换装或声音生成,无法确认实际模型参数或输出质量。
|
|
||||||
|
|
||||||
因此本轮为 TVstar 补上未发送内容和引用素材草稿的按画布恢复、`@` 模型引用入口,以及角色设定草稿在关闭/刷新后的恢复。角色参数级修图、年龄派生、音色库和妆造技能的真实生成执行器仍未实现;不得把角色提示词拼接称作同等的人像编辑算法。
|
|
||||||
|
|
||||||
### 无生成模式的真实 Agent 执行(同日续测)
|
|
||||||
|
|
||||||
在当前空画布的 TV Director 同一会话中,先要求“只分析创作步骤,不提交生成任务”。参考站先显示思考和素材查找状态,随后报告没有可用素材并给出创作规划。再要求创建一支 15 秒毛笔广告的**待运行**工作流,同时明确不生成、不扣费。它先保存创作稿、视觉锚点和分镜账本,再实际创建 4 个关键帧图片节点与 3 个视频节点;每个节点有 `node_key`,视频节点引用多个共用关键帧。画布显示真实连线,余额保持 20。它说明最终成片只能在视频生成完成后调用合成工具,因此此时只在分镜账本中登记待合成占位,没有假称已产出视频。刷新后,当前会话、消息和画布节点恢复;加载期间曾短暂显示“新对话”,随后恢复原会话。
|
|
||||||
|
|
||||||
点击待运行的视频节点时,TV Director 输入框自动加入该节点引用。参考站的 `@` 不局限于媒体 URL,也能引用未运行节点。其内部调度模型、图片质量、视频高光选片与合成算法仍不可由这些界面行为推断;本次没有付费生成。TVstar 本轮据此增加了“只规划不提交”和“建立待运行工作流”的意图区分:前者只保留分析;后者在画布创建脚本、关键帧、视频和成片节点并连好依赖,不触发模型生成,后续按钮或同一对话的“现在开始制作”可执行原组。点击画布节点会将节点 ID、类型和当前文本草稿加入对话上下文,`@` 列表也展示未运行节点。本站的待运行成片节点是可编辑的占位,参考站此阶段仅有账本占位,属于不同实现。本站当前仍是逐镜头关键帧,缺少参考站这种跨镜头共用的产品/场景/人物视觉锚点;节点引用也没有参考站完整的结构化资源解析能力。不能据此宣称画布 Agent 已完全追平参考站。
|
|
||||||
|
|
||||||
参考站公开的 [LibTV Skill](https://github.com/libtv-labs/libtv-skills/blob/main/skills/libtv-skill/SKILL.md?plain=1) 进一步证明其会话层支持持久 `sessionId`、同会话追加消息、按序号增量查询进展、素材上传和结果下载;文档明确说后端 Agent 负责需求理解、分镜拆解、模型选择和工作流编排。公开资料未披露其内部提示词、模型路由、画面评分或剪辑算法,不应把本站的启发式规则写成“逆向得到参考站算法”。本站现有会话仍以浏览器本地存储为主,不能跨设备续聊;节点编排是固定的分镜→关键帧→视频→选片→成片路径,尚缺按用户意图动态选择工具并按任务失败反馈重新规划。建议下一阶段做服务器持久会话与操作日志、结构化资产锚点和节点引用、任务级状态流、低匹配镜头返工与成片质量门槛。
|
|
||||||
|
|
||||||
本轮的实现已在本地通过 TypeScript、41 项 TVstar/编组/剪辑回归测试、`git diff --check` 和生产构建;服务器独立构建完成后才切换 `.next`,内外 `/ping` 均返回 `{"ok":true}`。生产画布路由要求登录,因此本轮无法在未获用户账号的浏览器会话中验证生产页面内的点击、刷新和真实付费生成;这些仍是验收边界。服务器构建还暴露旧 `backups` 目录被通配 TypeScript 扫描的问题,已在项目 `tsconfig.json` 排除该目录。
|
|
||||||
|
|
||||||
参考:用户已登录的 [LibLib 画布](https://www.liblib.tv/canvas?guideSource=home-feature-grid&spaceId=9684219&projectId=289bb2626edd465baafc57f4e29d189f) 与本次提供的七张对照截图。通过页面实际点击确认了空文本节点、文本转视频、空智能剪辑节点、素材混剪预设、空导演台、空视频节点和五分钟视频入口。测试创建的参考站节点已用撤销清理,刷新后确认画布恢复为空。
|
参考:用户已登录的 [LibLib 画布](https://www.liblib.tv/canvas?guideSource=home-feature-grid&spaceId=9684219&projectId=289bb2626edd465baafc57f4e29d189f) 与本次提供的七张对照截图。通过页面实际点击确认了空文本节点、文本转视频、空智能剪辑节点、素材混剪预设、空导演台、空视频节点和五分钟视频入口。测试创建的参考站节点已用撤销清理,刷新后确认画布恢复为空。
|
||||||
|
|
||||||
| 功能 | 参考站实际行为 | 本轮本地修改 | 尚未通过的验收 |
|
| 功能 | 参考站实际行为 | 本轮本地修改 | 尚未通过的验收 |
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
# 视频画布生产差距与验收(2026-10-03)
|
|
||||||
|
|
||||||
参考:LibTV 公开站点、此前登录态交互核对记录、实际项目代码和部署服务。公开页面与截图只能证明外部行为,不能证明对方内部算法。本记录不把模板、可点击按钮、类型检查或本地 FFmpeg 测试当作端到端生产验收。
|
|
||||||
|
|
||||||
## 本轮修复
|
|
||||||
|
|
||||||
| 发现的问题 | 实际修复 | 验收方式 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| TVstar 关键帧与成片固定横屏 720P | 从需求传递画幅、清晰度与声音;不支持的 2K/4K 自动成片直接说明限制 | 参数与模型兼容性回归;真实竖屏成片规格已通过;视觉审查发现首镜头分屏,继续定向修复 |
|
|
||||||
| 离散生成时长可能短于剪辑所需时长 | 按最终分镜分配时长,选择不短于需求的生成规格;指定模型不满足要求时拒绝,不能静默换模型 | 18 秒/双镜头生成规格测试;保留小数总时长;超过 300 秒拒绝 |
|
|
||||||
| 高光匹配低分也会继续交付 | 分数低于 60、错序、遗漏、重复或越界区间直接停止;错误指明镜头;分数是模型判断,不能当作客观质量保证 | 独立选片合同验证;真实官方模型拦截已知分屏以及不足3秒的无缺陷区间 |
|
|
||||||
| 六帧预览不能充分描述运动 | 已支持的官方千问视频模型读取真实视频(2帧/秒),结合相邻镜头预览选取连续区间;最多同时分析3段;供应商失败不自动换模型或重发 | 请求格式回归;真实官方视频选片已执行,保存了起止时间与画面依据;模型曾漏报分屏,不能把分数当作作品验收 |
|
|
||||||
| 最终合成服务不可用时仍先启动上游付费生成 | 创建任务前检查成片/高清/转写/分离所需服务;错误明确为未启动;前端清除提交重试记录 | 临时故障进程的真实 HTTP、鉴权、数据库零任务验收 |
|
|
||||||
| 新检查可能破坏任务幂等重查 | 检查在数据库排重后、新任务插入前执行,已有请求不受当前服务故障影响 | 保留原排重和原请求冲突检查 |
|
|
||||||
| 多个角色视图各自凭文本生成 | 第一张实际生成图作为身份基准;其他视图等待并引用它;多视图必须选择支持参考图的模型 | 真实画布依赖检查;真实立绘与近景均已生成,近景引用立绘;本轮人工检查人物、发型与服装一致,不等于身份自动验收 |
|
|
||||||
| 无声剪辑仍可能保留素材音轨 | 无声约束写入实际剪辑方案,静音所有视频片段并移除背景音乐 | 剪辑方案回归;真实成片音轨检测峰值与均值 -91 dB,静音通过 |
|
|
||||||
|
|
||||||
## 真实测试额外发现
|
|
||||||
|
|
||||||
1. **供应商视频规格漂移导致生产 HTTP 400。** 默认 MiniMax H3 的公开线路使用 `landscape/portrait`、`480p/768p/1080p`,存储配置却仍是 `9:16` 和 `720p`。已按公开合同校正指定模型、转换原生画幅、选择不低于最终输出的原生规格,并在任何上游付费任务插入前核验公开线路。旧规格请求的真实 HTTP 验收返回 503,数据库零任务。
|
|
||||||
2. **整片时长被“每个镜头3秒”覆盖。** 6秒双镜头需求曾被错误理解为3秒整片,已经修复自然语言时长解析。
|
|
||||||
3. **全能创作重复请求规划。** 初版实际等待116秒后才提交制作。当前全能创作一次生成结构化分镜和规划,真实浏览器验收观察到一次 `story-script` 请求,并即时显示用户消息;先确认模式保存工作流且没有生成任务。
|
|
||||||
4. **画布连线把整个脚本注入每张关键帧。** 首次真实成片虽可播放且规格正确,逐秒抽帧发现前三秒上下分屏;AI却给出92分。根因是脚本连接携带整片两个镜头,单帧生成消费了全部内容。已按镜头ID投影上游脚本、约束单镜头构图,并要求分析返回具体画面缺陷;报告缺陷时即使高分也拒绝合成。模型未报告的错误仍需独立审片,不能宣称自动视觉验收已经完善。
|
|
||||||
5. **角色分析自动模型遗漏。** 初次目录未加载时,角色分析走另一条鉴权失效的默认线路,而脚本分析会选已配置的官方千问。已统一角色分析默认选择,并在角色出图前恢复读取模型目录。
|
|
||||||
|
|
||||||
6. **供应商图片 HTTP 409 被错误标为失败。** 同一幂等键仍在处理的409,现在仅用原键进行有限重查;超出重查或传输不确定时保留中断/待核对状态,禁止切换线路或模型再次付费。真实旧请求无法取得可核对的供应商任务ID,未盲目重发;模拟接口集成测试验证同一键、无模型切换、503不确定状态保留。不能因此宣称该旧任务已找回。
|
|
||||||
7. **视频分析返回格式不稳定。** 真实素材审查曾返回不能解析的JSON并阻止合成。按官方合同启用标准JSON输出,并检查普通与流式返回的截断标记;截断即停止且不自动重发。
|
|
||||||
|
|
||||||
## 服务现状
|
|
||||||
|
|
||||||
2026-10-03 本轮检查时主站 `/ping` 正常,主机的成片、视频高清和音频服务转发端口已恢复。先前一次测试时成片隧道不可用,不能把那次状态作为当前结论,也不能把当前健康响应当作长期可用性证明。转发服务需持续运行保障,任务恢复和不可用时拒绝提交是不同的验收点。
|
|
||||||
|
|
||||||
## 仍未达到完整生产对等的项目
|
|
||||||
|
|
||||||
1. **生成后定向返修与结果复用。** 不合格片段可以被拦截,但尚无完整的自动返修预算和仅重做失败镜头的闭环;重新执行整组可能重做成功镜头。
|
|
||||||
2. **音频质量验收。** 视觉千问模型不理解声音;Omni 才支持视频声音。配乐跨镜头连续、旁白台词准确、音量响度和音画对齐未形成自动验收闭环。无声要求已经执行,不能因此声称有声广告也验收完毕。
|
|
||||||
3. **通用画布 Agent。** TVstar 当前是分镜生成、视频、选片、合成的编组流程;尚无可任意操作工具、检查结果并自主改写画布的完整工具循环。Plugin 也不能直接发起视频生成。
|
|
||||||
4. **项目级成本。** 缺乏精确归因到项目的费用账本、整片报价、分类预算和自动执行上限;不能虚构积分消耗。
|
|
||||||
5. **角色云端复用与身份验收。** 基准图引用改善一致性,但没有人物身份自动验收、完整个人角色版本库及跨设备复用闭环。角色造型草稿目前主要在本地保存。
|
|
||||||
6. **交付规格与视觉验收。** 分辨率、时长、文件可读性可检查;真实品牌字样、商品细节、运动合理性与跨镜头主体一致性仍要通过真实输出审查。模型自评不是独立审片。
|
|
||||||
7. **账户及发布入口。** 部分支持、发布或账户入口仍未接入真实服务;不可将这些禁用入口列为生产功能。
|
|
||||||
|
|
||||||
本轮具体依据更新了此前的[功能对照记录](video-canvas-liblib-parity-audit-2026-09-24.md)中的待验证项;较早日期的状态不能直接作为当前生产验收结论。
|
|
||||||
|
|
||||||
## 本轮验收结果
|
|
||||||
|
|
||||||
- 本轮完整 helper 回归:574 项通过;新增覆盖指定镜头消费、分屏缺陷与高分冲突、公开视频规格、自然语言时长等风险。
|
|
||||||
- TypeScript 检查通过。
|
|
||||||
- 真实服务故障注入、鉴权、数据库验收:单任务与整组 503,零任务;公开供应商拒绝旧视频规格前,图片任务也未启动。
|
|
||||||
- 生产构建通过;最终代码提交 `c4a2753a` 已在服务器快进更新并上线,构建号 `6MscLfc7pGW5cYMZZmyY-`。旧构建保留用于回滚,公网 `/ping` 为200。
|
|
||||||
- 两段实际视频生成、官方原生视频高光分析、FFmpeg合成、浏览器播放、刷新后云端对话与成片恢复通过。首轮成片 6.000 秒、720×1280、30 FPS、音轨峰值 -91 dB。生成与合成耗时约679秒。这只证明链路及文件规格,**首轮作品存在分屏,不算视觉合格**。
|
|
||||||
- 角色两张真实生成任务均成功,第二张依赖第一张实际图像;初版近景含全身和脸部插图,人工审查未通过。拆开人物身份与视图构图后,仅重做近景并复用立绘,已得到单一脸部近景,人物、发型、服装经人工检查一致。立绘脚部入画完整性仍需另行验收。
|
|
||||||
|
|
||||||
官方视频输入与采样依据:[阿里云 OpenAI 兼容 Chat 文档](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-chat-completions)。原生视频模式仍按指定 FPS 采样,不意味着模型逐帧检查全部内容。
|
|
||||||
|
|
||||||
## 交付证据与限制
|
|
||||||
|
|
||||||
- 默认图片与视频模型已真实调用,费用不以本地测试推算;本轮没有核算项目完整费用账本。
|
|
||||||
- 分屏反向测试使用真正生成的视频,并由官方视频模型分析后阻止合成。另一次发现镜头2仅约2.46秒干净画面,不能满足连续3秒,也正确阻止了合成。
|
|
||||||
- 修复样片通过本站真实视频处理接口裁切首段、截取镜头2干净区间并减速两倍,随后重新选片合成。处理复用了两段已生成素材,没有再次生成视频。裁切首段只有378×672,再放大输出720×1280,不能称为原生720P细节质量。这个定向处理由本轮开发验收安排,尚不是TVstar完整自动返修能力。
|
|
||||||
- 角色新版近景视觉截图保存在本轮忽略的QA目录,旧拼贴近景不作合格样例。
|
|
||||||
- 生产首次真实生成到成片约679秒,规划去除一次重复调用改善了提交阶段;模型生成时间仍取决于供应商,不能保证所有请求秒出。
|
|
||||||
|
|
||||||
## Git 与发布方式
|
|
||||||
|
|
||||||
本轮所有代码修复都有Git提交,服务器通过Git bundle快进拉取相同提交。原origin仓库返回404,未更改或绕过远端鉴权;Git远端推送仍未成功。服务器预存的`next-env.d.ts`修改保留,构建自动更改的`tsconfig.json`已恢复,没有覆盖用户数据。
|
|
||||||
|
|
||||||
## 最终已上线复验
|
|
||||||
|
|
||||||
标准JSON输出版真实官方视频分析已成功,后续实际合成任务成功,未重新生成图片或视频。最终文件为[修复验收样片](https://scqny.jiansun.vip/uploads/1791021293810-hu1l9w.mp4):720×1280、30 FPS、6.022秒,与6秒目标的误差低于一帧。浏览器实际播放时间前进,媒体无错误;刷新后云端对话中的成片恢复并可播放。模型输出评分只作为选片依据,另做人工抽帧视觉检查。
|
|
||||||
|
|
||||||
验收脚本曾误将媒体时长要求写成浮点数严格等于6;这是测试断言问题,改为一帧容差后只读取原成功任务和作品,没有再次提交生成或分析。其他已发现的测试定位问题也未据此重复下单。
|
|
||||||
|
|
||||||
最终作品逐秒抽帧人工检查:首段笔尖墨水近景、后段桌面全景均为单一画幅,抽帧未见分屏/拼贴,暖色色调一致;音轨峰值和均值均为-91dB,无声验收通过。这个样片的两段视频由真实模型生成,定向裁切和截取减速由验收人员通过本站处理接口发起,随后AI实际选片和系统合成;不能将其写成“自然语言发起后全程自动返修成功”。首轮原始生成的分屏输出和中间分析失败如实保留。
|
|
||||||
21
docs/video-canvas-toolbar-parity-2026-09-24.md
Normal file
21
docs/video-canvas-toolbar-parity-2026-09-24.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# 视频画布选中素材工具条核对(2026-09-24)
|
||||||
|
|
||||||
|
参考:用户提供的 Liblib 选中视频、图片截图,以及已登录参考画布。以下「已接线」只表示按钮有真实任务路径,不表示真实素材输出和像素一致性已验收。
|
||||||
|
|
||||||
|
| 素材 | 参考功能 | 本地路径 | 本轮验证 | 发布状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 视频 | 高清 | Real-ESRGAN 算力服务;仅展示可执行的 1080P、2K、4K,选定即提交持久任务 | 尺寸与任务恢复测试通过 | 真实结果未验收 |
|
||||||
|
| 视频 | 片段重拍 | 画布内提示词面板;Wan 2.7 `wan2.7-videoedit`;超过 10 秒先截片段 | 请求契约、恢复、节点结果测试通过;模型目录可查 | 真实结果未验收 |
|
||||||
|
| 视频 | 逐帧拉片 | 既有视频分析任务 | 既有分析测试通过 | 本轮未完成真实素材验收 |
|
||||||
|
| 视频 | 智能去字幕 | 框选/底部区域掩码;Wan 2.1 VACE `wanx2.1-vace-plus` | 掩码像素、请求契约测试通过;模型目录可查 | 单次限 5 秒,整片识别擦除与参考站不同 |
|
||||||
|
| 视频 | 音视频分离 | 既有音轨/人声/背景声分离路径 | 既有音频测试通过 | 本轮未完成真实素材验收 |
|
||||||
|
| 视频 | 主体消除 | 框选动态跟踪掩码;VACE 视频局部编辑 | 区域掩码与请求契约测试通过 | 单次限 5 秒,真实结果未验收 |
|
||||||
|
| 视频 | 创意片头 | 画布内提示词面板;VACE 尾片段延展 | 请求契约测试通过 | 尾片段限 3 秒,真实结果未验收 |
|
||||||
|
| 视频 | 裁剪、下载、全屏、剪辑、慢放 | 既有媒体处理及预览/下载 | FFmpeg 剪辑与音频同步测试通过 | 本轮未完成逐项视觉验收 |
|
||||||
|
| 图片 | 人像质感调节 | 配置的图片重绘模型与身份保持提示词 | 工具条入口已在本地页面显示 | 真实结果未验收 |
|
||||||
|
| 图片 | 全景、多角度、打光、九宫格、高清、宫格切分、元素编辑 | 既有模型或本地编辑路径;未配置时明确报错,不再创建空壳节点 | 本轮核对按钮及部分既有测试 | 本轮未完成逐项视觉/真实结果验收 |
|
||||||
|
| 图片 | 图层分离 | 现有 Replicate `qwen/qwen-image-layered` 服务,返回多图层并恢复到画布 | 多图层恢复测试通过 | 线上已有 Token 但开关关闭;本地无配置,未验收 |
|
||||||
|
|
||||||
|
本地对象存储未配置,上传真实图片或视频会失败。本轮另在临时本地画布引用阿里云公开示例视频,实测选中视频工具条、高清/字幕/音频/主体/更多菜单、重拍提示词面板、主体框选及字幕操作栏;框选后「生成擦除」从禁用变为可用。临时项目已删除。这验证了本地交互,不代表模型出图。阿里云账号的模型目录查询同时列出 `wan2.7-videoedit`、`wanx2.1-vace-plus`,但没有发起付费生成,也不能由此推断输出质量或速度。官方 VACE 还要求输入为 MP4、帧率至少 16 FPS、文件不超过 50 MB,局部编辑最多处理 5 秒,片头参考片段最多 3 秒;当前仅按时长拦截,格式、帧率、体积与完整长片处理仍需补齐。生产构建、TypeScript 检查和本轮工具条契约测试通过,不等于像素级和真实媒体验收。发布前仍需在隔离测试环境配置可用媒体存储、启用图层分离模型,并逐个对真实素材运行、留存结果与参考站截图比较。
|
||||||
|
|
||||||
|
模型接口依据:[Wan 2.7 视频编辑](https://help.aliyun.com/zh/model-studio/wan-video-editing-api-reference)、[Wan 2.1 VACE 视频编辑](https://help.aliyun.com/zh/model-studio/legacy-wanx-vace-api-reference)、[Qwen-Image-Layered](https://github.com/QwenLM/Qwen-Image-Layered)。
|
||||||
@ -41,9 +41,9 @@ export function useCanvasGroupRuns(projectId: string, refresh: () => void) {
|
|||||||
signal: AbortSignal.timeout(20000),
|
signal: AbortSignal.timeout(20000),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.json().catch(() => ({}));
|
if (response.status < 500 && response.status !== 429) {
|
||||||
if ((response.status < 500 && response.status !== 429) || body.code === "CANVAS_SERVICE_UNAVAILABLE") {
|
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
|
const body = await response.json();
|
||||||
throw Error(body.error || "编组请求被拒绝");
|
throw Error(body.error || "编组请求被拒绝");
|
||||||
}
|
}
|
||||||
throw Error("编组提交尚未确认,已保存,将查询同一批任务");
|
throw Error("编组提交尚未确认,已保存,将查询同一批任务");
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
|
||||||
import {
|
|
||||||
collectImageArchiveSources,
|
|
||||||
type ArchivedImageUrlMap,
|
|
||||||
} from "@/lib/storage/image-archive-urls";
|
|
||||||
|
|
||||||
function preload(url: string, signal: AbortSignal): Promise<boolean> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const image = new Image();
|
|
||||||
let done = false;
|
|
||||||
const finish = (loaded: boolean) => {
|
|
||||||
if (done) return;
|
|
||||||
done = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
signal.removeEventListener("abort", cancel);
|
|
||||||
image.onload = image.onerror = null;
|
|
||||||
resolve(loaded);
|
|
||||||
};
|
|
||||||
const cancel = () => finish(false);
|
|
||||||
const timer = setTimeout(cancel, 15_000);
|
|
||||||
signal.addEventListener("abort", cancel, { once: true });
|
|
||||||
image.onload = () => finish(true);
|
|
||||||
image.onerror = cancel;
|
|
||||||
if (signal.aborted) cancel();
|
|
||||||
else image.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCanvasImageArchives(projectId: string) {
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const checked = new Set<string>();
|
|
||||||
const unknownChecks = new Map<string, number>();
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
let polling = false;
|
|
||||||
const poll = async () => {
|
|
||||||
if (polling || controller.signal.aborted || document.visibilityState === "hidden") return;
|
|
||||||
const state = useCanvasStore.getState();
|
|
||||||
if (state.projectId !== projectId) return;
|
|
||||||
const urls = collectImageArchiveSources(state.nodes)
|
|
||||||
.filter((url) => !checked.has(url))
|
|
||||||
.slice(0, 100);
|
|
||||||
if (!urls.length) return;
|
|
||||||
polling = true;
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/canvas/${encodeURIComponent(projectId)}/image-archives`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ urls }),
|
|
||||||
signal: controller.signal,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) return;
|
|
||||||
const result = (await response.json()) as {
|
|
||||||
replacements: ArchivedImageUrlMap;
|
|
||||||
pending: string[];
|
|
||||||
};
|
|
||||||
const pending = new Set(result.pending);
|
|
||||||
const ready: ArchivedImageUrlMap = {};
|
|
||||||
await Promise.all(
|
|
||||||
urls.map(async (url) => {
|
|
||||||
const target = result.replacements[url];
|
|
||||||
if (target) {
|
|
||||||
if (await preload(target, controller.signal)) {
|
|
||||||
ready[url] = target;
|
|
||||||
checked.add(url);
|
|
||||||
checked.add(target);
|
|
||||||
}
|
|
||||||
} else if (!pending.has(url)) {
|
|
||||||
// A direct generation can reach the browser before its first autosave.
|
|
||||||
// Allow ownership records to catch up before classifying a URL as untracked.
|
|
||||||
const checks = (unknownChecks.get(url) || 0) + 1;
|
|
||||||
unknownChecks.set(url, checks);
|
|
||||||
if (checks >= 10) checked.add(url);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!controller.signal.aborted &&
|
|
||||||
useCanvasStore.getState().projectId === projectId &&
|
|
||||||
Object.keys(ready).length
|
|
||||||
) {
|
|
||||||
useCanvasStore.getState().replaceArchivedImageUrls(ready);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Temporary network errors keep provider previews and are retried on the next poll.
|
|
||||||
} finally {
|
|
||||||
polling = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const tick = () => {
|
|
||||||
void poll();
|
|
||||||
timer = setTimeout(tick, 3_000);
|
|
||||||
};
|
|
||||||
timer = setTimeout(tick, 1_000);
|
|
||||||
return () => {
|
|
||||||
controller.abort();
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [projectId]);
|
|
||||||
}
|
|
||||||
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