feat: update canvas and flow board features
This commit is contained in:
parent
d88c28356a
commit
e054dcabcc
50
app/api/flow-board/_utils.ts
Normal file
50
app/api/flow-board/_utils.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import type { FlowBoardDraft, FlowBoardSnapshotData } from "@/lib/db/schema";
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeRecord(value: unknown): Record<string, unknown> | null {
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
export function normalizeFlowBoardTitle(value: unknown) {
|
||||
const title = String(value || "").trim();
|
||||
return title.slice(0, 256) || "未命名流程白板";
|
||||
}
|
||||
|
||||
export function normalizeFlowBoardSnapshot(
|
||||
value: unknown
|
||||
): FlowBoardSnapshotData {
|
||||
const raw = isRecord(value) ? value : {};
|
||||
const updatedAt =
|
||||
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
|
||||
? raw.updatedAt
|
||||
: new Date().toISOString();
|
||||
|
||||
return {
|
||||
children: Array.isArray(raw.children) ? raw.children.filter(isRecord) : [],
|
||||
viewport: normalizeRecord(raw.viewport),
|
||||
theme: normalizeRecord(raw.theme),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFlowBoardDraft(draft: FlowBoardDraft) {
|
||||
return {
|
||||
id: draft.id,
|
||||
title: draft.title || "未命名流程白板",
|
||||
snapshot: draft.snapshot ?? { children: [] },
|
||||
createdAt: draft.createdAt?.toISOString() ?? null,
|
||||
updatedAt: draft.updatedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFlowBoardDraftSummary(draft: FlowBoardDraft) {
|
||||
return {
|
||||
id: draft.id,
|
||||
title: draft.title || "未命名流程白板",
|
||||
createdAt: draft.createdAt?.toISOString() ?? null,
|
||||
updatedAt: draft.updatedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
@ -4,34 +4,11 @@ import {
|
||||
getFlowBoardDraftByUserId,
|
||||
upsertFlowBoardDraftByUserId,
|
||||
} from "@/lib/db/queries";
|
||||
import type { FlowBoardSnapshotData } from "@/lib/db/schema";
|
||||
import { mapFlowBoardDraft, normalizeFlowBoardSnapshot } from "../_utils";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeRecord(value: unknown): Record<string, unknown> | null {
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function normalizeFlowBoardSnapshot(value: unknown): FlowBoardSnapshotData {
|
||||
const raw = isRecord(value) ? value : {};
|
||||
const updatedAt =
|
||||
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
|
||||
? raw.updatedAt
|
||||
: new Date().toISOString();
|
||||
|
||||
return {
|
||||
children: Array.isArray(raw.children)
|
||||
? raw.children.filter(isRecord)
|
||||
: [],
|
||||
viewport: normalizeRecord(raw.viewport),
|
||||
theme: normalizeRecord(raw.theme),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
@ -48,10 +25,15 @@ export async function GET() {
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
snapshot: draft?.snapshot ?? { children: [] },
|
||||
updatedAt: draft?.updatedAt?.toISOString() ?? null,
|
||||
},
|
||||
data: draft
|
||||
? mapFlowBoardDraft(draft)
|
||||
: {
|
||||
id: null,
|
||||
title: "流程白板",
|
||||
snapshot: { children: [] },
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -82,9 +64,6 @@ export async function PUT(request: Request) {
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
snapshot: savedDraft.snapshot,
|
||||
updatedAt: savedDraft.updatedAt?.toISOString() ?? snapshot.updatedAt,
|
||||
},
|
||||
data: mapFlowBoardDraft(savedDraft),
|
||||
});
|
||||
}
|
||||
|
||||
116
app/api/flow-board/files/[id]/route.ts
Normal file
116
app/api/flow-board/files/[id]/route.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
deleteFlowBoardDraftById,
|
||||
getFlowBoardDraftById,
|
||||
updateFlowBoardDraftById,
|
||||
} from "@/lib/db/queries";
|
||||
import {
|
||||
mapFlowBoardDraft,
|
||||
normalizeFlowBoardSnapshot,
|
||||
normalizeFlowBoardTitle,
|
||||
} from "../../_utils";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, { params }: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const draft = await getFlowBoardDraftById({
|
||||
userId: session.user.id,
|
||||
draftId: id,
|
||||
});
|
||||
|
||||
if (!draft) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "流程白板文件不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: mapFlowBoardDraft(draft),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, { params }: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
title?: unknown;
|
||||
snapshot?: unknown;
|
||||
};
|
||||
const draft = await updateFlowBoardDraftById({
|
||||
userId: session.user.id,
|
||||
draftId: id,
|
||||
title:
|
||||
body.title !== undefined
|
||||
? normalizeFlowBoardTitle(body.title)
|
||||
: undefined,
|
||||
snapshot:
|
||||
body.snapshot !== undefined
|
||||
? normalizeFlowBoardSnapshot(body.snapshot)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!draft) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "流程白板文件不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: mapFlowBoardDraft(draft),
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const draft = await deleteFlowBoardDraftById({
|
||||
userId: session.user.id,
|
||||
draftId: id,
|
||||
});
|
||||
|
||||
if (!draft) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "流程白板文件不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: mapFlowBoardDraft(draft),
|
||||
});
|
||||
}
|
||||
67
app/api/flow-board/files/route.ts
Normal file
67
app/api/flow-board/files/route.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createFlowBoardDraft,
|
||||
listFlowBoardDraftsByUserId,
|
||||
} from "@/lib/db/queries";
|
||||
import {
|
||||
mapFlowBoardDraft,
|
||||
mapFlowBoardDraftSummary,
|
||||
normalizeFlowBoardSnapshot,
|
||||
normalizeFlowBoardTitle,
|
||||
} from "../_utils";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const drafts = await listFlowBoardDraftsByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: drafts.map(mapFlowBoardDraftSummary),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
title?: unknown;
|
||||
snapshot?: unknown;
|
||||
};
|
||||
const draft = await createFlowBoardDraft({
|
||||
userId: session.user.id,
|
||||
title: normalizeFlowBoardTitle(body.title),
|
||||
snapshot: normalizeFlowBoardSnapshot(body.snapshot),
|
||||
});
|
||||
|
||||
if (!draft) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "创建流程白板失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: mapFlowBoardDraft(draft),
|
||||
});
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Box,
|
||||
@ -9,6 +10,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clapperboard,
|
||||
CheckCircle2,
|
||||
Crosshair,
|
||||
Download,
|
||||
Eye,
|
||||
@ -16,11 +18,15 @@ import {
|
||||
FileText,
|
||||
Film,
|
||||
FolderOpen,
|
||||
Flag,
|
||||
GitBranch,
|
||||
Grid3X3,
|
||||
Hand,
|
||||
Heart,
|
||||
HelpCircle,
|
||||
ImagePlus,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
MessageCircle,
|
||||
@ -29,6 +35,7 @@ import {
|
||||
Palette,
|
||||
Pencil,
|
||||
PenTool,
|
||||
Pin,
|
||||
Plus,
|
||||
Redo2,
|
||||
Save,
|
||||
@ -36,13 +43,17 @@ import {
|
||||
Scissors,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Star,
|
||||
Table2,
|
||||
Tag,
|
||||
Trash2,
|
||||
Type,
|
||||
Undo2,
|
||||
Upload,
|
||||
UserRound,
|
||||
Video,
|
||||
Wand2,
|
||||
Zap,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
@ -146,6 +157,19 @@ import {
|
||||
import { normalizeAgentPresetModeConfig } from "@/lib/agents/preset-mode-config";
|
||||
import type { AgentPsdTemplateCategoryTagIndex } from "@/lib/agents/psd-template-category-tag-config";
|
||||
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
|
||||
import {
|
||||
compactVideoCanvasMarkers,
|
||||
VIDEO_CANVAS_MARKER_COLORS,
|
||||
VIDEO_CANVAS_MARKER_METADATA_KEY,
|
||||
VIDEO_CANVAS_PRIORITY_MARKERS,
|
||||
VIDEO_CANVAS_SYMBOL_MARKERS,
|
||||
VIDEO_CANVAS_TASK_MARKERS,
|
||||
normalizeVideoCanvasMarkers,
|
||||
type VideoCanvasMarkerKey,
|
||||
type VideoCanvasMarkerState,
|
||||
type VideoCanvasMarkerValue,
|
||||
type VideoCanvasSymbolMarkerId,
|
||||
} from "@/lib/canvas/video-canvas-markers";
|
||||
import {
|
||||
buildAgentWorkflowComposerContext,
|
||||
getAgentWorkflowProgress,
|
||||
@ -293,6 +317,8 @@ const IMPORTABLE_CANVAS_TOOL_TYPES = new Set<ToolType>([
|
||||
"text",
|
||||
]);
|
||||
|
||||
const DEFAULT_CANVAS_TEXT_COLOR = "#111827";
|
||||
|
||||
const VIDEO_CANVAS_INSERT_MENU_ITEMS: VideoCanvasInsertMenuItem[] = [
|
||||
{ id: "image", label: "图片", icon: FileImage, templateType: "input-image" },
|
||||
{ id: "text", label: "文字", icon: FileText, templateType: "text-node" },
|
||||
@ -792,6 +818,284 @@ const VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS =
|
||||
"border-[#d6def7] bg-[#eef2ff] text-[#3954db] shadow-[0_0_0_1px_rgba(91,118,254,0.14)] hover:bg-[#e7edff] dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900";
|
||||
const VIDEO_CANVAS_COMPACT_CONTROLS_CLASS =
|
||||
"rounded-full border border-[#dbe2ee] bg-white px-2 py-1 shadow-[0_0_0_1px_rgba(219,226,238,0.36),0_12px_26px_rgba(22,24,29,0.06)] dark:border-zinc-800 dark:bg-zinc-950";
|
||||
const VIDEO_CANVAS_MARKER_PANEL_ICON_MAP: Record<
|
||||
VideoCanvasSymbolMarkerId,
|
||||
LucideIcon
|
||||
> = {
|
||||
idea: Lightbulb,
|
||||
question: HelpCircle,
|
||||
warning: AlertTriangle,
|
||||
pin: Pin,
|
||||
heart: Heart,
|
||||
bolt: Zap,
|
||||
};
|
||||
|
||||
interface VideoCanvasMarkerPanelProps {
|
||||
hasSelection: boolean;
|
||||
markers: VideoCanvasMarkerState;
|
||||
onApply: (key: VideoCanvasMarkerKey, value: VideoCanvasMarkerValue) => void;
|
||||
onClear: () => void;
|
||||
onClose: () => void;
|
||||
selectedLabel: string;
|
||||
}
|
||||
|
||||
function VideoCanvasMarkerPanel({
|
||||
hasSelection,
|
||||
markers,
|
||||
onApply,
|
||||
onClear,
|
||||
onClose,
|
||||
selectedLabel,
|
||||
}: VideoCanvasMarkerPanelProps) {
|
||||
const isActive = (key: VideoCanvasMarkerKey, value: VideoCanvasMarkerValue) =>
|
||||
(markers as Record<string, unknown>)[key] === value;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-[28px] border border-[#dbe2ee] bg-white text-[#16181d] shadow-[0_0_0_1px_rgba(219,226,238,0.38),0_28px_80px_rgba(22,24,29,0.14)] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100">
|
||||
<div className="flex items-center justify-between border-b border-[#eef1f7] px-4 py-3 dark:border-zinc-800">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-sm">标记</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-[#8b94a6]">
|
||||
{hasSelection ? selectedLabel : "先选中一个模块"}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 w-8 rounded-full text-[#7a8497] hover:bg-[#f5f7fb]"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-1 border-b border-[#eef1f7] p-2 dark:border-zinc-800">
|
||||
{["标记", "贴纸", "插画"].map((tab) => (
|
||||
<button
|
||||
className={cn(
|
||||
"h-8 rounded-[12px] text-[12px] font-medium transition-colors",
|
||||
tab === "标记"
|
||||
? "bg-[#16181d] text-white shadow-sm dark:bg-zinc-100 dark:text-zinc-950"
|
||||
: "text-[#8b94a6] hover:bg-[#f7f9ff]"
|
||||
)}
|
||||
disabled={tab !== "标记"}
|
||||
key={tab}
|
||||
type="button"
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-5 p-4",
|
||||
!hasSelection && "pointer-events-none opacity-45"
|
||||
)}
|
||||
>
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
标签
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => (
|
||||
<button
|
||||
aria-label={`标签 ${entry.label}`}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||||
isActive("tagColor", entry.id) &&
|
||||
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||||
)}
|
||||
key={entry.id}
|
||||
onClick={() => onApply("tagColor", entry.id)}
|
||||
style={{ backgroundColor: entry.color }}
|
||||
type="button"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
优先级
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{VIDEO_CANVAS_PRIORITY_MARKERS.map((entry) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full border border-white font-semibold text-[13px] text-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||||
isActive("priority", entry.value) &&
|
||||
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||||
)}
|
||||
key={entry.value}
|
||||
onClick={() => onApply("priority", entry.value)}
|
||||
style={{ backgroundColor: entry.color }}
|
||||
type="button"
|
||||
>
|
||||
{entry.value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
任务
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{VIDEO_CANVAS_TASK_MARKERS.map((entry) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-full border border-white p-[3px] shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||||
isActive("task", entry.id) &&
|
||||
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||||
)}
|
||||
key={entry.id}
|
||||
onClick={() => onApply("task", entry.id)}
|
||||
style={{
|
||||
background: `conic-gradient(#10b981 ${entry.progress * 3.6}deg, #dfe6f3 0deg)`,
|
||||
}}
|
||||
title={entry.label}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-full w-full items-center justify-center rounded-full bg-white text-[#10b981]">
|
||||
{entry.progress >= 100 ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
旗帜
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full border border-white text-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||||
isActive("flagColor", entry.id) &&
|
||||
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||||
)}
|
||||
key={entry.id}
|
||||
onClick={() => onApply("flagColor", entry.id)}
|
||||
style={{ backgroundColor: entry.color }}
|
||||
title={entry.label}
|
||||
type="button"
|
||||
>
|
||||
<Flag className="h-4 w-4" fill="currentColor" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
星星
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full border border-white text-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||||
isActive("starColor", entry.id) &&
|
||||
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||||
)}
|
||||
key={entry.id}
|
||||
onClick={() => onApply("starColor", entry.id)}
|
||||
style={{ backgroundColor: entry.color }}
|
||||
title={entry.label}
|
||||
type="button"
|
||||
>
|
||||
<Star className="h-4 w-4" fill="currentColor" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
人像
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full border border-white text-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||||
isActive("avatarColor", entry.id) &&
|
||||
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||||
)}
|
||||
key={entry.id}
|
||||
onClick={() => onApply("avatarColor", entry.id)}
|
||||
style={{ backgroundColor: entry.color }}
|
||||
title={entry.label}
|
||||
type="button"
|
||||
>
|
||||
<UserRound className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
符号
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{VIDEO_CANVAS_SYMBOL_MARKERS.map((entry) => {
|
||||
const Icon = VIDEO_CANVAS_MARKER_PANEL_ICON_MAP[entry.id];
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-[14px] border border-[#e5eaf4] bg-white px-3 py-2 text-left text-[12px] font-medium text-[#4a5365] shadow-[0_8px_18px_rgba(15,23,42,0.05)] transition-all hover:border-[#cfdcff] hover:bg-[#f7f9ff]",
|
||||
isActive("symbol", entry.id) &&
|
||||
"border-[#9aaeff] bg-[#f3f6ff] text-[#3954db] ring-2 ring-[#5b76fe]/20"
|
||||
)}
|
||||
key={entry.id}
|
||||
onClick={() => onApply("symbol", entry.id)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: entry.soft, color: entry.color }}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
{entry.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t border-[#eef1f7] p-3 dark:border-zinc-800">
|
||||
<Button
|
||||
className="h-10 w-full rounded-[16px] border border-[#e3e8f3] bg-white text-[#5f687a] shadow-none hover:bg-[#f7f9ff]"
|
||||
disabled={!hasSelection}
|
||||
onClick={onClear}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
清空当前模块标记
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildScopedCanvasChatId(projectId: string, scope: "agent-mode") {
|
||||
const normalized = String(projectId || "")
|
||||
@ -1097,6 +1401,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
const addEdge = useCanvasStore((state) => state.addEdge);
|
||||
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||
const pushHistory = useCanvasStore((state) => state.pushHistory);
|
||||
const setGenerating = useCanvasStore((state) => state.setGenerating);
|
||||
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
|
||||
const requestImageNodeAction = useCanvasStore(
|
||||
@ -1125,6 +1430,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
});
|
||||
const [videoCanvasInsertMenu, setVideoCanvasInsertMenu] =
|
||||
useState<VideoCanvasInsertMenuState | null>(null);
|
||||
const [isVideoCanvasFileListOpen, setIsVideoCanvasFileListOpen] =
|
||||
useState(false);
|
||||
const [isVideoCanvasMarkerPanelOpen, setIsVideoCanvasMarkerPanelOpen] =
|
||||
useState(false);
|
||||
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
|
||||
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
|
||||
const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat");
|
||||
@ -1150,6 +1459,8 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
useEffect(() => {
|
||||
if (!isVideoMode) {
|
||||
setVideoCanvasInsertMenu(null);
|
||||
setIsVideoCanvasFileListOpen(false);
|
||||
setIsVideoCanvasMarkerPanelOpen(false);
|
||||
}
|
||||
}, [isVideoMode]);
|
||||
|
||||
@ -1360,6 +1671,26 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
: null,
|
||||
[selectedVideoWorkflowType]
|
||||
);
|
||||
const selectedVideoCanvasMarkers = useMemo(() => {
|
||||
const metadata = (selectedVideoWorkflowNode?.data as
|
||||
| { metadata?: unknown }
|
||||
| undefined)?.metadata;
|
||||
return normalizeVideoCanvasMarkers(
|
||||
isPlainObject(metadata)
|
||||
? metadata[VIDEO_CANVAS_MARKER_METADATA_KEY]
|
||||
: undefined
|
||||
);
|
||||
}, [selectedVideoWorkflowNode]);
|
||||
const selectedVideoCanvasMarkerLabel = useMemo(() => {
|
||||
if (!selectedVideoWorkflowNode) return "未选择模块";
|
||||
const data = selectedVideoWorkflowNode.data as {
|
||||
content?: unknown;
|
||||
title?: unknown;
|
||||
};
|
||||
const title = String(data.title || data.content || "").trim();
|
||||
if (title) return title.slice(0, 36);
|
||||
return selectedVideoWorkflowDefinition?.label || "当前模块";
|
||||
}, [selectedVideoWorkflowDefinition?.label, selectedVideoWorkflowNode]);
|
||||
const selectedVideoWorkflowQuickConnects = useMemo(
|
||||
() =>
|
||||
selectedVideoWorkflowType
|
||||
@ -1423,6 +1754,73 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
selectedVideoWorkflowDefinition?.label,
|
||||
selectedVideoWorkflowNode,
|
||||
]);
|
||||
const handleApplyVideoCanvasMarker = useCallback(
|
||||
(key: VideoCanvasMarkerKey, value: VideoCanvasMarkerValue) => {
|
||||
if (!selectedVideoWorkflowNode) {
|
||||
toast.info("请先选中一个模块");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentData = selectedVideoWorkflowNode.data;
|
||||
const currentMetadata = (currentData as { metadata?: unknown }).metadata;
|
||||
const metadata = isPlainObject(currentMetadata)
|
||||
? { ...currentMetadata }
|
||||
: {};
|
||||
const currentMarkers = normalizeVideoCanvasMarkers(
|
||||
metadata[VIDEO_CANVAS_MARKER_METADATA_KEY]
|
||||
);
|
||||
const nextMarkers: VideoCanvasMarkerState = { ...currentMarkers };
|
||||
const nextMarkerRecord = nextMarkers as Record<string, unknown>;
|
||||
|
||||
if (nextMarkerRecord[key] === value) {
|
||||
delete nextMarkerRecord[key];
|
||||
} else {
|
||||
nextMarkerRecord[key] = value;
|
||||
}
|
||||
|
||||
const compactMarkers = compactVideoCanvasMarkers(nextMarkers);
|
||||
if (Object.keys(compactMarkers).length > 0) {
|
||||
metadata[VIDEO_CANVAS_MARKER_METADATA_KEY] = compactMarkers;
|
||||
} else {
|
||||
delete metadata[VIDEO_CANVAS_MARKER_METADATA_KEY];
|
||||
}
|
||||
|
||||
updateNode(selectedVideoWorkflowNode.id, {
|
||||
data: {
|
||||
...currentData,
|
||||
metadata,
|
||||
} as CanvasNode["data"],
|
||||
});
|
||||
pushHistory();
|
||||
},
|
||||
[pushHistory, selectedVideoWorkflowNode, updateNode]
|
||||
);
|
||||
const handleClearVideoCanvasMarkers = useCallback(() => {
|
||||
if (!selectedVideoWorkflowNode) {
|
||||
toast.info("请先选中一个模块");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentData = selectedVideoWorkflowNode.data;
|
||||
const currentMetadata = (currentData as { metadata?: unknown }).metadata;
|
||||
const metadata = isPlainObject(currentMetadata)
|
||||
? { ...currentMetadata }
|
||||
: {};
|
||||
|
||||
if (!(VIDEO_CANVAS_MARKER_METADATA_KEY in metadata)) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete metadata[VIDEO_CANVAS_MARKER_METADATA_KEY];
|
||||
updateNode(selectedVideoWorkflowNode.id, {
|
||||
data: {
|
||||
...currentData,
|
||||
metadata,
|
||||
} as CanvasNode["data"],
|
||||
});
|
||||
pushHistory();
|
||||
toast.success("已清空当前模块标记");
|
||||
}, [pushHistory, selectedVideoWorkflowNode, updateNode]);
|
||||
const workflowGraph = useMemo(
|
||||
() => createWorkflowGraphContext(nodes, edges),
|
||||
[edges, nodes]
|
||||
@ -5613,6 +6011,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
|
||||
size: { width: 200, height: 100 },
|
||||
data: {
|
||||
color: DEFAULT_CANVAS_TEXT_COLOR,
|
||||
content: "双击编辑文本",
|
||||
},
|
||||
connections: [],
|
||||
@ -8898,6 +9297,51 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVideoMode && isVideoCanvasFileListOpen ? (
|
||||
<div
|
||||
className="absolute z-30 overflow-hidden rounded-[28px] border border-border/70 bg-background/95 shadow-[0_24px_80px_rgba(15,23,42,0.18)] backdrop-blur-xl"
|
||||
style={{
|
||||
top: DOCKED_PANEL_INSET,
|
||||
right: DOCKED_PANEL_INSET,
|
||||
bottom: `${FLOATING_BOTTOM_INSET + 76}px`,
|
||||
width: 380,
|
||||
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET * 2}px)`,
|
||||
}}
|
||||
>
|
||||
<CanvasFileListPanel
|
||||
buildProjectUrl={buildWorkspaceProjectUrl}
|
||||
createUrl={createNewCanvasUrl}
|
||||
currentId={id}
|
||||
onClose={() => setIsVideoCanvasFileListOpen(false)}
|
||||
workspaceLabel={workspaceLabel}
|
||||
workspaceType={activeCanvasWorkspaceType}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isVideoMode && isVideoCanvasMarkerPanelOpen ? (
|
||||
<div
|
||||
className="absolute z-30"
|
||||
data-canvas-overlay="true"
|
||||
style={{
|
||||
top: DOCKED_PANEL_INSET,
|
||||
right: DOCKED_PANEL_INSET,
|
||||
bottom: `${FLOATING_BOTTOM_INSET + 76}px`,
|
||||
width: 324,
|
||||
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET * 2}px)`,
|
||||
}}
|
||||
>
|
||||
<VideoCanvasMarkerPanel
|
||||
hasSelection={Boolean(selectedVideoWorkflowNode)}
|
||||
markers={selectedVideoCanvasMarkers}
|
||||
onApply={handleApplyVideoCanvasMarker}
|
||||
onClear={handleClearVideoCanvasMarkers}
|
||||
onClose={() => setIsVideoCanvasMarkerPanelOpen(false)}
|
||||
selectedLabel={selectedVideoCanvasMarkerLabel}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 5. Floating Toolbar (Bottom) */}
|
||||
<div
|
||||
className="-translate-x-1/2 absolute left-1/2 z-20"
|
||||
@ -8956,6 +9400,69 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
<TooltipContent side="top">平移 (H)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{isVideoMode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
|
||||
disabled={isSaving}
|
||||
onClick={handleSave}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">保存到云端</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{isVideoMode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
setIsVideoCanvasMarkerPanelOpen(false);
|
||||
setIsVideoCanvasFileListOpen(true);
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">云文件列表</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{isVideoMode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS,
|
||||
isVideoCanvasMarkerPanelOpen &&
|
||||
VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||
)}
|
||||
onClick={() => {
|
||||
setIsVideoCanvasFileListOpen(false);
|
||||
setIsVideoCanvasMarkerPanelOpen((open) => !open);
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Tag className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">标记</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{isVideoMode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@ -15,7 +15,145 @@ import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
import { Download, GitBranch, Minus, Plus } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Download,
|
||||
Flag,
|
||||
GitBranch,
|
||||
Heart,
|
||||
HelpCircle,
|
||||
Lightbulb,
|
||||
Minus,
|
||||
Pin,
|
||||
Plus,
|
||||
Star,
|
||||
Tag,
|
||||
type LucideIcon,
|
||||
UserRound,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getVideoCanvasMarkerColor,
|
||||
getVideoCanvasSymbolMarker,
|
||||
getVideoCanvasTaskMarker,
|
||||
normalizeVideoCanvasMarkers,
|
||||
VIDEO_CANVAS_MARKER_METADATA_KEY,
|
||||
VIDEO_CANVAS_PRIORITY_MARKERS,
|
||||
} from "@/lib/canvas/video-canvas-markers";
|
||||
|
||||
const VIDEO_CANVAS_SYMBOL_ICON_MAP: Record<string, LucideIcon> = {
|
||||
idea: Lightbulb,
|
||||
question: HelpCircle,
|
||||
warning: AlertTriangle,
|
||||
pin: Pin,
|
||||
heart: Heart,
|
||||
bolt: Zap,
|
||||
};
|
||||
|
||||
function getNodeVideoCanvasMarkers(node: CanvasNode) {
|
||||
const metadata = (node.data as { metadata?: unknown } | undefined)?.metadata;
|
||||
return normalizeVideoCanvasMarkers(
|
||||
metadata && typeof metadata === "object" && !Array.isArray(metadata)
|
||||
? (metadata as Record<string, unknown>)[VIDEO_CANVAS_MARKER_METADATA_KEY]
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
function VideoCanvasNodeMarkers({ node }: { node: CanvasNode }) {
|
||||
const markers = getNodeVideoCanvasMarkers(node);
|
||||
const tagColor = getVideoCanvasMarkerColor(markers.tagColor);
|
||||
const flagColor = getVideoCanvasMarkerColor(markers.flagColor);
|
||||
const starColor = getVideoCanvasMarkerColor(markers.starColor);
|
||||
const avatarColor = getVideoCanvasMarkerColor(markers.avatarColor);
|
||||
const task = getVideoCanvasTaskMarker(markers.task);
|
||||
const priority = VIDEO_CANVAS_PRIORITY_MARKERS.find(
|
||||
(entry) => entry.value === markers.priority
|
||||
);
|
||||
const symbol = getVideoCanvasSymbolMarker(markers.symbol);
|
||||
const SymbolIcon = markers.symbol
|
||||
? VIDEO_CANVAS_SYMBOL_ICON_MAP[markers.symbol]
|
||||
: null;
|
||||
const hasMarkers = Boolean(
|
||||
tagColor || flagColor || starColor || avatarColor || task || priority || symbol
|
||||
);
|
||||
|
||||
if (!hasMarkers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute -top-4 left-4 z-30 flex items-center gap-1.5">
|
||||
{tagColor ? (
|
||||
<span
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||
style={{ backgroundColor: tagColor.color, color: "#ffffff" }}
|
||||
title={`标签:${tagColor.label}`}
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
{priority ? (
|
||||
<span
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-white font-semibold text-[12px] text-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||
style={{ backgroundColor: priority.color }}
|
||||
title={`优先级:${priority.value}`}
|
||||
>
|
||||
{priority.value}
|
||||
</span>
|
||||
) : null}
|
||||
{task ? (
|
||||
<span
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-white p-[2px] shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||
style={{
|
||||
background: `conic-gradient(#10b981 ${task.progress * 3.6}deg, #dfe6f3 0deg)`,
|
||||
}}
|
||||
title={`任务:${task.label}`}
|
||||
>
|
||||
<span className="flex h-full w-full items-center justify-center rounded-full bg-white text-[#10b981]">
|
||||
{task.progress >= 100 ? <Check className="h-3 w-3" /> : null}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{flagColor ? (
|
||||
<span
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||
style={{ backgroundColor: flagColor.color, color: "#ffffff" }}
|
||||
title={`旗帜:${flagColor.label}`}
|
||||
>
|
||||
<Flag className="h-3.5 w-3.5" fill="currentColor" />
|
||||
</span>
|
||||
) : null}
|
||||
{starColor ? (
|
||||
<span
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||
style={{ backgroundColor: starColor.color, color: "#ffffff" }}
|
||||
title={`星标:${starColor.label}`}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" fill="currentColor" />
|
||||
</span>
|
||||
) : null}
|
||||
{avatarColor ? (
|
||||
<span
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||
style={{ backgroundColor: avatarColor.color, color: "#ffffff" }}
|
||||
title={`人像:${avatarColor.label}`}
|
||||
>
|
||||
<UserRound className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
{symbol && SymbolIcon ? (
|
||||
<span
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||
style={{ backgroundColor: symbol.soft, color: symbol.color }}
|
||||
title={`符号:${symbol.label}`}
|
||||
>
|
||||
<SymbolIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 性能优化:判断节点是否在视口内
|
||||
function isNodeInViewport(
|
||||
@ -505,36 +643,84 @@ export function CanvasWorkspace({
|
||||
return `M ${source.x} ${source.y} C ${source.x + controlOffset} ${source.y}, ${target.x - controlOffset} ${target.y}, ${target.x} ${target.y}`;
|
||||
}, []);
|
||||
|
||||
const renderedEdges = useMemo(() => {
|
||||
return edges
|
||||
const { renderedEdges, renderedMergeTrunks } = useMemo(() => {
|
||||
type RenderedWorkflowEdgeBase = {
|
||||
edge: CanvasEdge;
|
||||
sourcePoint: Position;
|
||||
targetPoint: Position;
|
||||
};
|
||||
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const baseEdges = edges
|
||||
.map((edge) => {
|
||||
const sourceNode = nodes.find((node) => node.id === edge.source);
|
||||
const targetNode = nodes.find((node) => node.id === edge.target);
|
||||
const sourceNode = nodeById.get(edge.source);
|
||||
const targetNode = nodeById.get(edge.target);
|
||||
if (!sourceNode || !targetNode) return null;
|
||||
const sourcePoint = toScreenPosition(getNodeOutputAnchor(sourceNode));
|
||||
const targetPoint = toScreenPosition(getNodeInputAnchor(targetNode));
|
||||
return {
|
||||
edge,
|
||||
sourcePoint,
|
||||
targetPoint,
|
||||
path: buildWorkflowPath(sourcePoint, targetPoint),
|
||||
midPoint: {
|
||||
x: (sourcePoint.x + targetPoint.x) / 2,
|
||||
y: (sourcePoint.y + targetPoint.y) / 2,
|
||||
},
|
||||
sourcePoint: toScreenPosition(getNodeOutputAnchor(sourceNode)),
|
||||
targetPoint: toScreenPosition(getNodeInputAnchor(targetNode)),
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
item
|
||||
): item is {
|
||||
edge: CanvasEdge;
|
||||
sourcePoint: Position;
|
||||
targetPoint: Position;
|
||||
path: string;
|
||||
midPoint: Position;
|
||||
} => Boolean(item)
|
||||
);
|
||||
.filter((item): item is RenderedWorkflowEdgeBase => Boolean(item));
|
||||
|
||||
const incomingEdges = new Map<string, RenderedWorkflowEdgeBase[]>();
|
||||
baseEdges.forEach((item) => {
|
||||
const group = incomingEdges.get(item.edge.target) ?? [];
|
||||
group.push(item);
|
||||
incomingEdges.set(item.edge.target, group);
|
||||
});
|
||||
|
||||
const mergePointByTarget = new Map<string, Position>();
|
||||
const renderedMergeTrunks: {
|
||||
targetNodeId: string;
|
||||
targetPoint: Position;
|
||||
mergePoint: Position;
|
||||
path: string;
|
||||
edgeIds: string[];
|
||||
}[] = [];
|
||||
|
||||
incomingEdges.forEach((items, targetNodeId) => {
|
||||
if (items.length <= 1) return;
|
||||
|
||||
const targetPoint = items[0].targetPoint;
|
||||
const averageSourceX =
|
||||
items.reduce((total, item) => total + item.sourcePoint.x, 0) / items.length;
|
||||
const averageDeltaX =
|
||||
items.reduce((total, item) => total + Math.abs(targetPoint.x - item.sourcePoint.x), 0) /
|
||||
items.length;
|
||||
const mergeDistance = Math.max(56, Math.min(180, averageDeltaX * 0.32));
|
||||
const direction = averageSourceX <= targetPoint.x ? -1 : 1;
|
||||
const mergePoint = {
|
||||
x: targetPoint.x + direction * mergeDistance,
|
||||
y: targetPoint.y,
|
||||
};
|
||||
|
||||
mergePointByTarget.set(targetNodeId, mergePoint);
|
||||
renderedMergeTrunks.push({
|
||||
targetNodeId,
|
||||
targetPoint,
|
||||
mergePoint,
|
||||
path: buildWorkflowPath(mergePoint, targetPoint),
|
||||
edgeIds: items.map((item) => item.edge.id),
|
||||
});
|
||||
});
|
||||
|
||||
const renderedEdges = baseEdges.map((item) => {
|
||||
const mergePoint = mergePointByTarget.get(item.edge.target);
|
||||
const visualTargetPoint = mergePoint ?? item.targetPoint;
|
||||
return {
|
||||
...item,
|
||||
mergePoint,
|
||||
path: buildWorkflowPath(item.sourcePoint, visualTargetPoint),
|
||||
midPoint: {
|
||||
x: (item.sourcePoint.x + visualTargetPoint.x) / 2,
|
||||
y: (item.sourcePoint.y + visualTargetPoint.y) / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { renderedEdges, renderedMergeTrunks };
|
||||
}, [buildWorkflowPath, edges, getNodeInputAnchor, getNodeOutputAnchor, nodes, toScreenPosition]);
|
||||
|
||||
// 处理画布点击(取消选择)
|
||||
@ -1300,6 +1486,7 @@ export function CanvasWorkspace({
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{isWorkflowCanvas ? <VideoCanvasNodeMarkers node={node} /> : null}
|
||||
{node.type === 'image' && (
|
||||
<ImageNode node={node} isSelected={isSelected} />
|
||||
)}
|
||||
@ -1483,6 +1670,29 @@ export function CanvasWorkspace({
|
||||
|
||||
{isWorkflowCanvas ? (
|
||||
<svg className="absolute inset-0 z-[1] h-full w-full overflow-visible">
|
||||
{renderedMergeTrunks.map(({ targetNodeId, path, edgeIds }) => {
|
||||
const isSelected = Boolean(selectedEdgeId && edgeIds.includes(selectedEdgeId));
|
||||
return (
|
||||
<g key={`merge-trunk-${targetNodeId}`} style={{ pointerEvents: "none" }}>
|
||||
{isSelected ? (
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="rgba(59, 130, 246, 0.18)"
|
||||
strokeLinecap="round"
|
||||
strokeWidth={10}
|
||||
/>
|
||||
) : null}
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke={isSelected ? WORKFLOW_EDGE_ACTIVE_COLOR : WORKFLOW_EDGE_COLOR}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={isSelected ? 4 : 3}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{renderedEdges.map(({ edge, path }) => {
|
||||
const isSelected = selectedEdgeId === edge.id;
|
||||
return (
|
||||
|
||||
@ -1,24 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import type { CanvasNode, TextNodeData } from "@/types/canvas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface TextNodeProps {
|
||||
node: CanvasNode;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TEXT_NODE_COLOR = "#111827";
|
||||
const PASTED_IMAGE_MAX_BYTES = 50 * 1024 * 1024;
|
||||
const PASTED_IMAGE_MAX_SIZE = 360;
|
||||
const PASTED_IMAGE_MIN_SIZE = 140;
|
||||
|
||||
function getPastedImageDimensions(file: File): Promise<{
|
||||
width: number;
|
||||
height: number;
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof window === "undefined") {
|
||||
resolve({ width: 300, height: 240, originalWidth: 0, originalHeight: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new window.Image();
|
||||
img.onload = () => {
|
||||
const originalWidth = img.naturalWidth || 300;
|
||||
const originalHeight = img.naturalHeight || 240;
|
||||
let width = originalWidth;
|
||||
let height = originalHeight;
|
||||
|
||||
if (width > PASTED_IMAGE_MAX_SIZE || height > PASTED_IMAGE_MAX_SIZE) {
|
||||
const scale = Math.min(PASTED_IMAGE_MAX_SIZE / width, PASTED_IMAGE_MAX_SIZE / height);
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
|
||||
if (width < PASTED_IMAGE_MIN_SIZE && height < PASTED_IMAGE_MIN_SIZE) {
|
||||
const scale = PASTED_IMAGE_MIN_SIZE / Math.max(1, Math.min(width, height));
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({ width, height, originalWidth, originalHeight });
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({ width: 300, height: 240, originalWidth: 0, originalHeight: 0 });
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export function TextNode({ node, isSelected }: TextNodeProps) {
|
||||
const data = node.data as TextNodeData;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(data.content);
|
||||
const [isUploadingPastedImage, setIsUploadingPastedImage] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||
const addNode = useCanvasStore((state) => state.addNode);
|
||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||
|
||||
// 进入编辑模式时聚焦
|
||||
useEffect(() => {
|
||||
@ -31,7 +84,11 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
|
||||
// 保存编辑
|
||||
const handleSave = () => {
|
||||
updateNode(node.id, {
|
||||
data: { ...data, content: editContent },
|
||||
data: {
|
||||
...data,
|
||||
color: data.color || DEFAULT_TEXT_NODE_COLOR,
|
||||
content: editContent,
|
||||
},
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
@ -48,6 +105,83 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handlePasteImages = useCallback(async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setIsUploadingPastedImage(true);
|
||||
let addedCount = 0;
|
||||
|
||||
try {
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
const file = files[index];
|
||||
if (!file.type.startsWith("image/")) continue;
|
||||
if (file.size > PASTED_IMAGE_MAX_BYTES) {
|
||||
toast.error("图片大小不能超过 50MB");
|
||||
continue;
|
||||
}
|
||||
|
||||
const ext = file.type.split("/")[1] || "png";
|
||||
const fileName =
|
||||
(file.name || "").trim() || `粘贴图片-${Date.now()}-${index + 1}.${ext}`;
|
||||
const title = fileName.replace(/\.[^.]+$/, "") || "粘贴图片";
|
||||
const dimensions = await getPastedImageDimensions(file);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const response = await fetchUploadWithRetry(formData);
|
||||
if (!response.ok) {
|
||||
throw new Error("上传失败");
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const imageUrl = typeof payload?.url === "string" ? payload.url : "";
|
||||
if (!imageUrl) {
|
||||
throw new Error("上传结果缺少图片地址");
|
||||
}
|
||||
|
||||
const assetId =
|
||||
typeof payload?.assetId === "string" && payload.assetId.trim()
|
||||
? payload.assetId.trim()
|
||||
: undefined;
|
||||
const newNodeId = addNode({
|
||||
type: "image",
|
||||
position: {
|
||||
x: node.position.x + node.size.width + 32 + index * 24,
|
||||
y: node.position.y + index * 24,
|
||||
},
|
||||
size: { width: dimensions.width, height: dimensions.height },
|
||||
data: {
|
||||
src: imageUrl,
|
||||
assetId,
|
||||
status: "completed",
|
||||
title,
|
||||
prompt: title,
|
||||
metadata: {
|
||||
source: "text-node-paste",
|
||||
sourceTextNodeId: node.id,
|
||||
originalWidth: dimensions.originalWidth,
|
||||
originalHeight: dimensions.originalHeight,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
|
||||
selectNode(newNodeId);
|
||||
addedCount += 1;
|
||||
} catch (_error) {
|
||||
toast.error("图片上传失败");
|
||||
}
|
||||
}
|
||||
|
||||
if (addedCount > 0) {
|
||||
toast.success(addedCount === 1 ? "已粘贴图片到画布" : `已粘贴 ${addedCount} 张图片到画布`);
|
||||
}
|
||||
} finally {
|
||||
setIsUploadingPastedImage(false);
|
||||
}
|
||||
}, [addNode, node.id, node.position.x, node.position.y, node.size.width, selectNode]);
|
||||
|
||||
const containerStyle = data.transparent
|
||||
? undefined
|
||||
: {
|
||||
@ -74,7 +208,7 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
|
||||
const textStyle = {
|
||||
fontSize: data.fontSize || 14,
|
||||
fontFamily: data.fontFamily || undefined,
|
||||
color: data.color || "inherit",
|
||||
color: data.color || DEFAULT_TEXT_NODE_COLOR,
|
||||
fontWeight: data.fontWeight || "normal",
|
||||
lineHeight: data.lineHeight || 1.35,
|
||||
letterSpacing:
|
||||
@ -100,6 +234,17 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
|
||||
ref={textareaRef}
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
const imageFiles = Array.from(e.clipboardData?.items || [])
|
||||
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
void handlePasteImages(imageFiles);
|
||||
}}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
@ -120,6 +265,12 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
|
||||
{data.content || '双击编辑文本'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && isUploadingPastedImage ? (
|
||||
<div className="absolute bottom-2 left-2 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-500 shadow-sm">
|
||||
正在粘贴图片...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isEditing && isSelected ? (
|
||||
<>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
177
lib/canvas/video-canvas-markers.ts
Normal file
177
lib/canvas/video-canvas-markers.ts
Normal file
@ -0,0 +1,177 @@
|
||||
export const VIDEO_CANVAS_MARKER_METADATA_KEY = "videoCanvasMarkers";
|
||||
|
||||
export type VideoCanvasMarkerColorId =
|
||||
| "red"
|
||||
| "orange"
|
||||
| "yellow"
|
||||
| "green"
|
||||
| "blue"
|
||||
| "purple"
|
||||
| "gray";
|
||||
|
||||
export type VideoCanvasTaskMarkerId =
|
||||
| "todo"
|
||||
| "quarter"
|
||||
| "half"
|
||||
| "mostly"
|
||||
| "blocked"
|
||||
| "done";
|
||||
|
||||
export type VideoCanvasSymbolMarkerId =
|
||||
| "idea"
|
||||
| "question"
|
||||
| "warning"
|
||||
| "pin"
|
||||
| "heart"
|
||||
| "bolt";
|
||||
|
||||
export type VideoCanvasMarkerKey =
|
||||
| "tagColor"
|
||||
| "priority"
|
||||
| "task"
|
||||
| "flagColor"
|
||||
| "starColor"
|
||||
| "avatarColor"
|
||||
| "symbol";
|
||||
|
||||
export type VideoCanvasMarkerValue =
|
||||
| VideoCanvasMarkerColorId
|
||||
| VideoCanvasTaskMarkerId
|
||||
| VideoCanvasSymbolMarkerId
|
||||
| number;
|
||||
|
||||
export interface VideoCanvasMarkerState {
|
||||
tagColor?: VideoCanvasMarkerColorId;
|
||||
priority?: number;
|
||||
task?: VideoCanvasTaskMarkerId;
|
||||
flagColor?: VideoCanvasMarkerColorId;
|
||||
starColor?: VideoCanvasMarkerColorId;
|
||||
avatarColor?: VideoCanvasMarkerColorId;
|
||||
symbol?: VideoCanvasSymbolMarkerId;
|
||||
}
|
||||
|
||||
export const VIDEO_CANVAS_MARKER_COLORS: Array<{
|
||||
id: VideoCanvasMarkerColorId;
|
||||
label: string;
|
||||
color: string;
|
||||
soft: string;
|
||||
}> = [
|
||||
{ id: "red", label: "红色", color: "#ef4444", soft: "#fee2e2" },
|
||||
{ id: "orange", label: "橙色", color: "#f97316", soft: "#ffedd5" },
|
||||
{ id: "yellow", label: "黄色", color: "#eab308", soft: "#fef9c3" },
|
||||
{ id: "green", label: "绿色", color: "#10b981", soft: "#d1fae5" },
|
||||
{ id: "blue", label: "蓝色", color: "#5b76fe", soft: "#e0e7ff" },
|
||||
{ id: "purple", label: "紫色", color: "#7c3aed", soft: "#ede9fe" },
|
||||
{ id: "gray", label: "灰色", color: "#9ca3af", soft: "#f3f4f6" },
|
||||
];
|
||||
|
||||
export const VIDEO_CANVAS_PRIORITY_MARKERS = VIDEO_CANVAS_MARKER_COLORS.map(
|
||||
(entry, index) => ({
|
||||
...entry,
|
||||
value: index + 1,
|
||||
})
|
||||
);
|
||||
|
||||
export const VIDEO_CANVAS_TASK_MARKERS: Array<{
|
||||
id: VideoCanvasTaskMarkerId;
|
||||
label: string;
|
||||
progress: number;
|
||||
}> = [
|
||||
{ id: "todo", label: "未开始", progress: 0 },
|
||||
{ id: "quarter", label: "25%", progress: 25 },
|
||||
{ id: "half", label: "50%", progress: 50 },
|
||||
{ id: "mostly", label: "75%", progress: 75 },
|
||||
{ id: "blocked", label: "暂停", progress: 88 },
|
||||
{ id: "done", label: "完成", progress: 100 },
|
||||
];
|
||||
|
||||
export const VIDEO_CANVAS_SYMBOL_MARKERS: Array<{
|
||||
id: VideoCanvasSymbolMarkerId;
|
||||
label: string;
|
||||
color: string;
|
||||
soft: string;
|
||||
}> = [
|
||||
{ id: "idea", label: "想法", color: "#f59e0b", soft: "#fef3c7" },
|
||||
{ id: "question", label: "疑问", color: "#3b82f6", soft: "#dbeafe" },
|
||||
{ id: "warning", label: "注意", color: "#ef4444", soft: "#fee2e2" },
|
||||
{ id: "pin", label: "固定", color: "#64748b", soft: "#f1f5f9" },
|
||||
{ id: "heart", label: "喜欢", color: "#ec4899", soft: "#fce7f3" },
|
||||
{ id: "bolt", label: "灵感", color: "#8b5cf6", soft: "#ede9fe" },
|
||||
];
|
||||
|
||||
const MARKER_COLOR_IDS = new Set(
|
||||
VIDEO_CANVAS_MARKER_COLORS.map((entry) => entry.id)
|
||||
);
|
||||
const TASK_MARKER_IDS = new Set(
|
||||
VIDEO_CANVAS_TASK_MARKERS.map((entry) => entry.id)
|
||||
);
|
||||
const SYMBOL_MARKER_IDS = new Set(
|
||||
VIDEO_CANVAS_SYMBOL_MARKERS.map((entry) => entry.id)
|
||||
);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeMarkerColorId(value: unknown) {
|
||||
const id = typeof value === "string" ? value.trim() : "";
|
||||
return MARKER_COLOR_IDS.has(id as VideoCanvasMarkerColorId)
|
||||
? (id as VideoCanvasMarkerColorId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeTaskMarkerId(value: unknown) {
|
||||
const id = typeof value === "string" ? value.trim() : "";
|
||||
return TASK_MARKER_IDS.has(id as VideoCanvasTaskMarkerId)
|
||||
? (id as VideoCanvasTaskMarkerId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeSymbolMarkerId(value: unknown) {
|
||||
const id = typeof value === "string" ? value.trim() : "";
|
||||
return SYMBOL_MARKER_IDS.has(id as VideoCanvasSymbolMarkerId)
|
||||
? (id as VideoCanvasSymbolMarkerId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function normalizeVideoCanvasMarkers(
|
||||
value: unknown
|
||||
): VideoCanvasMarkerState {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const priority = Number(value.priority);
|
||||
return {
|
||||
tagColor: normalizeMarkerColorId(value.tagColor),
|
||||
priority:
|
||||
Number.isInteger(priority) && priority >= 1 && priority <= 7
|
||||
? priority
|
||||
: undefined,
|
||||
task: normalizeTaskMarkerId(value.task),
|
||||
flagColor: normalizeMarkerColorId(value.flagColor),
|
||||
starColor: normalizeMarkerColorId(value.starColor),
|
||||
avatarColor: normalizeMarkerColorId(value.avatarColor),
|
||||
symbol: normalizeSymbolMarkerId(value.symbol),
|
||||
};
|
||||
}
|
||||
|
||||
export function compactVideoCanvasMarkers(
|
||||
markers: VideoCanvasMarkerState
|
||||
): VideoCanvasMarkerState {
|
||||
return Object.fromEntries(
|
||||
Object.entries(markers).filter(([, value]) => value !== undefined)
|
||||
) as VideoCanvasMarkerState;
|
||||
}
|
||||
|
||||
export function getVideoCanvasMarkerColor(id?: VideoCanvasMarkerColorId) {
|
||||
return VIDEO_CANVAS_MARKER_COLORS.find((entry) => entry.id === id) || null;
|
||||
}
|
||||
|
||||
export function getVideoCanvasTaskMarker(id?: VideoCanvasTaskMarkerId) {
|
||||
return VIDEO_CANVAS_TASK_MARKERS.find((entry) => entry.id === id) || null;
|
||||
}
|
||||
|
||||
export function getVideoCanvasSymbolMarker(id?: VideoCanvasSymbolMarkerId) {
|
||||
return VIDEO_CANVAS_SYMBOL_MARKERS.find((entry) => entry.id === id) || null;
|
||||
}
|
||||
12
lib/db/migrations/0070_flow_board_cloud_files.sql
Normal file
12
lib/db/migrations/0070_flow_board_cloud_files.sql
Normal file
@ -0,0 +1,12 @@
|
||||
ALTER TABLE "FlowBoardDraft"
|
||||
DROP CONSTRAINT IF EXISTS "FlowBoardDraft_userId_unique";
|
||||
|
||||
ALTER TABLE "FlowBoardDraft"
|
||||
ADD COLUMN IF NOT EXISTS "title" varchar(256) DEFAULT '未命名流程白板' NOT NULL;
|
||||
|
||||
UPDATE "FlowBoardDraft"
|
||||
SET "title" = '流程白板'
|
||||
WHERE "title" = '未命名流程白板';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "FlowBoardDraft_user_title_idx"
|
||||
ON "FlowBoardDraft"("userId", "title");
|
||||
@ -393,6 +393,13 @@
|
||||
"when": 1775869200000,
|
||||
"tag": "0069_add_flow_board_drafts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 56,
|
||||
"version": "7",
|
||||
"when": 1775908800000,
|
||||
"tag": "0070_flow_board_cloud_files",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -907,6 +907,7 @@ export async function getFlowBoardDraftByUserId({
|
||||
.select()
|
||||
.from(flowBoardDraft)
|
||||
.where(eq(flowBoardDraft.userId, userId))
|
||||
.orderBy(desc(flowBoardDraft.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
return result ?? null;
|
||||
@ -918,8 +919,53 @@ export async function getFlowBoardDraftByUserId({
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertFlowBoardDraftByUserId(params: {
|
||||
export async function listFlowBoardDraftsByUserId({
|
||||
userId,
|
||||
}: {
|
||||
userId: string;
|
||||
}): Promise<FlowBoardDraft[]> {
|
||||
try {
|
||||
return await db
|
||||
.select()
|
||||
.from(flowBoardDraft)
|
||||
.where(eq(flowBoardDraft.userId, userId))
|
||||
.orderBy(desc(flowBoardDraft.updatedAt));
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to list flow board drafts"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFlowBoardDraftById(params: {
|
||||
userId: string;
|
||||
draftId: string;
|
||||
}): Promise<FlowBoardDraft | null> {
|
||||
try {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(flowBoardDraft)
|
||||
.where(
|
||||
and(
|
||||
eq(flowBoardDraft.id, params.draftId),
|
||||
eq(flowBoardDraft.userId, params.userId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return result ?? null;
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to get flow board draft by id"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFlowBoardDraft(params: {
|
||||
userId: string;
|
||||
title: string;
|
||||
snapshot: FlowBoardSnapshotData;
|
||||
}): Promise<FlowBoardDraft | null> {
|
||||
try {
|
||||
@ -928,20 +974,104 @@ export async function upsertFlowBoardDraftByUserId(params: {
|
||||
.insert(flowBoardDraft)
|
||||
.values({
|
||||
userId: params.userId,
|
||||
title: params.title,
|
||||
snapshot: params.snapshot,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: flowBoardDraft.userId,
|
||||
set: {
|
||||
snapshot: params.snapshot,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
return result ?? null;
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to create flow board draft"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFlowBoardDraftById(params: {
|
||||
userId: string;
|
||||
draftId: string;
|
||||
title?: string;
|
||||
snapshot?: FlowBoardSnapshotData;
|
||||
}): Promise<FlowBoardDraft | null> {
|
||||
try {
|
||||
const updateData: Partial<typeof flowBoardDraft.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (params.title !== undefined) {
|
||||
updateData.title = params.title;
|
||||
}
|
||||
if (params.snapshot !== undefined) {
|
||||
updateData.snapshot = params.snapshot;
|
||||
}
|
||||
|
||||
const [result] = await db
|
||||
.update(flowBoardDraft)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(flowBoardDraft.id, params.draftId),
|
||||
eq(flowBoardDraft.userId, params.userId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return result ?? null;
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to update flow board draft"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFlowBoardDraftById(params: {
|
||||
userId: string;
|
||||
draftId: string;
|
||||
}): Promise<FlowBoardDraft | null> {
|
||||
try {
|
||||
const [result] = await db
|
||||
.delete(flowBoardDraft)
|
||||
.where(
|
||||
and(
|
||||
eq(flowBoardDraft.id, params.draftId),
|
||||
eq(flowBoardDraft.userId, params.userId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return result ?? null;
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to delete flow board draft"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertFlowBoardDraftByUserId(params: {
|
||||
userId: string;
|
||||
snapshot: FlowBoardSnapshotData;
|
||||
}): Promise<FlowBoardDraft | null> {
|
||||
try {
|
||||
const existing = await getFlowBoardDraftByUserId({
|
||||
userId: params.userId,
|
||||
});
|
||||
if (existing) {
|
||||
return await updateFlowBoardDraftById({
|
||||
userId: params.userId,
|
||||
draftId: existing.id,
|
||||
snapshot: params.snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
return await createFlowBoardDraft({
|
||||
userId: params.userId,
|
||||
title: "流程白板",
|
||||
snapshot: params.snapshot,
|
||||
});
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
|
||||
@ -964,8 +964,10 @@ export const flowBoardDraft = pgTable("FlowBoardDraft", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
userId: uuid("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" })
|
||||
.unique(),
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
title: varchar("title", { length: 256 })
|
||||
.notNull()
|
||||
.default("未命名流程白板"),
|
||||
snapshot: jsonb("snapshot")
|
||||
.$type<FlowBoardSnapshotData>()
|
||||
.notNull()
|
||||
|
||||
349
lib/flow-board/mind-map-import.ts
Normal file
349
lib/flow-board/mind-map-import.ts
Normal file
@ -0,0 +1,349 @@
|
||||
import type { PlaitElement, Point } from "@plait/core";
|
||||
import { MindLayoutType } from "@plait/layouts";
|
||||
import { createMindElement, type MindElement, type PlaitMind } from "@plait/mind";
|
||||
|
||||
export const FLOW_BOARD_MIND_MAP_IMPORT_ACCEPT =
|
||||
".md,.markdown,.mdown,.mkd,.opml,.mm,.xmind,text/markdown,text/plain,text/xml,application/xml";
|
||||
|
||||
type MindMapImportFormat = "markdown" | "opml" | "freemind" | "xmind";
|
||||
|
||||
type OutlineNode = {
|
||||
text: string;
|
||||
children: OutlineNode[];
|
||||
};
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
return String(value || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function getMindMapImportTitle(fileName: string) {
|
||||
const title = fileName
|
||||
.replace(/\.(md|markdown|mdown|mkd|opml|mm|xmind)$/i, "")
|
||||
.trim();
|
||||
return title || "导入思维导图";
|
||||
}
|
||||
|
||||
function detectMindMapImportFormat(file: File): MindMapImportFormat | null {
|
||||
const fileName = file.name.toLowerCase();
|
||||
if (
|
||||
fileName.endsWith(".md") ||
|
||||
fileName.endsWith(".markdown") ||
|
||||
fileName.endsWith(".mdown") ||
|
||||
fileName.endsWith(".mkd")
|
||||
) {
|
||||
return "markdown";
|
||||
}
|
||||
if (fileName.endsWith(".opml")) {
|
||||
return "opml";
|
||||
}
|
||||
if (fileName.endsWith(".mm")) {
|
||||
return "freemind";
|
||||
}
|
||||
if (fileName.endsWith(".xmind")) {
|
||||
return "xmind";
|
||||
}
|
||||
if (file.type === "text/markdown" || file.type === "text/plain") {
|
||||
return "markdown";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isSupportedMindMapImportFile(file: File) {
|
||||
return Boolean(detectMindMapImportFormat(file));
|
||||
}
|
||||
|
||||
function hasMarkdownHeading(markdownText: string) {
|
||||
return /^\s{0,3}#{1,6}\s+\S+/m.test(markdownText);
|
||||
}
|
||||
|
||||
function parseXmlDocument(xmlText: string, formatLabel: string) {
|
||||
if (typeof DOMParser === "undefined") {
|
||||
throw new Error(`${formatLabel} import requires DOMParser`);
|
||||
}
|
||||
|
||||
const document = new DOMParser().parseFromString(xmlText, "application/xml");
|
||||
if (document.getElementsByTagName("parsererror").length > 0) {
|
||||
throw new Error(`${formatLabel} XML parse failed`);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
function getDirectChildrenByTag(element: Element, tagName: string) {
|
||||
const normalizedTagName = tagName.toLowerCase();
|
||||
return Array.from(element.children).filter(
|
||||
(child) => child.tagName.toLowerCase() === normalizedTagName
|
||||
);
|
||||
}
|
||||
|
||||
function getDirectChildrenByLocalName(element: Element, localName: string) {
|
||||
const normalizedLocalName = localName.toLowerCase();
|
||||
return Array.from(element.children).filter(
|
||||
(child) =>
|
||||
(child.localName || child.tagName).toLowerCase() === normalizedLocalName
|
||||
);
|
||||
}
|
||||
|
||||
function createMindFromOutline(root: OutlineNode): PlaitMind {
|
||||
const build = (node: OutlineNode): MindElement => {
|
||||
const element = createMindElement(node.text || "未命名节点", {});
|
||||
element.children = node.children.map(build);
|
||||
return element;
|
||||
};
|
||||
|
||||
const mind = createMindElement(root.text || "中心主题", {
|
||||
layout: MindLayoutType.right,
|
||||
}) as PlaitMind;
|
||||
mind.isRoot = true;
|
||||
mind.type = "mindmap";
|
||||
mind.points = [[0, 0]] as Point[];
|
||||
mind.children = root.children.map(build);
|
||||
return mind;
|
||||
}
|
||||
|
||||
function parseOpmlOutlineNode(element: Element): OutlineNode {
|
||||
const text =
|
||||
normalizeText(element.getAttribute("text")) ||
|
||||
normalizeText(element.getAttribute("title")) ||
|
||||
"未命名节点";
|
||||
|
||||
return {
|
||||
text,
|
||||
children: getDirectChildrenByTag(element, "outline").map(parseOpmlOutlineNode),
|
||||
};
|
||||
}
|
||||
|
||||
function parseOpmlToMind(xmlText: string, fallbackTitle: string): PlaitMind {
|
||||
const document = parseXmlDocument(xmlText, "OPML");
|
||||
const body = document.getElementsByTagName("body")[0];
|
||||
if (!body) {
|
||||
throw new Error("OPML body not found");
|
||||
}
|
||||
|
||||
const headTitle = normalizeText(document.getElementsByTagName("title")[0]?.textContent);
|
||||
const topOutlines = getDirectChildrenByTag(body, "outline").map(parseOpmlOutlineNode);
|
||||
if (topOutlines.length === 0) {
|
||||
throw new Error("OPML outline not found");
|
||||
}
|
||||
|
||||
const root =
|
||||
topOutlines.length === 1
|
||||
? topOutlines[0]
|
||||
: {
|
||||
text: headTitle || fallbackTitle || "OPML 思维导图",
|
||||
children: topOutlines,
|
||||
};
|
||||
|
||||
return createMindFromOutline(root);
|
||||
}
|
||||
|
||||
function getFreeMindNodeText(element: Element) {
|
||||
const attrText = normalizeText(element.getAttribute("TEXT"));
|
||||
if (attrText) {
|
||||
return attrText;
|
||||
}
|
||||
|
||||
const richContent = getDirectChildrenByTag(element, "richcontent").find(
|
||||
(child) => (child.getAttribute("TYPE") || "").toUpperCase() === "NODE"
|
||||
);
|
||||
return normalizeText(richContent?.textContent) || "未命名节点";
|
||||
}
|
||||
|
||||
function parseFreeMindNode(element: Element): OutlineNode {
|
||||
return {
|
||||
text: getFreeMindNodeText(element),
|
||||
children: getDirectChildrenByTag(element, "node").map(parseFreeMindNode),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFreeMindToMind(xmlText: string): PlaitMind {
|
||||
const document = parseXmlDocument(xmlText, "FreeMind");
|
||||
const rootElement = document.documentElement;
|
||||
const rootNode =
|
||||
rootElement.tagName.toLowerCase() === "map"
|
||||
? getDirectChildrenByTag(rootElement, "node")[0]
|
||||
: rootElement.tagName.toLowerCase() === "node"
|
||||
? rootElement
|
||||
: null;
|
||||
|
||||
if (!rootNode) {
|
||||
throw new Error("FreeMind root node not found");
|
||||
}
|
||||
|
||||
return createMindFromOutline(parseFreeMindNode(rootNode));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getXmindTopicTitle(topic: Record<string, unknown>) {
|
||||
const title = normalizeText(String(topic.title || topic.text || ""));
|
||||
return title || "未命名节点";
|
||||
}
|
||||
|
||||
function getXmindJsonChildTopics(topic: Record<string, unknown>) {
|
||||
const children = topic.children;
|
||||
if (Array.isArray(children)) {
|
||||
return children.filter(isRecord);
|
||||
}
|
||||
if (!isRecord(children)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.values(children).flatMap((value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(isRecord);
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return [value];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function parseXmindJsonTopic(topic: Record<string, unknown>): OutlineNode {
|
||||
return {
|
||||
text: getXmindTopicTitle(topic),
|
||||
children: getXmindJsonChildTopics(topic).map(parseXmindJsonTopic),
|
||||
};
|
||||
}
|
||||
|
||||
function parseXmindJsonToMind(jsonText: string, fallbackTitle: string) {
|
||||
const parsed = JSON.parse(jsonText) as unknown;
|
||||
const sheets = Array.isArray(parsed) ? parsed.filter(isRecord) : [];
|
||||
const sheetRoots = sheets
|
||||
.map((sheet) => {
|
||||
const rootTopic = sheet.rootTopic;
|
||||
return isRecord(rootTopic) ? parseXmindJsonTopic(rootTopic) : null;
|
||||
})
|
||||
.filter((sheet): sheet is OutlineNode => Boolean(sheet));
|
||||
|
||||
if (sheetRoots.length === 0) {
|
||||
throw new Error("XMind content.json rootTopic not found");
|
||||
}
|
||||
|
||||
const root =
|
||||
sheetRoots.length === 1
|
||||
? sheetRoots[0]
|
||||
: {
|
||||
text: fallbackTitle || "XMind 思维导图",
|
||||
children: sheetRoots,
|
||||
};
|
||||
|
||||
return createMindFromOutline(root);
|
||||
}
|
||||
|
||||
function getDirectXmindXmlTitle(element: Element) {
|
||||
return (
|
||||
normalizeText(getDirectChildrenByLocalName(element, "title")[0]?.textContent) ||
|
||||
normalizeText(element.getAttribute("title")) ||
|
||||
normalizeText(element.getAttribute("TEXT")) ||
|
||||
"未命名节点"
|
||||
);
|
||||
}
|
||||
|
||||
function getXmindXmlTopicChildren(element: Element) {
|
||||
const childrenContainers = getDirectChildrenByLocalName(element, "children");
|
||||
return childrenContainers.flatMap((childrenContainer) =>
|
||||
getDirectChildrenByLocalName(childrenContainer, "topics").flatMap((topics) =>
|
||||
getDirectChildrenByLocalName(topics, "topic")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function parseXmindXmlTopic(element: Element): OutlineNode {
|
||||
return {
|
||||
text: getDirectXmindXmlTitle(element),
|
||||
children: getXmindXmlTopicChildren(element).map(parseXmindXmlTopic),
|
||||
};
|
||||
}
|
||||
|
||||
function parseXmindXmlToMind(xmlText: string, fallbackTitle: string) {
|
||||
const document = parseXmlDocument(xmlText, "XMind");
|
||||
const sheets = Array.from(document.getElementsByTagName("*")).filter(
|
||||
(element) => (element.localName || element.tagName).toLowerCase() === "sheet"
|
||||
);
|
||||
const sheetRoots = sheets
|
||||
.map((sheet) => getDirectChildrenByLocalName(sheet, "topic")[0])
|
||||
.filter((topic): topic is Element => Boolean(topic))
|
||||
.map(parseXmindXmlTopic);
|
||||
|
||||
if (sheetRoots.length === 0) {
|
||||
const rootTopic = Array.from(document.getElementsByTagName("*")).find(
|
||||
(element) => (element.localName || element.tagName).toLowerCase() === "topic"
|
||||
);
|
||||
if (!rootTopic) {
|
||||
throw new Error("XMind content.xml topic not found");
|
||||
}
|
||||
return createMindFromOutline(parseXmindXmlTopic(rootTopic));
|
||||
}
|
||||
|
||||
const root =
|
||||
sheetRoots.length === 1
|
||||
? sheetRoots[0]
|
||||
: {
|
||||
text: fallbackTitle || "XMind 思维导图",
|
||||
children: sheetRoots,
|
||||
};
|
||||
|
||||
return createMindFromOutline(root);
|
||||
}
|
||||
|
||||
async function parseXmindToMind(file: File, fallbackTitle: string) {
|
||||
const { default: JSZip } = await import("jszip");
|
||||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||
const contentJsonFile =
|
||||
zip.file("content.json") ||
|
||||
zip.file(/(^|\/)content\.json$/i).find((entry) => !entry.dir);
|
||||
if (contentJsonFile) {
|
||||
return parseXmindJsonToMind(await contentJsonFile.async("string"), fallbackTitle);
|
||||
}
|
||||
|
||||
const contentXmlFile =
|
||||
zip.file("content.xml") ||
|
||||
zip.file(/(^|\/)content\.xml$/i).find((entry) => !entry.dir);
|
||||
if (contentXmlFile) {
|
||||
return parseXmindXmlToMind(await contentXmlFile.async("string"), fallbackTitle);
|
||||
}
|
||||
|
||||
throw new Error("XMind content file not found");
|
||||
}
|
||||
|
||||
export async function parseMindMapFileToDrawnixMind(
|
||||
file: File,
|
||||
fallbackTitle = getMindMapImportTitle(file.name)
|
||||
): Promise<PlaitElement & { points?: Point[] }> {
|
||||
const format = detectMindMapImportFormat(file);
|
||||
if (!format) {
|
||||
throw new Error("Unsupported mind map import file");
|
||||
}
|
||||
|
||||
if (format === "xmind") {
|
||||
return parseXmindToMind(file, fallbackTitle) as Promise<
|
||||
PlaitElement & { points?: Point[] }
|
||||
>;
|
||||
}
|
||||
|
||||
const fileText = await file.text();
|
||||
if (!fileText.trim()) {
|
||||
throw new Error("Mind map import file is empty");
|
||||
}
|
||||
|
||||
if (format === "markdown") {
|
||||
const { parseMarkdownToDrawnix } = await import(
|
||||
"@plait-board/markdown-to-drawnix"
|
||||
);
|
||||
return parseMarkdownToDrawnix(
|
||||
fileText,
|
||||
hasMarkdownHeading(fileText) ? undefined : fallbackTitle
|
||||
) as unknown as PlaitElement & { points?: Point[] };
|
||||
}
|
||||
|
||||
if (format === "opml") {
|
||||
return parseOpmlToMind(fileText, fallbackTitle) as PlaitElement & {
|
||||
points?: Point[];
|
||||
};
|
||||
}
|
||||
|
||||
return parseFreeMindToMind(fileText) as PlaitElement & { points?: Point[] };
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
import type { CanvasNode, NodeType, Position } from "@/types/canvas";
|
||||
|
||||
const DEFAULT_VIDEO_WORKFLOW_TEXT_COLOR = "#111827";
|
||||
|
||||
export type VideoWorkflowSectionId =
|
||||
| "input"
|
||||
| "analysis"
|
||||
@ -651,6 +653,7 @@ export function buildVideoWorkflowCanvasNode(
|
||||
? "在这里粘贴长篇剧情、人物关系、世界观设定或章节梗概"
|
||||
: "输入脚本、镜头、画面意图或视频需求",
|
||||
fontSize: isNovelInput ? 16 : 15,
|
||||
color: DEFAULT_VIDEO_WORKFLOW_TEXT_COLOR,
|
||||
lineHeight: 1.5,
|
||||
borderWidth: 1,
|
||||
borderColor: "#d4d4d8",
|
||||
|
||||
@ -39,6 +39,7 @@
|
||||
"@langchain/langgraph": "^1.1.3",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.200.0",
|
||||
"@plait-board/markdown-to-drawnix": "0.0.7",
|
||||
"@plait-board/react-board": "0.4.0-2",
|
||||
"@plait-board/react-text": "0.4.0-2",
|
||||
"@plait/common": "^0.92.1",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -65,6 +65,9 @@ importers:
|
||||
'@opentelemetry/api-logs':
|
||||
specifier: ^0.200.0
|
||||
version: 0.200.0
|
||||
'@plait-board/markdown-to-drawnix':
|
||||
specifier: 0.0.7
|
||||
version: 0.0.7
|
||||
'@plait-board/react-board':
|
||||
specifier: 0.4.0-2
|
||||
version: 0.4.0-2(immer@11.1.3)(is-hotkey@0.2.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user